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,181 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user