#!/usr/bin/env python3 """MAINT-21: prove that splitting app/main.py moved code and changed none. python3 goodwe_controller/tools/maint21_same_ast.py [BASE_REF] [--docstrings] Every module-level def, class and assignment of app/main.py at BASE_REF (default release/1.0), and every method of its `Controller`, is looked up by qualified name in the working tree: app/main.py, app/rows.py and the mixin classes of app/ctl_*.py (a mixin's methods count as `Controller.`). Each must be found exactly once with an identical `ast.dump()`, docstrings removed unless --docstrings. Allowed extras: the `Controller` class header, `_LOG = logging.getLogger("goodwe")` in a module that logs, and import lines. Prints one line per file and `OK` or the differences. The diff against BASE_REF may touch only goodwe_controller/app, DECISIONS, test_*.py and tools. Run from the repository root on a clean checkout (untracked files are read, but the diff check only sees tracked ones). Exit 1 on any difference. """ import ast import collections import glob import os import re import subprocess import sys APP = "goodwe_controller/app" MIXIN = re.compile(r"^[A-Z][A-Za-z]*Mixin$") def git(*a): return subprocess.run(["git", *a], check=True, capture_output=True, text=True).stdout def bare(n, docstrings): if not docstrings: for x in ast.walk(n): if isinstance(x, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and x.body: e = x.body[0] if (isinstance(e, ast.Expr) and isinstance(e.value, ast.Constant) and isinstance(e.value.value, str)): x.body = x.body[1:] or [ast.Pass()] return ast.dump(n) def key_of(n): if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): return n.name if isinstance(n, ast.Assign): return "=" + ",".join(ast.unparse(t) for t in n.targets) if isinstance(n, ast.AnnAssign): return "=" + ast.unparse(n.target) return "?" + ast.unparse(n)[:60] def units(src, docstrings, mixins): """{qualified name: [dump, ...]} for one file's text.""" out = collections.defaultdict(list) tree = ast.parse(src) for n in tree.body: if isinstance(n, (ast.Import, ast.ImportFrom)): continue if (isinstance(n, ast.Expr) and isinstance(n.value, ast.Constant) and isinstance(n.value.value, str)): continue if isinstance(n, ast.ClassDef) and ( n.name == "Controller" or (mixins and MIXIN.match(n.name))): for m in n.body: out["Controller." + key_of(m)].append(bare(m, docstrings)) continue out[key_of(n)].append(bare(n, docstrings)) return out def main(argv): docstrings = "--docstrings" in argv argv = [a for a in argv if not a.startswith("--")] base = argv[0] if argv else "release/1.0" bad = 0 print(f"base {base} = {git('rev-parse', base).strip()}") for f in git("diff", "--name-only", base).split(): if not re.match(r"^goodwe_controller/(app/[^/]+|DECISIONS/[^/]+|" r"test_[^/]+\.py|tools/[^/]+)$", f): print(f"FAIL: diff touches {f}") bad += 1 old = units(git("show", f"{base}:{APP}/main.py"), docstrings, False) files = ["main.py", "rows.py"] + sorted( os.path.basename(p) for p in glob.glob(f"{APP}/ctl_*.py")) where = collections.defaultdict(list) # key -> [(file, dump)] for fn in files: p = f"{APP}/{fn}" if not os.path.exists(p): continue for k, dumps in units(open(p, encoding="utf-8").read(), docstrings, True).items(): for d in dumps: where[k].append((fn, d)) found = collections.Counter() problems = collections.defaultdict(list) for k, dumps in old.items(): if k == "Controller": continue got = where.get(k, []) if k == "=_LOG": # one per module that logs, all the same got = got[:1] * len(dumps) if all( d == dumps[0] for _, d in got) else [] if len(got) != len(dumps): problems["?"].append(f"{k}: {len(dumps)} in base, " f"{len(got)} in head") continue for (fn, d), want in zip(got, dumps): if d != want: problems[fn].append(f"{k}: AST differs") else: found[fn] += 1 for k, got in where.items(): if k not in old and k != "=_LOG": for fn, _ in got: problems[fn].append(f"{k}: not in base") for fn in files: if not os.path.exists(f"{APP}/{fn}"): continue if problems.get(fn): print(f"DIFFERS {fn}") for line in problems[fn]: print(" ", line) bad += len(problems[fn]) else: print(f"OK {fn} {found[fn]} units identical") for line in problems.get("?", []): print("DIFFERS ", line) bad += 1 total = sum(found.values()) print(f"base units {sum(len(v) for k, v in old.items() if k != 'Controller')}" f", identical in head {total}") print("OK: code moved, none changed" if not bad else f"FAIL: {bad} problem(s)") return 1 if bad else 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))