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>
182 lines
7.1 KiB
Python
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")
|