Files
goodwe-addon/goodwe_controller/tools/maint21_same_ast.py
T
glenn schrooyenandClaude 0d49027daa MAINT-21: tests read and patch the Controller through tools/main_files.py
Prepares the split of app/main.py's Controller into files by job, with no app
change. tools/main_files.py gives a test the text and the AST of main.py plus
the files split from it, and `live`, which sets a global in every file whose
functions read it (and raises when none does). Tests that read main.py's
source, and tests and replay tools that patch a main global (datetime,
compute, resolve, _mono, WRITE_BUDGET_S, ...), go through it, so they pass on
this layout and on the split one. test_split_patches.py fails when a test or
tool patches an app.main attribute that main.py no longer reads, or that
another file also reads. tools/maint21_same_ast.py proves every function,
method and module-level assignment of main.py at the base is found unchanged
in its new file.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 00:27:07 +01:00

143 lines
5.4 KiB
Python

#!/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.<name>`).
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:]))