""" 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))