#!/usr/bin/env python3 """MAINT-20: prove a comment/docstring cut changed no code, and show the ratio. python3 goodwe_controller/tools/maint20_same_code.py [BASE_REF] [FILE ...] For each FILE (default: every goodwe_controller/app/*.py that differs from BASE_REF, default release/1.0) the ast.dump() of the tree with docstrings removed must be identical at BASE_REF and in the working tree, and so must the `python -OO` compiled code (a wholly removed docstring leaves a no-op there). An added or removed app/*.py file is a code difference. The diff against BASE_REF may touch only goodwe_controller/app/*.py, goodwe_controller/DECISIONS/*.md and this script. Also prints code lines vs comment-only + docstring lines (whole lines, via tokenize) before and after, and the docstrings removed entirely or added. Run from the repository root. Exit 1 on any difference. """ import ast import io import os import re import subprocess import sys import tokenize SCOPES = (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef) def docstring(n): if isinstance(n, SCOPES) and n.body: e = n.body[0] if (isinstance(e, ast.Expr) and isinstance(e.value, ast.Constant) and isinstance(e.value.value, str)): return e return None def dump(src): tree = ast.parse(src) for n in ast.walk(tree): if docstring(n) is not None: n.body = n.body[1:] or [ast.Pass()] return ast.dump(tree) def counts(src): doc = set() for n in ast.walk(ast.parse(src)): e = docstring(n) if e is not None: doc.update(range(e.lineno, e.end_lineno + 1)) skip = (tokenize.NL, tokenize.NEWLINE, tokenize.INDENT, tokenize.DEDENT, tokenize.ENDMARKER, tokenize.COMMENT) code, com = set(), set() for tk in tokenize.generate_tokens(io.StringIO(src).readline): if tk.type == tokenize.COMMENT: com.add(tk.start[0]) elif tk.type not in skip: code.update(range(tk.start[0], tk.end[0] + 1)) code -= doc return len(code), len(doc) + len(com - code - doc) def qualnames(src): out = {} def walk(node, prefix): for n in ast.iter_child_nodes(node): if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): q = prefix + n.name out[q] = docstring(n) is not None walk(n, q + ".") out[""] = docstring(ast.parse(src)) is not None walk(ast.parse(src), "") return out def code_sig(co): consts = tuple(code_sig(c) if hasattr(c, "co_code") else c for c in co.co_consts) return (co.co_name, co.co_code, consts, co.co_names, co.co_varnames, co.co_freevars, co.co_cellvars, co.co_flags, co.co_argcount, co.co_stacksize, co.co_exceptiontable) def oo_same(old, new): return (code_sig(compile(old, "f", "exec", optimize=2)) == code_sig(compile(new, "f", "exec", optimize=2))) def git(*a): return subprocess.run(["git", *a], check=True, capture_output=True, text=True).stdout def exists(base, f): return subprocess.run(["git", "cat-file", "-e", f"{base}:{f}"], capture_output=True).returncode == 0 ALLOWED = re.compile(r"^goodwe_controller/(app/[^/]+\.py|DECISIONS/[^/]+\.md|" r"tools/maint20_same_code\.py)$") def ratio(m, c): return f"{m / c:.2f}" if c else "n/a" def main(argv): base = argv[0] if argv else "release/1.0" bad = 0 print(f"base {base} = {git('rev-parse', base).strip()}") changed = git("diff", "--name-only", base).split() for f in changed: if not ALLOWED.match(f): print(f"FAIL: diff touches {f}, outside app/*.py, DECISIONS/*.md " "and this script") bad += 1 files = argv[1:] or [f for f in changed if re.match( r"^goodwe_controller/app/[^/]+\.py$", f)] for f in files: in_base, in_head = exists(base, f), os.path.exists(f) if in_base != in_head: print(f"CODE DIFFERS {f} " f"{'removed' if in_base else 'added'} file") bad += 1 continue old = git("show", f"{base}:{f}") new = open(f, encoding="utf-8").read() same = dump(old) == dump(new) oo = oo_same(old, new) (c0, m0), (c1, m1) = counts(old), counts(new) print(f"{'SAME CODE' if same else 'CODE DIFFERS'} {f} " f"code {c0}->{c1} comment+doc {m0}->{m1} " f"ratio {ratio(m0, c0)}->{ratio(m1, c1)} " f"-OO {'same' if oo else 'DIFFERS'}") bad += (not same) + (not oo) qo, qn = qualnames(old), qualnames(new) for q in sorted(q for q in qo if qo[q] and not qn.get(q, True)): print(f" docstring REMOVED: {f}:{q}") for q in sorted(q for q in qn if qn[q] and not qo.get(q, True)): print(f" docstring ADDED: {f}:{q}") print("OK: no code change" if not bad else f"FAIL: {bad} problem(s)") return 1 if bad else 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))