diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-23 22:25:48 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-24 16:45:37 +0200 |
| commit | 73e04868614160ab6cf98b1c1206f3a9b3840c02 (patch) | |
| tree | 5f037b96cb29b4f8c6b0d7257458f2e8ad9186a0 /packages | |
| parent | 328b01a2dd545d70a078db8df1e91b02d65bfc9c (diff) | |
| download | meshbay-73e04868614160ab6cf98b1c1206f3a9b3840c02.tar.gz | |
test(node): a patched module attribute must be read where it is patched
A function reads its globals from the module that defines it, so patching a
name a module only re-exports changes nothing and the test passes anyway.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/meshbay-node/tests/test_patch_targets_are_live.py | 118 |
1 files changed, 118 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_patch_targets_are_live.py b/packages/meshbay-node/tests/test_patch_targets_are_live.py new file mode 100644 index 0000000..c1dd905 --- /dev/null +++ b/packages/meshbay-node/tests/test_patch_targets_are_live.py @@ -0,0 +1,118 @@ +""" +A monkeypatch on a module attribute only works where that module *reads* the +attribute. A function looks its globals up in the module it is defined in, so +patching a name that a module merely imports and passes on — a re-export — +replaces a binding nothing reads: the code under test keeps the original, and +the test goes on passing for a reason that has nothing to do with it. + +`monkeypatch.setattr` on a name that does not exist at all raises, which is +loud and fine. This test covers the quiet case: every module attribute a test +in this suite patches must be read by that module's own code, or defined there. +""" + +import ast +import importlib +import types +from pathlib import Path + +TESTS = Path(__file__).resolve().parent + + +def _module_aliases(tree: ast.Module) -> dict[str, str]: + """alias -> dotted name, for every name a test module binds by import.""" + out = {} + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for a in node.names: + if a.asname: + out[a.asname] = a.name + else: + out[a.name.split(".")[0]] = a.name.split(".")[0] + elif isinstance(node, ast.ImportFrom) and node.module and not node.level: + for a in node.names: + out[a.asname or a.name] = f"{node.module}.{a.name}" + return out + + +def _resolve(dotted: str): + """The object a dotted name designates, or None.""" + parts = dotted.split(".") + for i in range(len(parts), 0, -1): + try: + obj = importlib.import_module(".".join(parts[:i])) + except ImportError: + continue + try: + for p in parts[i:]: + obj = getattr(obj, p) + except AttributeError: + return None + return obj + return None + + +def _patches(path: Path): + """(module_object, attribute, line) for every replacement of a module + attribute: `monkeypatch.setattr`, `patch`, `patch.object`, or a plain + assignment.""" + tree = ast.parse(path.read_text(encoding="utf-8")) + aliases = _module_aliases(tree) + for node in ast.walk(tree): + # `module.NAME = stand_in`, restored by hand in a `finally`. + if isinstance(node, ast.Assign): + for t in node.targets: + if isinstance(t, ast.Attribute) and isinstance(t.value, ast.Name) \ + and t.value.id in aliases: + yield _resolve(aliases[t.value.id]), t.attr, node.lineno + continue + if not isinstance(node, ast.Call) or not node.args: + continue + f = node.func + name = (f.attr if isinstance(f, ast.Attribute) + else f.id if isinstance(f, ast.Name) else "") + first = node.args[0] + if name in ("setattr", "object") and len(node.args) >= 2 \ + and isinstance(first, ast.Name) \ + and isinstance(node.args[1], ast.Constant) \ + and isinstance(node.args[1].value, str): + target = _resolve(aliases.get(first.id, "")) + yield target, node.args[1].value, node.lineno + elif name in ("setattr", "patch") and isinstance(first, ast.Constant) \ + and isinstance(first.value, str) and "." in first.value: + mod, _, attr = first.value.rpartition(".") + yield _resolve(mod), attr, node.lineno + + +def _reads_or_defines(module: types.ModuleType, attr: str) -> bool: + tree = ast.parse(Path(module.__file__).read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Name) and node.id == attr and \ + isinstance(node.ctx, ast.Load): + return True + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) \ + and node.name == attr: + return True + if isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign)): + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + if any(isinstance(t, ast.Name) and t.id == attr for t in targets): + return True + return False + + +def test_every_patched_module_attribute_is_read_where_it_is_patched(): + dead, checked = [], 0 + for path in sorted(TESTS.glob("*.py")): + for target, attr, line in _patches(path): + if not isinstance(target, types.ModuleType) or not getattr( + target, "__file__", None): + continue # a class or an instance: an attribute lookup, always live + if not target.__name__.startswith("meshbay_"): + continue # the standard library and dependencies are not ours to police + checked += 1 + if not _reads_or_defines(target, attr): + dead.append(f"{path.name}:{line} {target.__name__}.{attr}") + assert checked > 50, "the scan found almost nothing to check — did the patterns change?" + assert not dead, ( + "these patches replace a name the module only imports; the code under " + "test reads it somewhere else, so the patch changes nothing:\n " + + "\n ".join(dead)) |