Files
goodwe-addon/goodwe_controller/test_split_patches.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

182 lines
7.1 KiB
Python

"""MAINT-21 tripwire: a test must patch a Controller global where it is read.
The Controller's code lives in app/main.py and in the files split from it
(app/ctl_*.py, app/rows.py). A function reads a module global from its own
file, so `app.main.NAME = fake` changes only the code that is still in
main.py. This file fails when a test or tool still does that:
1. it patches an `app.main` attribute that main.py's own functions no
longer read (a stale patch, silently inert), or
2. it patches an `app.main` attribute that another file also reads (the
patch reaches only part of the code).
The way to patch is `tools/main_files.py`: `main_files.live.NAME = fake` sets
NAME in every file that reads it and raises when none does.
Part 3 runs the scan on spoilt sources and the patch helper on the real tree.
Read MUTATION.md for the habit this follows. `python3 test_split_patches.py`
"""
import ast
import glob
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
import main_files # noqa: E402
fails = []
ALIASES = {"m", "M", "main", "app_main", "_main", "_main_mod", "main_mod"}
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def main_aliases(tree):
"""Names bound to the module app.main in one file."""
al = set()
for n in ast.walk(tree):
if isinstance(n, ast.Import):
al.update(a.asname for a in n.names
if a.name == "app.main" and a.asname)
elif isinstance(n, ast.ImportFrom) and n.module == "app":
al.update(a.asname or a.name for a in n.names if a.name == "main")
for n in ast.walk(tree):
if (isinstance(n, ast.Assign) and isinstance(n.value, ast.Name)
and n.value.id in al):
al.update(t.id for t in n.targets if isinstance(t, ast.Name))
# `for mod in (app_main, homeview): mod.datetime = fake`
if (isinstance(n, ast.For) and isinstance(n.target, ast.Name)
and isinstance(n.iter, (ast.Tuple, ast.List))
and any(isinstance(e, ast.Name) and e.id in al
for e in n.iter.elts)):
al.add(n.target.id)
return al
def flat(targets):
"""The assignment targets, tuples and lists taken apart."""
for t in targets:
if isinstance(t, (ast.Tuple, ast.List)):
yield from flat(t.elts)
else:
yield t
def patches(src):
"""[(line, name)]: every attribute of app.main a file assigns, or hands to
setattr / a patching helper as `(app.main alias, "name", value)`."""
tree = ast.parse(src)
al = main_aliases(tree)
out = []
for n in ast.walk(tree):
targets = []
if isinstance(n, ast.Assign):
targets = n.targets
elif isinstance(n, (ast.AugAssign, ast.AnnAssign)):
targets = [n.target]
for t in flat(targets):
# `app_main.NAME = x`, or `rig.m.NAME = x` where rig did
# `import app.main as m`
if isinstance(t, ast.Attribute) and (
(isinstance(t.value, ast.Name) and t.value.id in al)
or (isinstance(t.value, ast.Attribute)
and t.value.attr in ALIASES)):
out.append((n.lineno, t.attr))
if (isinstance(n, ast.Call) and len(n.args) >= 3
and isinstance(n.args[0], ast.Name) and n.args[0].id in al
and isinstance(n.args[1], ast.Constant)
and isinstance(n.args[1].value, str)
and ast.unparse(n.func) != "getattr"):
out.append((n.lineno, n.args[1].value))
return out
def problems(src, where="<source>", readers=main_files.reading):
"""Messages for each patch in `src` that the rule above rejects."""
out = []
for line, name in patches(src):
who = readers(name)
if "main" not in who:
out.append(f"{where}:{line} patches main.{name}, which main.py "
f"does not read" + (f" (read by {who})" if who else ""))
elif who != ["main"]:
out.append(f"{where}:{line} patches main.{name}, which "
f"{[w for w in who if w != 'main']} also read: use "
f"main_files.live.{name}")
return out
# --------------------------------------------------------------------------- #
print("1. no test and no tool patches a main global the code reads elsewhere")
sites = bad = 0
for path in sorted(glob.glob(os.path.join(HERE, "test_*.py"))
+ glob.glob(os.path.join(HERE, "tools", "*.py"))):
if os.path.basename(path) == "test_split_patches.py":
continue
with open(path, encoding="utf-8") as fh:
src = fh.read()
found = problems(src, os.path.relpath(path, HERE))
sites += len(patches(src))
for msg in found:
print(" " + msg)
bad += len(found)
check(f"{sites} patches of an app.main attribute found, {bad} stale or partial",
sites > 0 and bad == 0)
# --------------------------------------------------------------------------- #
print("\n2. the scan is not blind: spoilt sources are caught")
HEAD = "import app.main as m\n"
check("main.<a name nothing reads> is stale",
len(problems(HEAD + "m.no_such_global_anywhere = 1\n")) == 1)
check("main.datetime is partial (the clock is read in more than one file)"
if len(main_files.reading("datetime")) > 1 else
"main.datetime (one reader here, so nothing to catch yet)",
len(problems(HEAD + "m.datetime = 1\n")) ==
(1 if len(main_files.reading("datetime")) > 1 else 0))
check("the same through setattr, a helper and an alias",
len(problems(HEAD + "k = m\nsetattr(k, 'no_such_global_anywhere', 1)\n"
"patched(m, 'no_such_global_anywhere', 1)(f)\n")) == 2)
check("a patch through main_files.live is not flagged",
problems(HEAD + "import main_files\n"
"main_files.live.datetime = 1\n") == [])
check("a name only main reads may still be patched on main",
problems(HEAD + "m.load_options = 1\n") == [])
# --------------------------------------------------------------------------- #
print("\n3. main_files.live sets a global in every file that reads it")
import importlib # noqa: E402
live = main_files.live
for name in ("datetime", "compute", "_mono"):
readers = main_files.reading(name)
mods = [importlib.import_module("app." + r) for r in readers]
before = [getattr(m, name) for m in mods]
sentinel = object()
setattr(live, name, sentinel)
try:
hit = all(getattr(m, name) is sentinel for m in mods)
read_back = getattr(live, name) is sentinel
finally:
for m, old in zip(mods, before):
setattr(m, name, old)
check(f"{name}: set in all {len(mods)} reading file(s) {readers}, and "
f"read back", bool(mods) and hit and read_back)
try:
live.no_such_global_anywhere = 1
raised = False
except AttributeError:
raised = True
check("a name no file reads raises instead of patching nothing", raised)
print()
if fails:
print(f"FAILED: {len(fails)}")
sys.exit(1)
print("all ok")