From 851535f633f6856f653b380267fd29f5f14abeb3 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 4 Sep 2026 18:15:31 +0200 Subject: [PATCH 1/2] test: define positional patterns, nonlocal, local classes and while conditions --- tests/test_cache.py | 2 +- tests/test_closures.py | 4 +- tests/test_config_secrets_coverage.py | 4 +- tests/test_factories_match_loop_else.py | 6 - tests/test_interprocedural.py | 4 +- tests/test_match_patterns.py | 5 +- tests/test_parallel.py | 2 +- tests/test_project.py | 2 +- tests/test_remaining_limits.py | 168 ++++++++++++++++++++++++ tests/test_syntax_control_flow.py | 5 +- tests/test_syntax_coverage.py | 10 +- 11 files changed, 185 insertions(+), 27 deletions(-) create mode 100644 tests/test_remaining_limits.py diff --git a/tests/test_cache.py b/tests/test_cache.py index 21744af..1543f15 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -183,7 +183,7 @@ def test_cached_modules_still_serve_project_plugins_and_correlation(tmp_path: Pa def test_unsupported_and_syntax_notes_are_cached_too(tmp_path: Path) -> None: - root = project(tmp_path / "src", {"nested.py": "def outer():\n class Inner:\n pass\n return Inner\n"}) + root = project(tmp_path / "src", {"nested.py": "def outer():\n break\n"}) cache = ProjectCache(tmp_path / "cache") first = engine.analyze_project(root, [PLUGINS], cache=cache) diff --git a/tests/test_closures.py b/tests/test_closures.py index d258d13..c82a141 100644 --- a/tests/test_closures.py +++ b/tests/test_closures.py @@ -115,9 +115,9 @@ def test_make_function_carries_the_captured_values() -> None: assert [(m.name, len(m.captured)) for m in made] == [("run", 2), ("lambda_8_11", 0)] -def test_nonlocal_assignments_are_still_reported() -> None: +def test_nonlocal_assignments_are_analysed_inside_the_writer() -> None: findings = check("def outer():\n n = 0\n def bump():\n nonlocal n\n n = n + 1\n bump()\n return n\n") - assert [(f.rule_id, f.function) for f in findings] == [("unsupported-syntax", "bump")] + assert findings == () # --------------------------------------------------------------------------- taint through closures diff --git a/tests/test_config_secrets_coverage.py b/tests/test_config_secrets_coverage.py index 750508f..6929ba2 100644 --- a/tests/test_config_secrets_coverage.py +++ b/tests/test_config_secrets_coverage.py @@ -175,7 +175,7 @@ def test_project_coverage_reflects_notes(tmp_path: Path) -> None: tmp_path, { "clean.py": "def a():\n return 1\n\ndef b():\n return 2\n", - "partial.py": "def ok():\n return 1\n\ndef odd():\n class Inner:\n pass\n return Inner\n", + "partial.py": "def ok():\n return 1\n\ndef odd():\n break\n", "broken.py": "def (:\n", "legacy.py": b"\xff\xfeprint('x')\n", }, @@ -194,7 +194,7 @@ def test_project_coverage_reflects_notes(tmp_path: Path) -> None: def test_single_file_analysis_carries_coverage() -> None: analysis = engine.analyze_file( - SourceManager().add_source("one.py", "def a():\n return 1\n\ndef b():\n class C:\n pass\n"), [PLUGINS] + SourceManager().add_source("one.py", "def a():\n return 1\n\ndef b():\n break\n"), [PLUGINS] ) assert analysis.coverage.summary() == "coverage: 1/1 files, 1/2 functions" assert [f.rule_id for f in analysis.findings] == ["unsupported-syntax"] diff --git a/tests/test_factories_match_loop_else.py b/tests/test_factories_match_loop_else.py index b4ef8b5..2e1b6db 100644 --- a/tests/test_factories_match_loop_else.py +++ b/tests/test_factories_match_loop_else.py @@ -187,12 +187,6 @@ def test_match_wildcard_and_subject_taint() -> None: assert rules(findings) == [("command-injection", 7)] -def test_unsupported_patterns_are_reported_per_function() -> None: - findings = check("def f(p):\n match p:\n case Point(0, y):\n return y\n case _:\n return 0\n") - assert [f.rule_id for f in findings] == ["unsupported-syntax"] - assert "pattern" in findings[0].message - - def test_the_image_editing_site_is_analysed(tmp_path: Path) -> None: text = ( "import os\nfrom flask import Flask, request\nfrom werkzeug.utils import secure_filename\n\n" diff --git a/tests/test_interprocedural.py b/tests/test_interprocedural.py index 71ff83f..87a9cbd 100644 --- a/tests/test_interprocedural.py +++ b/tests/test_interprocedural.py @@ -117,7 +117,7 @@ def test_methods_are_named_by_their_class() -> None: def test_recursion_and_unsupported_functions() -> None: graph = call_graph( "def loop(n):\n return loop(n)\n\n" - "def outer():\n class Inner:\n pass\n return Inner\n" + "def outer():\n break\n" ) assert graph.callees("loop") == frozenset({"loop"}) @@ -227,7 +227,7 @@ def test_recursive_summaries_reach_a_fixpoint() -> None: def test_unsupported_functions_have_conservative_summaries() -> None: - table = summaries("def outer(a):\n class Inner:\n pass\n return Inner\n\ndef use(b):\n return outer(b)\n") + table = summaries("def outer(a):\n break\n\ndef use(b):\n return outer(b)\n") assert table.summary("outer").return_dependencies == frozenset({0}) assert table.summary("outer").unsupported is True diff --git a/tests/test_match_patterns.py b/tests/test_match_patterns.py index bfeb8ba..f11579d 100644 --- a/tests/test_match_patterns.py +++ b/tests/test_match_patterns.py @@ -127,9 +127,8 @@ def test_mapping_and_class_patterns_lower_to_membership_and_isinstance() -> None assert notes("def f(p):\n match p:\n case {'op': op}:\n return op\n case Point(y=y):\n return y\n") == [] -def test_positional_class_patterns_are_still_reported() -> None: - found = notes("def f(p):\n match p:\n case Point(0, y):\n return y\n") - assert len(found) == 1 and "positional" in found[0] +def test_positional_class_patterns_on_unknown_classes_lower_conservatively() -> None: + assert notes("def f(p):\n match p:\n case Point(0, y):\n return y\n") == [] # --------------------------------------------------------------------------- taint diff --git a/tests/test_parallel.py b/tests/test_parallel.py index f5b6d4c..057d247 100644 --- a/tests/test_parallel.py +++ b/tests/test_parallel.py @@ -171,7 +171,7 @@ def test_parallel_analysis_keeps_syntax_and_unsupported_notes(tmp_path: Path) -> tmp_path / "src", { "broken.py": "def (:\n", - "nested.py": "def outer():\n class Inner:\n pass\n return Inner\n", + "nested.py": "def outer():\n break\n", "fine.py": CLEAN, }, ) diff --git a/tests/test_project.py b/tests/test_project.py index 7b1d44f..9c5b361 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -215,7 +215,7 @@ def test_unsupported_functions_and_syntax_errors_are_reported_per_file(tmp_path: tmp_path, { "good.py": "def f():\n pass\n", - "nested.py": "def outer():\n class Inner:\n pass\n return Inner\n", + "nested.py": "def outer():\n break\n", "broken.py": "def broken(:\n", }, ) diff --git a/tests/test_remaining_limits.py b/tests/test_remaining_limits.py new file mode 100644 index 0000000..d1198be --- /dev/null +++ b/tests/test_remaining_limits.py @@ -0,0 +1,168 @@ +"""Acceptance tests for the four documented limits Hugo asked to close. + +- Positional class patterns: ``case Point(0, y)`` uses the class's ``__match_args__`` + when the class is defined in the module, explicitly or as a dataclass field order; for + an unknown class each position is a conservative attribute of the subject, so the + match lowers and taint still flows. +- ``nonlocal``: the declared name becomes a local of the nested function initialised + from the captured value, so the body is analysed; the write does not flow back to the + enclosing function yet, which is documented. +- A class defined inside a function is a ``MakeClass`` value bound to its name; its + methods are analysed as nested functions. +- Control flow inside a ``while`` condition: the loop becomes ``while True`` with the + condition recomputed at the top of the body and a ``break``, the ``else`` clause + running before that ``break``. + +Expected to remain red until these four lower. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from coretrace_python import engine +from coretrace_python.findings import Finding +from coretrace_python.frontend import build_hir +from coretrace_python.hir import nodes +from coretrace_python.ir.lowering import analyzable_functions, lower_module +from coretrace_python.ir.printer import format_module +from coretrace_python.source import SourceManager + +try: + from coretrace_python.ir.model import MakeClass +except ImportError as error: # pragma: no cover - red until the limits close + MISSING: Exception | None = error +else: + MISSING = None + + +@pytest.fixture(autouse=True) +def require_limits() -> None: + if MISSING is not None: + pytest.fail(f"the remaining limits are not closed yet: {MISSING}") + + +REPO = Path(__file__).resolve().parent.parent +PLUGINS = REPO / "plugins" + + +def hir(text: str) -> nodes.Module: + return build_hir(SourceManager().add_source("m.py", text)) + + +def printed(text: str) -> str: + return format_module(lower_module(hir(text))) + + +def check(text: str) -> tuple[Finding, ...]: + return engine.check(SourceManager().add_source("m.py", text), [PLUGINS]) + + +def rules(findings: tuple[Finding, ...]) -> list[tuple[str, int, str | None]]: + return sorted((f.rule_id, f.span.start_line, f.function) for f in findings) + + +def notes(text: str) -> list[str]: + return [f.message for f in check(text) if f.rule_id in ("syntax-error", "unsupported-syntax")] + + +# --------------------------------------------------------------------------- positional class patterns + + +POINT = "class Point:\n __match_args__ = ('x', 'y')\n\n def __init__(self, x, y):\n self.x = x\n self.y = y\n\n" + + +def test_positional_class_patterns_use_match_args() -> None: + text = printed(POINT + "def f(p):\n match p:\n case Point(0, y):\n return y\n return None\n") + + assert "get_attr %" in text and "'x'" in text and "'y'" in text + assert "python.builtins.isinstance" in text + assert notes(POINT + "def f(p):\n match p:\n case Point(0, y):\n return y\n") == [] + + +def test_dataclass_fields_order_the_positions() -> None: + text = printed( + "from dataclasses import dataclass\n\n@dataclass\nclass Pair:\n left: int\n right: int = 0\n\n" + "def f(p):\n match p:\n case Pair(1, r):\n return r\n return None\n" + ) + assert "'left'" in text and "'right'" in text + + +def test_unknown_classes_match_positions_conservatively() -> None: + assert notes("def f(p):\n match p:\n case Vec(0, y):\n return y\n") == [] + text = printed("def f(p):\n match p:\n case Vec(0, y):\n return y\n return None\n") + assert "'_match_arg_0'" in text and "'_match_arg_1'" in text + + +def test_taint_flows_through_positional_captures() -> None: + findings = check( + "import os\n\n" + POINT + "def f():\n p = Point(input(), 1)\n match p:\n case Point(cmd, _):\n os.system(cmd)\n" + ) + assert rules(findings) == [("command-injection", 14, "f")] + + +# --------------------------------------------------------------------------- nonlocal + + +def test_nonlocal_assignments_lower_as_locals_of_the_nested_function() -> None: + text = "def outer():\n n = 0\n def bump():\n nonlocal n\n n = n + 1\n return n\n bump()\n return n\n" + assert notes(text) == [] + lowered = printed(text) + assert 'store_local "n"' in lowered.split("func @outer.bump")[1] + + +def test_a_nonlocal_write_is_visible_inside_the_writer_but_not_yet_outside() -> None: + inside = "import os\n\ndef outer():\n cmd = 'ls'\n def set_cmd():\n nonlocal cmd\n cmd = input()\n os.system(cmd)\n set_cmd()\n" + assert rules(check(inside)) == [("command-injection", 8, "set_cmd")] + # Documented limit: the write does not flow back to the enclosing function. + outside = "import os\n\ndef outer():\n cmd = 'ls'\n def set_cmd():\n nonlocal cmd\n cmd = input()\n set_cmd()\n os.system(cmd)\n" + assert check(outside) == () + + +# --------------------------------------------------------------------------- classes inside functions + + +def test_classes_defined_inside_functions_are_values_with_analysed_methods() -> None: + text = "import os\n\ndef outer():\n class Inner:\n def run(self):\n os.system(input())\n return Inner\n" + assert notes(text) == [] + lowered = printed(text) + assert 'make_class "Inner"' in lowered and 'store_local "Inner"' in lowered + assert "func @outer.Inner.run" in lowered + assert [f.name for f in analyzable_functions(hir(text))] == ["outer", "run"] + assert rules(check(text)) == [("command-injection", 6, "run")] + (made,) = [i for f in lower_module(hir(text)).functions for b in f.blocks for i in b.instructions if isinstance(i, MakeClass)] + assert made.name == "Inner" + + +def test_local_class_bases_and_decorators_are_evaluated() -> None: + text = "def outer(base, deco):\n @deco\n class Inner(base):\n pass\n return Inner\n" + assert notes(text) == [] + assert "make_class" in printed(text) + + +# --------------------------------------------------------------------------- control flow in while conditions + + +def test_conditionals_and_comprehensions_in_while_conditions_lower() -> None: + text = "def f(flag, items):\n n = 0\n while (items if flag else []) and [i for i in items if i]:\n n = n + 1\n items = items[1:]\n return n\n" + assert notes(text) == [] + lowered = printed(text) + assert lowered.count("for_next") >= 1 and "branch" in lowered + + +def test_the_condition_is_recomputed_every_iteration_and_else_still_runs() -> None: + text = ( + "import os\n\n" + "def f(flag):\n" + " while (input() if flag else 'q') != 'q':\n" + " os.system('echo')\n" + " else:\n" + " return 'done'\n" + " return 'broken'\n" + ) + assert notes(text) == [] + lowered = printed(text) + assert "'done'" in lowered and "'broken'" in lowered + assert "python.builtins.input" in lowered.split("loop_1:")[1] diff --git a/tests/test_syntax_control_flow.py b/tests/test_syntax_control_flow.py index 4a25cab..e3b9368 100644 --- a/tests/test_syntax_control_flow.py +++ b/tests/test_syntax_control_flow.py @@ -126,9 +126,8 @@ def test_conditionals_nest_inside_other_expressions() -> None: assert flow_lines("def f(a, b):\n os.system('ping ' + (input() if a else ('x' if b else 'y')))\n") == [4] -def test_conditionals_in_loop_conditions_are_reported_not_crashed() -> None: - found = notes("def f(x):\n while (x if x else 1):\n x = x - 1\n") - assert len(found) == 1 and "loop condition" in found[0] +def test_conditionals_in_loop_conditions_are_recomputed_each_iteration() -> None: + assert notes("def f(x):\n while (x if x else 1):\n x = x - 1\n") == [] # --------------------------------------------------------------------------- lambdas and nested functions diff --git a/tests/test_syntax_coverage.py b/tests/test_syntax_coverage.py index 92efd60..630bf7f 100644 --- a/tests/test_syntax_coverage.py +++ b/tests/test_syntax_coverage.py @@ -266,9 +266,7 @@ def test_check_reports_unsupported_functions_and_keeps_going(tmp_path, capsys) - source.write_text( "CONFIG = {}\n\n" "def outer():\n" - " class Inner:\n" - " pass\n" - " return Inner\n\n" + " break\n\n" "class Runner:\n" " def go(self, code):\n" " eval(code)\n", @@ -280,13 +278,13 @@ def test_check_reports_unsupported_functions_and_keeps_going(tmp_path, capsys) - assert exit_code == 1 assert f"{source}:3:1: info unsupported-syntax: " in output - assert "Class" in output - assert f"{source}:10:9: high dangerous-eval:" in output + assert "break" in output + assert f"{source}:8:9: high dangerous-eval:" in output assert output.endswith("2 findings\ncoverage: 1/1 files, 1/2 functions\n") def test_unsupported_syntax_findings_are_notes() -> None: - findings = engine.check(SourceManager().add_source("n.py", "def f():\n class G:\n pass\n"), [PLUGINS]) + findings = engine.check(SourceManager().add_source("n.py", "def f():\n break\n"), [PLUGINS]) assert [f.rule_id for f in findings] == ["unsupported-syntax"] assert findings[0].severity is Severity.INFO From d1ced9b33bda3b140daba8ab79c2e99f6e7359dc Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 4 Sep 2026 18:15:31 +0200 Subject: [PATCH 2/2] feat: close the documented limits on patterns, nonlocal, local classes and while conditions --- README.md | 11 ++- src/coretrace_python/cfg/builder.py | 88 ++++++++++++++++---- src/coretrace_python/frontend/ast_adapter.py | 11 ++- src/coretrace_python/hir/nodes.py | 10 +++ src/coretrace_python/ir/lowering.py | 43 ++++++++-- src/coretrace_python/ir/model.py | 11 +++ src/coretrace_python/ir/printer.py | 3 + src/coretrace_python/semantic/scopes.py | 2 + 8 files changed, 150 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 4e9f2cb..da2db84 100644 --- a/README.md +++ b/README.md @@ -169,10 +169,13 @@ synthetic local, set literals, chained assignments, assignments to `global` name lambdas and nested function definitions, analysed as functions of their own whose captured variables are implicit parameters so taint flows through closures like through any call, `del`, loop `else` clauses, and `match` over literal, singleton, capture, wildcard, -or, sequence, mapping and keyword class patterns with guards, laid out as an `if` chain -over length, index, membership, attribute and `isinstance` tests. Not yet: positional -class patterns, `nonlocal` assignments, classes defined inside functions, and a -conditional expression or comprehension inside a `while` condition. Blocks inside a `try` +or, sequence, mapping and class patterns with guards, laid out as an `if` chain over +length, index, membership, attribute and `isinstance` tests, positional class patterns +using the class's `__match_args__` or a dataclass's field order when the class is in the +module; `nonlocal` assignments, whose writes stay inside the nested function; classes +defined inside functions, whose methods are analysed as nested functions; and control flow +inside a `while` condition, recomputed every iteration. Left: a function using syntax +the frontend rejects is reported as a `syntax-error` for its file. Blocks inside a `try` body carry exception edges to the handlers; `finally` is modelled on the normal path only. Methods of module-level classes are analysed like functions; other module-level code is skipped. An import inside a function is shown where it runs, as an `import` instruction naming the module, the bound name and the canonical symbol. A function using syntax outside this subset is reported by `--check` as diff --git a/src/coretrace_python/cfg/builder.py b/src/coretrace_python/cfg/builder.py index 671db81..f0c46a3 100644 --- a/src/coretrace_python/cfg/builder.py +++ b/src/coretrace_python/cfg/builder.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import fields, is_dataclass, replace from typing import Any, ClassVar @@ -31,7 +32,10 @@ def __init__(self, block_id: BlockId) -> None: class _Builder: - def __init__(self, function: nodes.Function) -> None: + def __init__( + self, function: nodes.Function, match_args: Mapping[str, tuple[str, ...]] | None = None + ) -> None: + self.match_args = dict(match_args or {}) self.function = function self.blocks: dict[BlockId, BasicBlock] = {} self.counters: dict[str, int] = {} @@ -59,12 +63,28 @@ def desugared(self, node: nodes.Statement, block: _Open) -> tuple[nodes.Statemen pending: list[nodes.Statement] = [] if isinstance(node, nodes.While): - if _has_control_flow(node.condition): - raise CFGError( - f"{node.span.display()}: conditional expressions and comprehensions in a " - "loop condition are not supported yet" - ) - return node, block + if not _has_control_flow(node.condition): + return node, block + # ``while :`` recomputes its condition every iteration: it + # becomes ``while True`` with the condition laid out at the top of the body + # and a ``break``; the ``else`` clause runs before that ``break``. + inner: list[nodes.Statement] = [] + condition = self.hoist(node.condition, inner) + stop = nodes.If( + nodes.UnaryOp("not", condition, node.span), + (*node.orelse, nodes.Break(node.span)), + (), + node.span, + ) + return ( + replace( + node, + condition=nodes.Constant(True, node.span), + body=(*inner, stop, *node.body), + orelse=(), + ), + block, + ) if isinstance(node, nodes.Assign | nodes.AugAssign): node = replace(node, target=self.hoist(node.target, pending), value=self.hoist(node.value, pending)) elif isinstance(node, nodes.ExpressionStatement): @@ -430,16 +450,17 @@ def class_pattern( """``Cls(name=pattern)``: an instance of the class whose attributes match.""" span = node.span - if node.patterns: - raise CFGError( - f"{span.display()}: positional class patterns need the class's __match_args__ " - "and are not supported yet" - ) conditions: list[nodes.Expression] = [ nodes.Call(nodes.Name("isinstance", span), (subject, node.cls), (), span) ] bindings: list[nodes.Statement] = [] - for name, sub in zip(node.keyword_names, node.keyword_patterns, strict=True): + # Positional sub-patterns match the attributes ``__match_args__`` names, known + # for the module's classes; an unknown class gets a conservative position. + known = self.match_args.get(node.cls.identifier, ()) if isinstance(node.cls, nodes.Name) else () + positional = [ + (known[i] if i < len(known) else f"_match_arg_{i}", sub) for i, sub in enumerate(node.patterns) + ] + for name, sub in (*positional, *zip(node.keyword_names, node.keyword_patterns, strict=True)): condition, inner = self.pattern(sub, nodes.Attribute(subject, name, span)) conditions.append(condition) bindings.extend(inner) @@ -521,8 +542,43 @@ def _rename_target(target: nodes.Target, renames: dict[str, str]) -> nodes.Targe return renamed -def build_cfg(function: nodes.Function) -> CFG: - return _Builder(function).build() +def build_cfg(function: nodes.Function, match_args: Mapping[str, tuple[str, ...]] | None = None) -> CFG: + return _Builder(function, match_args).build() + + +def match_args_of(module: nodes.Module) -> dict[str, tuple[str, ...]]: + """``__match_args__`` of the module's classes: explicit, or the field order of a + dataclass (bare annotated declarations and assignments, in order).""" + + found: dict[str, tuple[str, ...]] = {} + for statement in module.body: + if not isinstance(statement, nodes.Class): + continue + explicit = None + fields: list[str] = [] + for member in statement.body: + if isinstance(member, nodes.Assign) and isinstance(member.target, nodes.Name): + if member.target.identifier == "__match_args__" and isinstance(member.value, nodes.Tuple | nodes.List): + explicit = tuple( + e.value for e in member.value.elements if isinstance(e, nodes.Constant) and isinstance(e.value, str) + ) + elif not member.target.identifier.startswith("_"): + fields.append(member.target.identifier) + elif isinstance(member, nodes.Declaration): + fields.append(member.name) + if explicit is not None: + found[statement.name] = explicit + elif any(_is_dataclass(d) for d in statement.decorators): + found[statement.name] = tuple(fields) + return found + + +def _is_dataclass(decorator: nodes.Expression) -> bool: + if isinstance(decorator, nodes.Call): + decorator = decorator.callee + if isinstance(decorator, nodes.Name): + return decorator.identifier == "dataclass" + return isinstance(decorator, nodes.Attribute) and decorator.name == "dataclass" class CFGAnalysis(FunctionAnalysis[CFG]): @@ -530,4 +586,4 @@ class CFGAnalysis(FunctionAnalysis[CFG]): @classmethod def compute(cls, ctx: AnalysisContext, function: nodes.Function) -> CFG: - return build_cfg(function) + return build_cfg(function, match_args_of(ctx.module)) diff --git a/src/coretrace_python/frontend/ast_adapter.py b/src/coretrace_python/frontend/ast_adapter.py index 07be2f5..91860c2 100644 --- a/src/coretrace_python/frontend/ast_adapter.py +++ b/src/coretrace_python/frontend/ast_adapter.py @@ -217,9 +217,16 @@ def statement(self, node: ast.stmt) -> nodes.Statement: if isinstance(node, ast.Assign): return nodes.Assign(self.target(node.targets[0]), self.expression(node.value), span) if isinstance(node, ast.AnnAssign): - # The annotation never affects behaviour; a bare declaration does nothing. + # The annotation never affects behaviour; a bare declaration binds nothing + # but names a dataclass field or an annotated local. if node.value is None: - return nodes.Pass(span) + annotation: nodes.Expression | None = None + try: + annotation = self.expression(node.annotation) + except HIRBuildError: + annotation = None + name = node.target.id if isinstance(node.target, ast.Name) else None + return nodes.Declaration(name, annotation, span) if name is not None else nodes.Pass(span) return nodes.Assign(self.target(node.target), self.expression(node.value), span) if isinstance(node, ast.AugAssign): operator = _BINARY_OPERATORS.get(type(node.op)) diff --git a/src/coretrace_python/hir/nodes.py b/src/coretrace_python/hir/nodes.py index 4b56a63..cc36934 100644 --- a/src/coretrace_python/hir/nodes.py +++ b/src/coretrace_python/hir/nodes.py @@ -266,6 +266,15 @@ class Delete: span: SourceSpan +@dataclass(frozen=True, slots=True) +class Declaration: + """``name: annotation`` without a value: a dataclass field or an annotated local.""" + + name: str + annotation: Expression | None + span: SourceSpan + + @dataclass(frozen=True, slots=True) class ValuePattern: """``case ``: equality with a value.""" @@ -545,6 +554,7 @@ class Class: | Pass | Delete | Match + | Declaration | Assert | If | While diff --git a/src/coretrace_python/ir/lowering.py b/src/coretrace_python/ir/lowering.py index 25a39e1..47a686c 100644 --- a/src/coretrace_python/ir/lowering.py +++ b/src/coretrace_python/ir/lowering.py @@ -46,6 +46,7 @@ Instruction, Jump, LoadLocal, + MakeClass, MakeFunction, ModuleIR, Raise, @@ -65,6 +66,7 @@ ) from coretrace_python.semantic import SEMANTIC_ANALYSES from coretrace_python.semantic.scopes import ( + BindingKind, Resolution, ResolutionKind, Scope, @@ -134,7 +136,10 @@ def expression(self, node: nodes.Expression) -> Value: if isinstance(node, nodes.Name): resolution = self.resolve(node.identifier) if resolution.kind is ResolutionKind.FREE: - # A captured variable is an implicit parameter of the nested function. + # A captured variable is an implicit parameter of the nested function; a + # ``nonlocal`` one lives in a local slot initialised from it. + if node.identifier in self.nonlocals: + return self.emit(LoadLocal(self.new_value(), node.span, node.identifier)) if node.identifier in self.parameters: return self.parameters[node.identifier] self.fail(node, "closures are not supported yet") @@ -223,8 +228,8 @@ def store(self, target: nodes.Target, value: Value) -> None: if resolution.kind is ResolutionKind.GLOBAL: self.emit_effect(SetGlobal(None, target.span, target.identifier, value)) return - if resolution.kind is not ResolutionKind.LOCAL: - self.fail(target, "assignment to a nonlocal name is not supported yet") + if resolution.kind is not ResolutionKind.LOCAL and target.identifier not in self.nonlocals: + self.fail(target, "assignment to a free variable that is not declared nonlocal") self.emit_effect(StoreLocal(None, target.span, target.identifier, value)) elif isinstance(target, nodes.Attribute): object_value = self.expression(target.value) @@ -244,6 +249,18 @@ def statement(self, node: nodes.Statement) -> None: if isinstance(node, nodes.Assign): self.store(node.target, self.expression(node.value)) return + if isinstance(node, nodes.Declaration): + return + if isinstance(node, nodes.Class): + # A local class is a value bound to its name; bases and decorators run here, + # its methods are analysed as nested functions. + for decorator in node.decorators: + self.expression(decorator) + for base in node.bases: + self.expression(base) + made_class = self.emit(MakeClass(self.new_value(), node.span, node.name)) + self.store(nodes.Name(node.name, node.span), made_class) + return if isinstance(node, nodes.Delete): for target in node.targets: if isinstance(target, nodes.Subscript): @@ -308,7 +325,7 @@ def statement(self, node: nodes.Statement) -> None: written = alias.name if isinstance(node, nodes.Import) else module self.emit_effect(Import(None, alias.span, written, symbol, bound)) return - if isinstance(node, nodes.Pass | nodes.Global): + if isinstance(node, nodes.Pass | nodes.Global | nodes.Nonlocal): # Declarations are already applied by the semantic analyses. return self.fail(node) @@ -365,9 +382,15 @@ def function(self, node: nodes.Function) -> FunctionIR: for parameter, value in zip(node.parameters, parameter_values, strict=False) if parameter.name not in reassigned } - # Captured variables come after the explicit parameters; they are never - # reassigned, an assignment would have made them local. - self.parameters.update(zip(captured, parameter_values[len(node.parameters) :], strict=True)) + # Captured variables come after the explicit parameters. A ``nonlocal`` one is + # assigned in this body, so it lives in a local slot initialised from the + # captured value; the others are never reassigned. + bindings = self.scopes.scope_for(node).bindings + self.nonlocals = frozenset( + name for name in captured if name in bindings and bindings[name].kind is BindingKind.NONLOCAL + ) + captured_values = dict(zip(captured, parameter_values[len(node.parameters) :], strict=True)) + self.parameters.update({n: v for n, v in captured_values.items() if n not in self.nonlocals}) blocks: list[BasicBlock] = [] for cfg_block in self.cfg.blocks.values(): self.instructions = [] @@ -376,6 +399,8 @@ def function(self, node: nodes.Function) -> FunctionIR: for parameter, value in zip(node.parameters, parameter_values, strict=False): if parameter.name in reassigned: self.emit_effect(StoreLocal(None, parameter.span, parameter.name, value)) + for name in sorted(self.nonlocals): + self.emit_effect(StoreLocal(None, node.span, name, captured_values[name])) for statement in cfg_block.statements: self.statement(statement) terminator = self.terminator(cfg_block) @@ -508,6 +533,10 @@ def walk(node: Node) -> None: _collect(lambda_function(node), into) return if isinstance(node, nodes.Class): + # The methods of a class defined inside a function are nested functions. + for member in node.body: + if isinstance(member, nodes.Function): + _collect(member, into) return for child in children(node): walk(child) diff --git a/src/coretrace_python/ir/model.py b/src/coretrace_python/ir/model.py index 86a2ca8..69c66c3 100644 --- a/src/coretrace_python/ir/model.py +++ b/src/coretrace_python/ir/model.py @@ -176,6 +176,16 @@ class MakeFunction(Operands): captured: tuple[Value, ...] = () +@dataclass(frozen=True) +class MakeClass(Operands): + """A class defined inside a function, bound to its name; its methods are analysed + as nested functions.""" + + result: Value + location: SourceSpan + name: str + + @dataclass(frozen=True) class BuildString(Operands): """An f-string: its constant and formatted parts, in order.""" @@ -374,6 +384,7 @@ class Import(Operands): | BuildString | BuildSlice | MakeFunction + | MakeClass | WithEnter | Catch | Await diff --git a/src/coretrace_python/ir/printer.py b/src/coretrace_python/ir/printer.py index 190b7b7..f1e6031 100644 --- a/src/coretrace_python/ir/printer.py +++ b/src/coretrace_python/ir/printer.py @@ -27,6 +27,7 @@ Instruction, Jump, LoadLocal, + MakeClass, MakeFunction, ModuleIR, Phi, @@ -102,6 +103,8 @@ def _instruction(instruction: Instruction) -> str: return f"del_item {_value(instruction.object)}, {_value(instruction.key)}" if isinstance(instruction, DelAttr): return f'del_attr {_value(instruction.object)}, "{instruction.attribute}"' + if isinstance(instruction, MakeClass): + return f'{_value(instruction.result)} = make_class "{instruction.name}"' if isinstance(instruction, SetGlobal): return f'set_global "{instruction.name}", {_value(instruction.value)}' if isinstance(instruction, BuildString): diff --git a/src/coretrace_python/semantic/scopes.py b/src/coretrace_python/semantic/scopes.py index 4cbfd9d..6a66fab 100644 --- a/src/coretrace_python/semantic/scopes.py +++ b/src/coretrace_python/semantic/scopes.py @@ -317,6 +317,8 @@ def statement(self, node: nodes.Statement, scope: _ScopeBuilder) -> None: elif isinstance(node, nodes.Delete): for target in node.targets: self.target(target, scope) + elif isinstance(node, nodes.Declaration): + scope.bind(node.name, BindingKind.LOCAL, node.span) elif not isinstance(node, nodes.Pass | nodes.Break | nodes.Continue): raise TypeError(f"unknown statement: {node!r}")