|
| 1 | +"""Every nonzero product exit code must appear in that script's README table. |
| 2 | +
|
| 3 | +Product codes live in `return N` and literal `sys.exit(N)`, not in |
| 4 | +`sys.exit(main())`. A regex over `sys.exit(` miscounts the tree (64 vs 58). |
| 5 | +
|
| 6 | +Detection (AST only): |
| 7 | +
|
| 8 | +- Integer literals on `return` in the file's own functions, plus literal |
| 9 | + `sys.exit(N)`. |
| 10 | +- `sys.exit(main())` and `sys.exit(name)` where `name` is assigned from a |
| 11 | + local function call (both headless templates: `exit_code = main()`) are |
| 12 | + harness pass-through, not unanalyzable. |
| 13 | +- `sys.exit(1)` inside an `except` handler is the FATAL wrapper documented |
| 14 | + in CONTRIBUTING.md — not a named check. |
| 15 | +- Scope: examples/, templates/, showcase/. tests/ and scripts/ are excluded. |
| 16 | +- Anything else at a `sys.exit(...)` site is unanalyzable and fails. |
| 17 | +
|
| 18 | +README check applies to entry-point files (`if __name__ == "__main__"`). |
| 19 | +Helpers such as examples/gallery_framing.py are still scanned for |
| 20 | +unanalyzable sys.exit sites but have no product table of their own. |
| 21 | +
|
| 22 | +No exemption list, allowlist, or skip file. |
| 23 | +""" |
| 24 | +from __future__ import annotations |
| 25 | + |
| 26 | +import ast |
| 27 | +import os |
| 28 | +import re |
| 29 | +import sys |
| 30 | + |
| 31 | +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| 32 | + |
| 33 | +SCOPES = ("examples", "templates", "showcase") |
| 34 | + |
| 35 | +TABLE_ROW_RE = re.compile(r"^\|\s*`?(\d+)`?\s*\|") |
| 36 | +EXIT_HEADING_RE = re.compile(r"^#+\s+Exit codes\s*$", re.IGNORECASE) |
| 37 | +HEADING_RE = re.compile(r"^#+\s+") |
| 38 | + |
| 39 | + |
| 40 | +def relpath(path): |
| 41 | + return os.path.relpath(path, ROOT).replace("\\", "/") |
| 42 | + |
| 43 | + |
| 44 | +def is_dunder_main(node): |
| 45 | + if not isinstance(node, ast.Compare) or len(node.ops) != 1: |
| 46 | + return False |
| 47 | + if not isinstance(node.ops[0], ast.Eq) or len(node.comparators) != 1: |
| 48 | + return False |
| 49 | + left, right = node.left, node.comparators[0] |
| 50 | + |
| 51 | + def is_name(n, ident): |
| 52 | + return isinstance(n, ast.Name) and n.id == ident |
| 53 | + |
| 54 | + def is_const(n, value): |
| 55 | + return isinstance(n, ast.Constant) and n.value == value |
| 56 | + |
| 57 | + return ( |
| 58 | + (is_name(left, "__name__") and is_const(right, "__main__")) |
| 59 | + or (is_const(left, "__main__") and is_name(right, "__name__")) |
| 60 | + ) |
| 61 | + |
| 62 | + |
| 63 | +def int_literal(node): |
| 64 | + """Return an int if *node* is an integer literal, else None. |
| 65 | +
|
| 66 | + ast.Constant(True) is a bool (subclass of int); skip it. |
| 67 | + """ |
| 68 | + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): |
| 69 | + inner = int_literal(node.operand) |
| 70 | + return None if inner is None else -inner |
| 71 | + if isinstance(node, ast.Constant) and type(node.value) is int: |
| 72 | + return node.value |
| 73 | + return None |
| 74 | + |
| 75 | + |
| 76 | +def module_function_names(tree): |
| 77 | + names = set() |
| 78 | + for node in tree.body: |
| 79 | + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): |
| 80 | + names.add(node.name) |
| 81 | + return names |
| 82 | + |
| 83 | + |
| 84 | +def is_passthrough_call(node, func_names): |
| 85 | + """sys.exit(main()) — Call of a function defined in this module.""" |
| 86 | + return ( |
| 87 | + isinstance(node, ast.Call) |
| 88 | + and isinstance(node.func, ast.Name) |
| 89 | + and node.func.id in func_names |
| 90 | + ) |
| 91 | + |
| 92 | + |
| 93 | +class ExitVisitor(ast.NodeVisitor): |
| 94 | + def __init__(self, func_names): |
| 95 | + self.func_names = func_names |
| 96 | + self.stack = [] |
| 97 | + self.scope_binds = [{}] |
| 98 | + self.literals = [] # (code, lineno) |
| 99 | + self.unanalyzable = [] # (lineno, snippet) |
| 100 | + |
| 101 | + def _push_scope(self): |
| 102 | + self.scope_binds.append({}) |
| 103 | + |
| 104 | + def _pop_scope(self): |
| 105 | + self.scope_binds.pop() |
| 106 | + |
| 107 | + def _bind(self, name, from_func): |
| 108 | + self.scope_binds[-1][name] = from_func |
| 109 | + |
| 110 | + def _bound_func(self, name): |
| 111 | + for binds in reversed(self.scope_binds): |
| 112 | + if name in binds: |
| 113 | + return binds[name] |
| 114 | + return None |
| 115 | + |
| 116 | + def _in_except(self): |
| 117 | + return any(isinstance(n, ast.ExceptHandler) for n in self.stack) |
| 118 | + |
| 119 | + def generic_visit(self, node): |
| 120 | + self.stack.append(node) |
| 121 | + super().generic_visit(node) |
| 122 | + self.stack.pop() |
| 123 | + |
| 124 | + def visit_FunctionDef(self, node): |
| 125 | + self._push_scope() |
| 126 | + self.generic_visit(node) |
| 127 | + self._pop_scope() |
| 128 | + |
| 129 | + visit_AsyncFunctionDef = visit_FunctionDef |
| 130 | + |
| 131 | + def visit_Assign(self, node): |
| 132 | + if is_passthrough_call(node.value, self.func_names): |
| 133 | + func_id = node.value.func.id |
| 134 | + for target in node.targets: |
| 135 | + if isinstance(target, ast.Name): |
| 136 | + self._bind(target.id, func_id) |
| 137 | + self.generic_visit(node) |
| 138 | + |
| 139 | + def visit_AnnAssign(self, node): |
| 140 | + if node.value is not None and is_passthrough_call(node.value, self.func_names): |
| 141 | + if isinstance(node.target, ast.Name): |
| 142 | + self._bind(node.target.id, node.value.func.id) |
| 143 | + self.generic_visit(node) |
| 144 | + |
| 145 | + def visit_Return(self, node): |
| 146 | + if node.value is not None: |
| 147 | + code = int_literal(node.value) |
| 148 | + if code is not None: |
| 149 | + self.literals.append((code, node.lineno)) |
| 150 | + self.generic_visit(node) |
| 151 | + |
| 152 | + def visit_Call(self, node): |
| 153 | + if not ( |
| 154 | + isinstance(node.func, ast.Attribute) |
| 155 | + and node.func.attr == "exit" |
| 156 | + and isinstance(node.func.value, ast.Name) |
| 157 | + and node.func.value.id == "sys" |
| 158 | + ): |
| 159 | + self.generic_visit(node) |
| 160 | + return |
| 161 | + |
| 162 | + snippet = ast.unparse(node) |
| 163 | + lineno = node.lineno |
| 164 | + if len(node.args) != 1 or node.keywords: |
| 165 | + self.unanalyzable.append((lineno, snippet)) |
| 166 | + self.generic_visit(node) |
| 167 | + return |
| 168 | + |
| 169 | + arg = node.args[0] |
| 170 | + code = int_literal(arg) |
| 171 | + if code is not None: |
| 172 | + if code == 1 and self._in_except(): |
| 173 | + self.generic_visit(node) |
| 174 | + return |
| 175 | + self.literals.append((code, lineno)) |
| 176 | + self.generic_visit(node) |
| 177 | + return |
| 178 | + |
| 179 | + if is_passthrough_call(arg, self.func_names): |
| 180 | + self.generic_visit(node) |
| 181 | + return |
| 182 | + |
| 183 | + if isinstance(arg, ast.Name) and self._bound_func(arg.id): |
| 184 | + self.generic_visit(node) |
| 185 | + return |
| 186 | + |
| 187 | + self.unanalyzable.append((lineno, snippet)) |
| 188 | + self.generic_visit(node) |
| 189 | + |
| 190 | + |
| 191 | +def has_dunder_main(tree): |
| 192 | + for node in tree.body: |
| 193 | + if isinstance(node, ast.If) and is_dunder_main(node.test): |
| 194 | + return True |
| 195 | + return False |
| 196 | + |
| 197 | + |
| 198 | +def readme_table_codes(text): |
| 199 | + codes = set() |
| 200 | + in_section = False |
| 201 | + for line in text.splitlines(): |
| 202 | + if EXIT_HEADING_RE.match(line): |
| 203 | + in_section = True |
| 204 | + continue |
| 205 | + if in_section and HEADING_RE.match(line): |
| 206 | + break |
| 207 | + if not in_section: |
| 208 | + continue |
| 209 | + match = TABLE_ROW_RE.match(line) |
| 210 | + if match: |
| 211 | + codes.add(int(match.group(1))) |
| 212 | + return codes |
| 213 | + |
| 214 | + |
| 215 | +def iter_py(): |
| 216 | + for scope in SCOPES: |
| 217 | + base = os.path.join(ROOT, scope) |
| 218 | + if not os.path.isdir(base): |
| 219 | + continue |
| 220 | + for dirpath, dirnames, filenames in os.walk(base): |
| 221 | + dirnames[:] = [d for d in dirnames if d != "__pycache__"] |
| 222 | + for name in filenames: |
| 223 | + if name.endswith(".py"): |
| 224 | + yield os.path.join(dirpath, name) |
| 225 | + |
| 226 | + |
| 227 | +def check_file(path): |
| 228 | + errors = [] |
| 229 | + rel = relpath(path) |
| 230 | + src = open(path, encoding="utf-8").read() |
| 231 | + try: |
| 232 | + tree = ast.parse(src, filename=rel) |
| 233 | + except SyntaxError as exc: |
| 234 | + errors.append(f"{rel}: cannot parse: {exc}") |
| 235 | + return errors |
| 236 | + |
| 237 | + visitor = ExitVisitor(module_function_names(tree)) |
| 238 | + visitor.visit(tree) |
| 239 | + |
| 240 | + for lineno, snippet in visitor.unanalyzable: |
| 241 | + errors.append( |
| 242 | + f"{rel}:{lineno}: unanalyzable exit code: {snippet}" |
| 243 | + ) |
| 244 | + |
| 245 | + if not has_dunder_main(tree): |
| 246 | + return errors |
| 247 | + |
| 248 | + nonzero = {} |
| 249 | + for code, lineno in visitor.literals: |
| 250 | + if code == 0: |
| 251 | + continue |
| 252 | + nonzero.setdefault(code, lineno) |
| 253 | + |
| 254 | + if not nonzero: |
| 255 | + return errors |
| 256 | + |
| 257 | + readme = os.path.join(os.path.dirname(path), "README.md") |
| 258 | + if not os.path.isfile(readme): |
| 259 | + listed = ", ".join(f"{c} (line {nonzero[c]})" for c in sorted(nonzero)) |
| 260 | + errors.append( |
| 261 | + f"{rel}: nonzero product codes {listed} but no sibling README.md" |
| 262 | + ) |
| 263 | + return errors |
| 264 | + |
| 265 | + table = readme_table_codes(open(readme, encoding="utf-8").read()) |
| 266 | + readme_rel = relpath(readme) |
| 267 | + if not table: |
| 268 | + errors.append( |
| 269 | + f"{rel}: {readme_rel} has no Exit codes table" |
| 270 | + ) |
| 271 | + return errors |
| 272 | + |
| 273 | + for code, lineno in sorted(nonzero.items()): |
| 274 | + if code not in table: |
| 275 | + errors.append( |
| 276 | + f"{rel}:{lineno}: return/exit {code} is not in the {readme_rel} exit table" |
| 277 | + ) |
| 278 | + return errors |
| 279 | + |
| 280 | + |
| 281 | +def main(argv): |
| 282 | + extra = argv[1:] |
| 283 | + paths = list(iter_py()) |
| 284 | + for item in extra: |
| 285 | + paths.append(item if os.path.isabs(item) else os.path.join(ROOT, item)) |
| 286 | + |
| 287 | + errors = [] |
| 288 | + seen = set() |
| 289 | + for path in paths: |
| 290 | + if path in seen: |
| 291 | + continue |
| 292 | + seen.add(path) |
| 293 | + if not os.path.isfile(path): |
| 294 | + errors.append(f"missing scan path {relpath(path)}") |
| 295 | + continue |
| 296 | + errors.extend(check_file(path)) |
| 297 | + |
| 298 | + if errors: |
| 299 | + for err in errors: |
| 300 | + print(f"ERROR: {err}", file=sys.stderr) |
| 301 | + return 1 |
| 302 | + print("exit-code README checks passed.") |
| 303 | + return 0 |
| 304 | + |
| 305 | + |
| 306 | +if __name__ == "__main__": |
| 307 | + sys.exit(main(sys.argv)) |
0 commit comments