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>
143 lines
5.4 KiB
Python
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:]))
|