"""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="", 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. 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")