summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_patch_targets_are_live.py
blob: c1dd905150d2d5ed50218dc5a4f3a61a7c42c5da (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
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))