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>
This commit is contained in:
co-authored by
Claude
parent
56fc41fffa
commit
0d49027daa
@@ -0,0 +1,142 @@
|
||||
#!/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:]))
|
||||
Reference in New Issue
Block a user