|
1 | | -"""Guard the aggregate test-runner contract itself. |
2 | | -
|
3 | | -The bug this pins down: a `test_*.py` that executes its checks at import time and ends with |
4 | | -`sys.exit(...)` ends the WHOLE process when run_tests.py imports it, silently dropping every |
5 | | -module discovered after it and the aggregate return code. This module statically proves that |
6 | | -CANNOT happen — every sibling `test_*.py` exposes `run()` and never calls sys.exit / |
7 | | -raise SystemExit at module scope (only under an `if __name__ == "__main__"` guard). |
| 1 | +"""Guard the aggregate test-runner contract — the ENFORCEABLE version. |
| 2 | +
|
| 3 | +The real enforcement is a PREFLIGHT in run_tests.py: before importing any test_*.py it |
| 4 | +calls _preflight.check_test_files(), which refuses any sys.exit / raise SystemExit that |
| 5 | +is not inside a standalone `if __name__ == "__main__":` guard body. That runs first, so an |
| 6 | +offender cannot sort ahead of the check and end the process during import. |
| 7 | +
|
| 8 | +This module proves the scanner behind that preflight: it exercises the location invariant |
| 9 | +(no exemption for function/lambda/class bodies — an immediately-invoked helper defeats any |
| 10 | +call-graph exemption), checks the live tests directory, and — the load-bearing regression — |
| 11 | +builds a throwaway tests directory whose EARLIEST-sorting file exits at import and confirms |
| 12 | +the preflight reports it (so the runner would return failure, not silent success). |
8 | 13 | """ |
9 | 14 |
|
10 | 15 | from __future__ import annotations |
11 | 16 |
|
12 | 17 | import ast |
13 | 18 | import os |
| 19 | +import tempfile |
| 20 | + |
| 21 | +from _preflight import check_test_files, exit_violations |
14 | 22 |
|
15 | 23 | failures: list[str] = [] |
16 | 24 | checks = 0 |
17 | 25 |
|
18 | 26 | _HERE = os.path.dirname(os.path.abspath(__file__)) |
19 | 27 |
|
20 | | - |
21 | | -def _is_main_guard(test: ast.expr) -> bool: |
22 | | - """Structurally recognise EXACTLY `__name__ == "__main__"` — not any top-level `if`, |
23 | | - so `if True: sys.exit(0)` is NOT waved through.""" |
24 | | - return (isinstance(test, ast.Compare) |
25 | | - and isinstance(test.left, ast.Name) and test.left.id == "__name__" |
26 | | - and len(test.ops) == 1 and isinstance(test.ops[0], ast.Eq) |
27 | | - and len(test.comparators) == 1 |
28 | | - and isinstance(test.comparators[0], ast.Constant) |
29 | | - and test.comparators[0].value == "__main__") |
30 | | - |
31 | | - |
32 | | -def _future_annotations(module: ast.AST) -> bool: |
33 | | - body = getattr(module, "body", []) |
34 | | - for stmt in body: |
35 | | - if isinstance(stmt, ast.ImportFrom) and stmt.module == "__future__": |
36 | | - if any(alias.name == "annotations" for alias in stmt.names): |
37 | | - return True |
38 | | - return False |
39 | | - |
40 | | - |
41 | | -def _annotation_exprs(func: ast.FunctionDef | ast.AsyncFunctionDef) -> list[ast.expr]: |
42 | | - a = func.args |
43 | | - exprs = [arg.annotation for arg in |
44 | | - (*a.posonlyargs, *a.args, *a.kwonlyargs) if arg.annotation] |
45 | | - for extra in (a.vararg, a.kwarg): |
46 | | - if extra is not None and extra.annotation is not None: |
47 | | - exprs.append(extra.annotation) |
48 | | - if func.returns is not None: |
49 | | - exprs.append(func.returns) |
50 | | - return exprs |
51 | | - |
52 | | - |
53 | | -def _module_scope_exit(node: ast.AST, ann_eager: bool | None = None) -> bool: |
54 | | - """True if a sys.exit(...) / raise SystemExit(...) would fire at IMPORT time. Only the |
55 | | - genuinely DEFERRED subtrees are pruned: a function / async-function / lambda BODY, and |
56 | | - the `if __name__ == "__main__"` body. Everything else runs on import and is scanned — |
57 | | - class bodies + bases + keywords, decorators, argument defaults, lambda defaults, and |
58 | | - (unless `from __future__ import annotations` is in force) annotations. The node itself |
59 | | - is checked BEFORE its children, so a decorator / default that IS `sys.exit(...)` is |
60 | | - caught, not just its arguments.""" |
61 | | - if ann_eager is None: # decided once, at the module root, then threaded down |
62 | | - ann_eager = not _future_annotations(node) |
63 | | - |
64 | | - if isinstance(node, ast.Raise) and _is_systemexit(node.exc): |
65 | | - return True |
66 | | - if isinstance(node, ast.Call) and _is_sys_exit(node.func): |
67 | | - return True |
68 | | - if isinstance(node, ast.If) and _is_main_guard(node.test): |
69 | | - # ONLY the guard's body is exempt — its `else:` (and `elif`) still run on import. |
70 | | - return any(_module_scope_exit(s, ann_eager) for s in node.orelse) |
71 | | - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): |
72 | | - eager = [*node.decorator_list, *node.args.defaults, |
73 | | - *[d for d in node.args.kw_defaults if d is not None]] |
74 | | - if ann_eager: |
75 | | - eager += _annotation_exprs(node) |
76 | | - return any(_module_scope_exit(e, ann_eager) for e in eager) |
77 | | - if isinstance(node, ast.Lambda): |
78 | | - eager = [*node.args.defaults, *[d for d in node.args.kw_defaults if d is not None]] |
79 | | - return any(_module_scope_exit(e, ann_eager) for e in eager) |
80 | | - # Everything else — module body, class body/bases/keywords/decorators, module-scope |
81 | | - # control flow — executes on import; scan every child. |
82 | | - return any(_module_scope_exit(c, ann_eager) for c in ast.iter_child_nodes(node)) |
83 | | - |
84 | | - |
85 | | -def _is_systemexit(exc: ast.expr | None) -> bool: |
86 | | - if isinstance(exc, ast.Call): |
87 | | - exc = exc.func |
88 | | - return isinstance(exc, ast.Name) and exc.id == "SystemExit" |
89 | | - |
90 | | - |
91 | | -def _is_sys_exit(func: ast.expr) -> bool: |
92 | | - return (isinstance(func, ast.Attribute) and func.attr == "exit" |
93 | | - and isinstance(func.value, ast.Name) and func.value.id == "sys") |
94 | | - |
95 | | - |
96 | | -_SELFTEST_MUST_CATCH = ( |
97 | | - ("bare sys.exit at module scope", "import sys\nsys.exit(0)\n"), |
98 | | - ("exit inside `if True:`", "import sys\nif True:\n sys.exit(0)\n"), |
99 | | - ("exit inside a top-level try", |
| 28 | +# MUST be reported as violations — including exits buried in a function / lambda / class |
| 29 | +# body, because a module-scope call (or an IIFE) runs them at import. |
| 30 | +_MUST_CATCH = ( |
| 31 | + ("bare module-scope exit", "import sys\nsys.exit(0)\n"), |
| 32 | + ("exit in `if True:`", "import sys\nif True:\n sys.exit(0)\n"), |
| 33 | + ("exit in a top-level try", |
100 | 34 | "import sys\ntry:\n sys.exit(0)\nexcept Exception:\n pass\n"), |
101 | 35 | ("raise SystemExit at module scope", "raise SystemExit(1)\n"), |
102 | | - ("exit inside a for loop", "import sys\nfor _ in range(1):\n sys.exit(0)\n"), |
103 | | - ("sys.exit in a main-guard else", |
| 36 | + ("exit in a for loop", "import sys\nfor _ in range(1):\n sys.exit(0)\n"), |
| 37 | + ("exit in a main-guard else", |
104 | 38 | "import sys\nif __name__ == '__main__':\n pass\nelse:\n sys.exit(0)\n"), |
105 | 39 | ("raise SystemExit in a main-guard else", |
106 | 40 | "if __name__ == '__main__':\n pass\nelse:\n raise SystemExit(1)\n"), |
107 | 41 | ("exit in a main-guard elif", |
108 | 42 | "import sys\nif __name__ == '__main__':\n pass\nelif True:\n sys.exit(0)\n"), |
109 | | - # Declarations that run code AT IMPORT — the trapdoors that skipping a whole |
110 | | - # FunctionDef / ClassDef / Lambda node would miss. |
111 | 43 | ("exit in a class body", "import sys\nclass C:\n sys.exit(7)\n"), |
112 | 44 | ("exit in a class base", "import sys\nclass C(sys.exit(7)):\n pass\n"), |
113 | 45 | ("exit in a class decorator", "import sys\n@sys.exit(7)\nclass C:\n pass\n"), |
114 | 46 | ("exit in a function default", "import sys\ndef f(value=sys.exit(7)):\n pass\n"), |
115 | 47 | ("exit in a function decorator", "import sys\n@sys.exit(7)\ndef f():\n pass\n"), |
116 | | - ("exit in a keyword-only default", |
117 | | - "import sys\ndef f(*, value=sys.exit(7)):\n pass\n"), |
118 | 48 | ("exit in a lambda default", "import sys\nf = lambda value=sys.exit(7): None\n"), |
119 | | - ("exit in an eager annotation", "import sys\ndef f(x: sys.exit(7)):\n pass\n"), |
| 49 | + # The blocker-2 forms: a body that IS reached at import. |
| 50 | + ("exit in a helper called at module scope", |
| 51 | + "import sys\ndef abort():\n sys.exit(7)\nabort()\n"), |
| 52 | + ("exit in an immediately-invoked lambda", "import sys\nv = (lambda: sys.exit(7))()\n"), |
| 53 | + # And even an UNCALLED body — the strict location rule refuses it regardless. |
| 54 | + ("exit in an uncalled function body", "import sys\ndef f():\n sys.exit(7)\n"), |
| 55 | + ("exit in a class method body", |
| 56 | + "import sys\nclass C:\n def m(self):\n sys.exit(7)\n"), |
120 | 57 | ) |
121 | | -_SELFTEST_MUST_PASS = ( |
| 58 | +# MUST be accepted: only the `__main__` guard body, or no exit at all, or a string literal. |
| 59 | +_MUST_PASS = ( |
122 | 60 | ("guarded entrypoint", "import sys\nif __name__ == '__main__':\n sys.exit(0)\n"), |
123 | | - ("exit inside a function", "import sys\ndef run():\n sys.exit(0)\n"), |
124 | | - ("exit inside a lambda", "f = lambda: __import__('sys').exit(0)\n"), |
125 | | - ("exit inside a class method body", |
126 | | - "import sys\nclass C:\n def m(self):\n sys.exit(0)\n"), |
127 | | - ("stringified annotation under future-annotations", |
128 | | - "from __future__ import annotations\nimport sys\ndef f(x: sys.exit(7)):\n pass\n"), |
| 61 | + ("guarded raise SystemExit(run())", |
| 62 | + "def run():\n return 0\nif __name__ == '__main__':\n raise SystemExit(run())\n"), |
| 63 | + ("guarded exit nested under an inner if", |
| 64 | + "import sys\nif __name__ == '__main__':\n if '--x' in sys.argv:\n" |
| 65 | + " raise SystemExit(0)\n"), |
| 66 | + ("no exit at all", "def run():\n return 0\n"), |
| 67 | + ("exit only as a string literal", "MSG = 'call sys.exit(0) to quit'\n"), |
129 | 68 | ) |
130 | 69 |
|
131 | 70 |
|
| 71 | +def _violates(src: str) -> bool: |
| 72 | + return bool(exit_violations(ast.parse(src))) |
| 73 | + |
| 74 | + |
132 | 75 | def run() -> int: |
133 | 76 | global checks |
134 | | - # Self-test the guard first: it must catch the ways the original bug could recur, and |
135 | | - # must NOT flag the sanctioned entrypoint or an exit that only lives inside a scope. |
136 | | - for label, src in _SELFTEST_MUST_CATCH: |
137 | | - checks += 1 |
138 | | - if not _module_scope_exit(ast.parse(src)): |
139 | | - failures.append(f"guard self-test: failed to catch {label}") |
140 | | - for label, src in _SELFTEST_MUST_PASS: |
| 77 | + for label, src in _MUST_CATCH: |
141 | 78 | checks += 1 |
142 | | - if _module_scope_exit(ast.parse(src)): |
143 | | - failures.append(f"guard self-test: wrongly flagged {label}") |
144 | | - |
145 | | - for fname in sorted(os.listdir(_HERE)): |
146 | | - if not (fname.startswith("test_") and fname.endswith(".py")): |
147 | | - continue |
148 | | - if fname == os.path.basename(__file__): |
149 | | - continue |
| 79 | + if not _violates(src): |
| 80 | + failures.append(f"scanner self-test: failed to catch {label}") |
| 81 | + for label, src in _MUST_PASS: |
150 | 82 | checks += 1 |
151 | | - src = open(os.path.join(_HERE, fname), encoding="utf-8").read() |
152 | | - tree = ast.parse(src) |
153 | | - has_run = any(isinstance(n, ast.FunctionDef) and n.name == "run" |
154 | | - for n in ast.iter_child_nodes(tree)) |
155 | | - if not has_run: |
156 | | - failures.append(f"{fname}: has no module-level run()") |
157 | | - if _module_scope_exit(tree): |
158 | | - failures.append(f"{fname}: calls sys.exit/raise SystemExit at import scope " |
159 | | - "(would short-circuit the aggregate runner)") |
160 | | - print(f"harness contract: {checks - len(failures)}/{checks} test modules honour run()") |
| 83 | + if _violates(src): |
| 84 | + failures.append(f"scanner self-test: wrongly flagged {label}") |
| 85 | + |
| 86 | + # The live tests directory must itself be clean (this is what the runner enforces). |
| 87 | + checks += 1 |
| 88 | + live = check_test_files(_HERE) |
| 89 | + if live: |
| 90 | + failures.append("live tests directory has import-time-exit violations: " |
| 91 | + + "; ".join(live)) |
| 92 | + |
| 93 | + # The load-bearing regression: an offender that sorts BEFORE any policing module must be |
| 94 | + # caught by the preflight, so the runner returns failure instead of a silent green. Also |
| 95 | + # a helper invoked at module scope — the blocker-2 case call-graph pruning would miss. |
| 96 | + checks += 1 |
| 97 | + with tempfile.TemporaryDirectory() as tmp: |
| 98 | + with open(os.path.join(tmp, "test_aaa_offender.py"), "w", encoding="utf-8") as fh: |
| 99 | + fh.write("import sys\nsys.exit(0)\n") |
| 100 | + with open(os.path.join(tmp, "test_zzz_helper.py"), "w", encoding="utf-8") as fh: |
| 101 | + fh.write("import sys\n\n\ndef _boom():\n sys.exit(0)\n\n\n_boom()\n") |
| 102 | + with open(os.path.join(tmp, "test_mmm_clean.py"), "w", encoding="utf-8") as fh: |
| 103 | + fh.write("def run():\n return 0\n\n\nif __name__ == '__main__':\n" |
| 104 | + " raise SystemExit(run())\n") |
| 105 | + found = check_test_files(tmp) |
| 106 | + offenders = {p.split(":", 1)[0] for p in found} |
| 107 | + if offenders != {"test_aaa_offender.py", "test_zzz_helper.py"}: |
| 108 | + failures.append(f"preflight regression: flagged {sorted(offenders)}, expected " |
| 109 | + "the bare-exit and helper-invoked offenders only " |
| 110 | + "(the guarded module must pass)") |
| 111 | + |
| 112 | + print(f"harness contract: {checks - len(failures)}/{checks} checks pass") |
161 | 113 | for f in failures: |
162 | 114 | print(f" FAIL: {f}") |
163 | 115 | return 1 if failures else 0 |
|
0 commit comments