Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,9 @@ jobs:
- name: Plugin-dist --out refuses destructive targets
run: python3 tests/test_build_plugin_dist.py -v

- name: Exit-code gate resolves fail() helpers and tuple returns
run: python3 tests/test_exit_code_readme.py -v

- name: Hero-drift tool exit-code tests
run: |
pip install numpy==2.2.6 Pillow==11.2.1
Expand Down
43 changes: 41 additions & 2 deletions tests/check_exit_code_readme.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
`return fcode`). Helper codes are read from examples/gallery_*.py, not
hardcoded. A `return name` that resolves to none of these is a pass-through
of another function's result; that function's own returns are scanned.
- Local fail-style helpers resolve through their argument: a module
function whose every `return` hands back one of its own parameters
(`def fail(msg, code): ...; return code`) makes `return fail("...", 11)`
code 11 (#360).
- Tuple returns resolve through their first element when it is a code:
`return 9, objs, objs` and `return (fail("...", 11),) + none3`.
- `sys.exit(main())` and `sys.exit(name)` where `name` is assigned from a
local function call (both headless templates: `exit_code = main()`) are
harness pass-through, not unanalyzable.
Expand Down Expand Up @@ -89,6 +95,25 @@ def module_function_names(tree):
return names


def passthrough_helpers(tree):
"""{func: (index, name)} for module functions whose every `return` hands
back one of their own parameters unchanged, e.g. ``def fail(msg, code):
print(...); return code``. A call to one is as static as the argument it
passes through."""
out = {}
for node in tree.body:
if not isinstance(node, ast.FunctionDef):
continue
params = [a.arg for a in node.args.args]
rets = [r for r in ast.walk(node) if isinstance(r, ast.Return)]
names = {r.value.id for r in rets if isinstance(r.value, ast.Name)}
if rets and len(names) == 1 and all(isinstance(r.value, ast.Name) for r in rets):
name = names.pop()
if name in params:
out[node.name] = (params.index(name), name)
return out


def module_int_constants(tree):
"""Module-level `NAME = <int literal>` bindings."""
consts = {}
Expand Down Expand Up @@ -144,9 +169,10 @@ def is_passthrough_call(node, func_names):


class ExitVisitor(ast.NodeVisitor):
def __init__(self, func_names, consts=None):
def __init__(self, func_names, consts=None, helpers=None):
self.func_names = func_names
self.consts = consts or {}
self.helpers = helpers or {}
self.stack = []
self.scope_binds = [{}]
self.code_binds = [{}] # name -> code, per function scope
Expand Down Expand Up @@ -174,7 +200,19 @@ def resolve_code(self, node):
dotted = _dotted(node)
if dotted in HELPER_CONSTS:
return HELPER_CONSTS[dotted]
if isinstance(node, ast.Tuple) and node.elts:
return self.resolve_code(node.elts[0])
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add) and isinstance(node.left, ast.Tuple):
return self.resolve_code(node.left)
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Name) and node.func.id in self.helpers:
index, name = self.helpers[node.func.id]
for kw in node.keywords:
if kw.arg == name:
return self.resolve_code(kw.value)
if index < len(node.args):
return self.resolve_code(node.args[index])
return None
return HELPER_FUNCS.get(_dotted(node.func))
return None

Expand Down Expand Up @@ -324,7 +362,8 @@ def check_file(path):
errors.append(f"{rel}: cannot parse: {exc}")
return errors

visitor = ExitVisitor(module_function_names(tree), module_int_constants(tree))
visitor = ExitVisitor(module_function_names(tree), module_int_constants(tree),
passthrough_helpers(tree))
visitor.visit(tree)

for lineno, snippet in visitor.unanalyzable:
Expand Down
64 changes: 64 additions & 0 deletions tests/test_exit_code_readme.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Resolution tests for tests/check_exit_code_readme.py (#360). No Blender.

The gate is only as good as the exit codes it can see. These pin the shapes
showcase and example scripts actually use, so a refactor of the resolver
cannot quietly stop seeing them again. Run:
python tests/test_exit_code_readme.py -v
"""
from __future__ import annotations

import ast
import sys
import textwrap
import unittest
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

import check_exit_code_readme as c # noqa: E402


def codes(src: str) -> set[int]:
tree = ast.parse(textwrap.dedent(src))
v = c.ExitVisitor(c.module_function_names(tree), c.module_int_constants(tree),
c.passthrough_helpers(tree))
v.visit(tree)
return {code for code, _ in v.literals if code}


class Resolution(unittest.TestCase):
def test_literal_and_constant(self):
self.assertEqual(codes("""
EXIT_X = 7
def main():
if a: return 3
return EXIT_X
"""), {3, 7})

def test_fail_helper_positional_and_keyword(self):
self.assertEqual(codes("""
def fail(msg, code):
print(msg)
return code
def check():
if a: return fail("one", 15)
return fail("two", code=16)
"""), {15, 16})

def test_tuple_return_first_element(self):
self.assertEqual(codes("""
def fail(msg, code):
return code
def check():
if a: return 9, objs, objs
if b: return fail("x", 11), None, None
return (fail("y", 12),) + none3
"""), {9, 11, 12})

def test_helper_returning_a_non_parameter_is_not_passthrough(self):
tree = ast.parse("def f(a, b):\n x = a\n return x\n")
self.assertEqual(c.passthrough_helpers(tree), {})


if __name__ == "__main__":
unittest.main()
Loading