From 925a49a06eef819bf3588ee0019aa51599d64c72 Mon Sep 17 00:00:00 2001 From: sebas8866 Date: Sat, 17 Oct 2026 11:13:30 -0400 Subject: [PATCH 1/9] fix(scopes): visit expressions in parameter annotations --- src/coretrace_python/semantic/scopes.py | 2 + tests/test_annotation_expression_scopes.py | 93 ++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 tests/test_annotation_expression_scopes.py diff --git a/src/coretrace_python/semantic/scopes.py b/src/coretrace_python/semantic/scopes.py index 4120871..7988b46 100644 --- a/src/coretrace_python/semantic/scopes.py +++ b/src/coretrace_python/semantic/scopes.py @@ -281,6 +281,8 @@ def statement(self, node: nodes.Statement, scope: _ScopeBuilder) -> None: for decorator in node.decorators: self.expression(decorator, scope) for parameter in node.parameters: + if parameter.annotation is not None: + self.expression(parameter.annotation, scope) if parameter.default is not None: self.expression(parameter.default, scope) scope.bind(node.name, BindingKind.FUNCTION, node.span) diff --git a/tests/test_annotation_expression_scopes.py b/tests/test_annotation_expression_scopes.py new file mode 100644 index 0000000..a007bbd --- /dev/null +++ b/tests/test_annotation_expression_scopes.py @@ -0,0 +1,93 @@ +"""Regression coverage for lexical scopes created inside parameter annotations.""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +from coretrace_python.frontend import build_hir +from coretrace_python.semantic.scopes import ResolutionKind, ScopeKind, analyze_scopes +from coretrace_python.source import SourceManager + + +class AnnotationExpressionScopes(unittest.TestCase): + def _check_directory(self, contents: str) -> None: + with tempfile.TemporaryDirectory() as temporary: + project = Path(temporary) / "project" + project.mkdir() + (project / "app.py").write_text(contents, encoding="utf-8") + env = os.environ.copy() + result = subprocess.run( + [sys.executable, "-m", "coretrace_python", "--check", str(project)], + capture_output=True, + text=True, + timeout=15, + check=False, + env=env, + ) + self.assertEqual(result.returncode, 0, result.stderr[-1000:]) + self.assertNotIn("KeyError", result.stderr) + + def test_keyword_only_parameter_lambda_annotation_does_not_crash_project(self): + self._check_directory("def f(*, status: (lambda value: value) = None):\n return status\n") + + def test_positional_parameter_lambda_annotation_does_not_crash_project(self): + self._check_directory("def f(status: (lambda value: value) = None):\n return status\n") + + def test_parameter_comprehension_annotation_does_not_crash_project(self): + self._check_directory("entries = (1, 2)\ndef f(status: [v for v in entries]):\n return status\n") + + def test_annotation_scopes_belong_to_the_enclosing_module(self): + source = SourceManager().add_source( + "annotation_scopes.py", + "entries = (1, 2)\ndef f(first: (lambda x: x), second: [v for v in entries]):\n pass\n", + ) + hir = build_hir(source) + scopes = analyze_scopes(hir) + function = hir.body[1] + first, second = function.parameters + lambda_scope = scopes.scope_for(first.annotation) + comprehension_scope = scopes.scope_for(second.annotation) + self.assertEqual(lambda_scope.kind, ScopeKind.FUNCTION) + self.assertEqual(comprehension_scope.kind, ScopeKind.COMPREHENSION) + self.assertEqual(lambda_scope.parent, scopes.module_scope.id) + self.assertEqual(comprehension_scope.parent, scopes.module_scope.id) + + def test_varargs_annotations_create_scopes_in_the_enclosing_module(self): + source = SourceManager().add_source( + "varargs_scopes.py", + "def f(*args: (lambda x: x), **kwargs: [v for v in (1, 2)]):\n pass\n", + ) + hir = build_hir(source) + scopes = analyze_scopes(hir) + first, second = hir.body[0].parameters + self.assertEqual(scopes.scope_for(first.annotation).parent, scopes.module_scope.id) + self.assertEqual(scopes.scope_for(second.annotation).parent, scopes.module_scope.id) + + def test_nested_parameter_annotation_resolves_enclosing_local(self): + source = SourceManager().add_source( + "nested_annotations.py", + "def outer():\n" + " secret = 1\n" + " def inner(value: (lambda: secret)):\n" + " return value\n" + " return inner\n", + ) + hir = build_hir(source) + scopes = analyze_scopes(hir) + outer = hir.body[0] + inner = outer.body[1] + annotation_scope = scopes.scope_for(inner.parameters[0].annotation) + outer_scope = scopes.scope_for(outer) + self.assertEqual(annotation_scope.parent, outer_scope.id) + resolution = scopes.resolve(annotation_scope.id, "secret") + self.assertEqual(resolution.kind, ResolutionKind.FREE) + self.assertEqual(resolution.scope, outer_scope.id) + + +if __name__ == "__main__": + unittest.main() From fc8a8b23af42bdb55316267d1d96998439b388a9 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 01:15:52 +0200 Subject: [PATCH 2/9] style(tests): sort the imports of the annotation scope tests --- tests/test_annotation_expression_scopes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_annotation_expression_scopes.py b/tests/test_annotation_expression_scopes.py index a007bbd..c85dce9 100644 --- a/tests/test_annotation_expression_scopes.py +++ b/tests/test_annotation_expression_scopes.py @@ -3,11 +3,11 @@ from __future__ import annotations import os -from pathlib import Path import subprocess import sys import tempfile import unittest +from pathlib import Path from coretrace_python.frontend import build_hir from coretrace_python.semantic.scopes import ResolutionKind, ScopeKind, analyze_scopes From 8fef21c2e68a521eaa4dd6268256399693aed7b7 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 01:15:52 +0200 Subject: [PATCH 3/9] test(scopes): cover the bindings of evaluated annotations --- tests/test_annotation_bindings.py | 43 +++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tests/test_annotation_bindings.py diff --git a/tests/test_annotation_bindings.py b/tests/test_annotation_bindings.py new file mode 100644 index 0000000..7597251 --- /dev/null +++ b/tests/test_annotation_bindings.py @@ -0,0 +1,43 @@ +"""What an annotation binds in the scope it stands in. + +An annotation evaluated when its definition runs binds the targets of its assignment +expressions there, as any expression does. Its lambdas and comprehensions introduce +scopes of their own, which keep their bindings. +""" + +from __future__ import annotations + +from coretrace_python.frontend import build_hir +from coretrace_python.semantic.scopes import BindingKind, Scope, ScopeTable, analyze_scopes +from coretrace_python.source import SourceManager + +POSTPONED = "from __future__ import annotations\n\n" + + +def analyze(text: str) -> ScopeTable: + return analyze_scopes(build_hir(SourceManager().add_source("app.py", text))) + + +def child(scopes: ScopeTable, parent: Scope, name: str) -> Scope: + (found,) = (scope for scope in scopes.children(parent.id) if scope.name == name) + return found + + +def test_an_evaluated_parameter_annotation_binds_where_the_function_is_defined() -> None: + scopes = analyze("def f(x: [(found := i) for i in (1,)]):\n pass\n") + + assert scopes.module_scope.bindings["found"].kind is BindingKind.LOCAL + + +def test_an_evaluated_parameter_annotation_binds_in_the_enclosing_function() -> None: + scopes = analyze("def outer():\n def inner(x: [(found := i) for i in (1,)]):\n pass\n") + + assert "found" in child(scopes, scopes.module_scope, "outer").bindings + assert "found" not in scopes.module_scope.bindings + + +def test_a_lambda_in_a_postponed_annotation_keeps_its_own_bindings() -> None: + scopes = analyze(POSTPONED + "def f(x: (lambda: (inner := 1))):\n pass\n") + + assert "inner" in child(scopes, scopes.module_scope, "").bindings + assert "inner" not in scopes.module_scope.bindings From 36b0118fd39b3ae8873ce490c78b278a3b6fa21d Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 01:15:52 +0200 Subject: [PATCH 4/9] fix(scopes): bind nothing from postponed annotations Under from __future__ import annotations, an annotation is never evaluated, yet an assignment expression in one bound its target where the function is defined: a builtin it named, such as eval, resolved to a binding that never exists, and its calls were not reported. Such an annotation still registers the scopes of its lambdas and comprehensions, but binds nothing in the scope it stands in. --- src/coretrace_python/semantic/scopes.py | 34 +++++++++++++- tests/test_annotation_bindings.py | 62 ++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/src/coretrace_python/semantic/scopes.py b/src/coretrace_python/semantic/scopes.py index 7988b46..cf1de7d 100644 --- a/src/coretrace_python/semantic/scopes.py +++ b/src/coretrace_python/semantic/scopes.py @@ -221,6 +221,9 @@ def __init__(self, module: nodes.Module) -> None: # The module scope is found by the module's span, as the synthetic ```` # function carries it. self.spans: dict[SourceSpan, ScopeId] = {module.span: self.module.id} + self.postponed = _postpones_annotations(module) + # The scope an annotation that is never evaluated stands in, while it is visited. + self.unevaluated: _ScopeBuilder | None = None self.body(module.body, self.module) def body(self, statements: tuple[nodes.Statement, ...], scope: _ScopeBuilder) -> None: @@ -282,7 +285,7 @@ def statement(self, node: nodes.Statement, scope: _ScopeBuilder) -> None: self.expression(decorator, scope) for parameter in node.parameters: if parameter.annotation is not None: - self.expression(parameter.annotation, scope) + self.annotation(parameter.annotation, scope, evaluated=not self.postponed) if parameter.default is not None: self.expression(parameter.default, scope) scope.bind(node.name, BindingKind.FUNCTION, node.span) @@ -363,7 +366,8 @@ def expression(self, node: nodes.Expression, scope: _ScopeBuilder) -> None: owner = scope while owner.kind is ScopeKind.COMPREHENSION and owner.parent is not None: owner = owner.parent - owner.bind(node.target.identifier, BindingKind.LOCAL, node.target.span) + if owner is not self.unevaluated: + owner.bind(node.target.identifier, BindingKind.LOCAL, node.target.span) elif isinstance(node, nodes.Tuple | nodes.List): for element in node.elements: self.expression(element, scope) @@ -399,6 +403,18 @@ def expression(self, node: nodes.Expression, scope: _ScopeBuilder) -> None: else: raise TypeError(f"unknown expression: {node!r}") + def annotation(self, node: nodes.Expression, scope: _ScopeBuilder, evaluated: bool) -> None: + """Visit an annotation standing in ``scope``. One that is never evaluated still + introduces the scopes of its lambdas and comprehensions, which later passes look + up, but binds nothing in ``scope``: its assignment expressions never run.""" + + if evaluated: + self.expression(node, scope) + return + self.unevaluated = scope + self.expression(node, scope) + self.unevaluated = None + def pattern(self, node: nodes.Pattern, scope: _ScopeBuilder) -> None: if isinstance(node, nodes.ValuePattern): self.expression(node.value, scope) @@ -463,6 +479,20 @@ def open( return builder +def _postpones_annotations(module: nodes.Module) -> bool: + """Whether ``module`` imports ``annotations`` from ``__future__`` (PEP 563): its + annotations are then kept as strings, never evaluated. Python accepts a future + import only at the top of the module, so the module's statements are enough.""" + + return any( + isinstance(statement, nodes.ImportFrom) + and statement.module == "__future__" + and statement.level == 0 + and any(alias.name == "annotations" for alias in statement.names) + for statement in module.body + ) + + def analyze_scopes(module: nodes.Module) -> ScopeTable: """Build the scope tree of ``module`` and validate its declarations.""" diff --git a/tests/test_annotation_bindings.py b/tests/test_annotation_bindings.py index 7597251..c0e714d 100644 --- a/tests/test_annotation_bindings.py +++ b/tests/test_annotation_bindings.py @@ -1,17 +1,30 @@ """What an annotation binds in the scope it stands in. An annotation evaluated when its definition runs binds the targets of its assignment -expressions there, as any expression does. Its lambdas and comprehensions introduce -scopes of their own, which keep their bindings. +expressions there, as any expression does. One that is never evaluated, under +``from __future__ import annotations`` (PEP 563), binds nothing there: a builtin it +names, such as ``eval``, stays the builtin. Either way, its lambdas and comprehensions +introduce scopes of their own, which keep their bindings. """ from __future__ import annotations +from collections.abc import Iterable +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.semantic.scopes import BindingKind, Scope, ScopeTable, analyze_scopes from coretrace_python.source import SourceManager POSTPONED = "from __future__ import annotations\n\n" +PLUGINS = [engine.BUNDLED_PLUGINS] +# The comprehension never runs, and its annotation is a string: ``eval`` stays the builtin. +SHADOWING = POSTPONED + "def f(x: [(eval := i) for i in ()]):\n pass\n\n\ndef run():\n eval(input())\n" +EVAL_FINDINGS = {("code-injection", 8), ("dangerous-eval", 8)} def analyze(text: str) -> ScopeTable: @@ -23,6 +36,10 @@ def child(scopes: ScopeTable, parent: Scope, name: str) -> Scope: return found +def located(findings: Iterable[Finding]) -> set[tuple[str, int]]: + return {(finding.rule_id, finding.span.start_line) for finding in findings} + + def test_an_evaluated_parameter_annotation_binds_where_the_function_is_defined() -> None: scopes = analyze("def f(x: [(found := i) for i in (1,)]):\n pass\n") @@ -41,3 +58,44 @@ def test_a_lambda_in_a_postponed_annotation_keeps_its_own_bindings() -> None: assert "inner" in child(scopes, scopes.module_scope, "").bindings assert "inner" not in scopes.module_scope.bindings + + +def test_a_postponed_parameter_annotation_binds_nothing_where_it_stands() -> None: + scopes = analyze(POSTPONED + "def f(x: [(found := i) for i in (1,)]):\n pass\n") + + assert "found" not in scopes.module_scope.bindings + assert child(scopes, scopes.module_scope, "").parent == scopes.module_scope.id + + +@pytest.mark.parametrize( + "header", + ["from .__future__ import annotations\n", "from __future__ import division\n", "import __future__\n"], +) +def test_only_the_future_statement_postpones_annotations(header: str) -> None: + scopes = analyze(header + "def f(x: [(found := i) for i in (1,)]):\n pass\n") + + assert "found" in scopes.module_scope.bindings + + +def test_assignment_expressions_after_a_postponed_annotation_still_bind() -> None: + scopes = analyze(POSTPONED + "def f(x: (lambda: 1)):\n pass\n\n\n[(found := i) for i in (1,)]\n") + + assert "found" in scopes.module_scope.bindings + + +def test_a_postponed_annotation_in_a_function_binds_nothing_there() -> None: + scopes = analyze(POSTPONED + "def outer():\n def inner(x: [(found := i) for i in (1,)]):\n pass\n") + + assert "found" not in child(scopes, scopes.module_scope, "outer").bindings + + +def test_a_builtin_named_in_a_postponed_annotation_stays_the_builtin_in_a_file() -> None: + findings = engine.check(SourceManager().add_source("app.py", SHADOWING), PLUGINS) + + assert located(findings) == EVAL_FINDINGS + + +def test_a_builtin_named_in_a_postponed_annotation_stays_the_builtin_in_a_project(tmp_path: Path) -> None: + (tmp_path / "app.py").write_text(SHADOWING, encoding="utf-8") + + assert located(engine.analyze_project(tmp_path, PLUGINS).findings) == EVAL_FINDINGS From f9d8be6b301fda5396e1fd659af4714c14dbebe3 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 01:15:52 +0200 Subject: [PATCH 5/9] fix(scopes): register the scopes of variable annotations A variable annotation kept its expression in the HIR without registering its scopes, so a lambda or comprehension there raised KeyError. Register them as for parameters; a local variable's annotation is never evaluated (PEP 526) and binds nothing. --- src/coretrace_python/semantic/scopes.py | 4 +++ tests/test_annotation_bindings.py | 35 ++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/coretrace_python/semantic/scopes.py b/src/coretrace_python/semantic/scopes.py index cf1de7d..d188734 100644 --- a/src/coretrace_python/semantic/scopes.py +++ b/src/coretrace_python/semantic/scopes.py @@ -328,6 +328,10 @@ def statement(self, node: nodes.Statement, scope: _ScopeBuilder) -> None: self.target(target, scope) elif isinstance(node, nodes.Declaration): scope.bind(node.name, BindingKind.LOCAL, node.span) + if node.annotation is not None: + # PEP 526: the annotation of a local variable is never evaluated. + evaluated = not self.postponed and scope.kind is not ScopeKind.FUNCTION + self.annotation(node.annotation, scope, evaluated) elif not isinstance(node, nodes.Pass | nodes.Break | nodes.Continue): raise TypeError(f"unknown statement: {node!r}") diff --git a/tests/test_annotation_bindings.py b/tests/test_annotation_bindings.py index c0e714d..b1bd021 100644 --- a/tests/test_annotation_bindings.py +++ b/tests/test_annotation_bindings.py @@ -2,9 +2,9 @@ An annotation evaluated when its definition runs binds the targets of its assignment expressions there, as any expression does. One that is never evaluated, under -``from __future__ import annotations`` (PEP 563), binds nothing there: a builtin it -names, such as ``eval``, stays the builtin. Either way, its lambdas and comprehensions -introduce scopes of their own, which keep their bindings. +``from __future__ import annotations`` (PEP 563) or on a local variable (PEP 526), binds +nothing there: a builtin it names, such as ``eval``, stays the builtin. Either way, its +lambdas and comprehensions introduce scopes of their own, which keep their bindings. """ from __future__ import annotations @@ -99,3 +99,32 @@ def test_a_builtin_named_in_a_postponed_annotation_stays_the_builtin_in_a_projec (tmp_path / "app.py").write_text(SHADOWING, encoding="utf-8") assert located(engine.analyze_project(tmp_path, PLUGINS).findings) == EVAL_FINDINGS + + +def test_a_lambda_or_comprehension_in_a_variable_annotation_leaves_a_project_analysable(tmp_path: Path) -> None: + (tmp_path / "app.py").write_text( + "settings: (lambda: 1)\n\n\ndef run():\n pending: [v for v in ()]\n eval(input())\n", encoding="utf-8" + ) + + assert located(engine.analyze_project(tmp_path, PLUGINS).findings) == {("code-injection", 6), ("dangerous-eval", 6)} + + +def test_a_module_variable_annotation_binds_where_it_stands() -> None: + scopes = analyze("settings: [(found := i) for i in (1,)]\n") + + assert "found" in scopes.module_scope.bindings + assert child(scopes, scopes.module_scope, "").parent == scopes.module_scope.id + + +def test_a_local_variable_annotation_binds_nothing_in_its_function() -> None: + scopes = analyze("def run():\n pending: [(found := i) for i in (1,)]\n") + run = child(scopes, scopes.module_scope, "run") + + assert "found" not in run.bindings + assert child(scopes, run, "").parent == run.id + + +def test_a_postponed_module_variable_annotation_binds_nothing() -> None: + scopes = analyze(POSTPONED + "settings: [(found := i) for i in (1,)]\n") + + assert "found" not in scopes.module_scope.bindings From 3204f94cccab7280658e0b3dc5c7a2b3c808626a Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 01:15:52 +0200 Subject: [PATCH 6/9] docs: state where an assignment expression in an annotation binds --- docs/usage.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/usage.md b/docs/usage.md index 5ea6847..44273e9 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -914,7 +914,10 @@ comprehensions, lambdas, nested functions and classes, `global`, `nonlocal`, `de assignment expressions (`(y := f(x))`, laid out as an assignment before the statement), `yield from`, starred assignment targets (`first, *rest = items`), class keyword arguments such as `metaclass=`, `with` targets of any assignable form and `match` with every -pattern kind. `finally` is modelled on the normal path only. A +pattern kind. An assignment expression in an annotation binds its name as Python 3.11 to +3.13 do, where the annotation stands, unless the module imports `annotations` from +`__future__` or the annotation is a local variable's: Python never evaluates those, so a +builtin they name stays the builtin. `finally` is modelled on the normal path only. A `nonlocal` write made by a nested function flows back to the enclosing variable when the enclosing function calls that nested function directly by name; a nested function passed around and called elsewhere keeps its writes to itself. Where the nested function may From ffefcf1502a79f7daad484f4fd48b194188ade47 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 03:40:15 +0200 Subject: [PATCH 7/9] fix(scopes): keep what annotations declare in functions Python's compiler declares the target of an assignment expression in an annotation whether the annotation is evaluated or not: in a function, the name is a local, which a nested function may declare nonlocal. Binding nothing from postponed and local variable annotations dropped that declaration, so a valid nonlocal raised ScopeError and the flow through it was lost. Only a module's names exist once assigned: only there does an annotation that is never evaluated bind nothing. --- src/coretrace_python/semantic/scopes.py | 17 +++++++++++------ tests/test_annotation_bindings.py | 21 ++++++++++++--------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/coretrace_python/semantic/scopes.py b/src/coretrace_python/semantic/scopes.py index d188734..779bd42 100644 --- a/src/coretrace_python/semantic/scopes.py +++ b/src/coretrace_python/semantic/scopes.py @@ -222,7 +222,8 @@ def __init__(self, module: nodes.Module) -> None: # function carries it. self.spans: dict[SourceSpan, ScopeId] = {module.span: self.module.id} self.postponed = _postpones_annotations(module) - # The scope an annotation that is never evaluated stands in, while it is visited. + # The module or class an annotation that is never evaluated stands in, while it is + # visited. self.unevaluated: _ScopeBuilder | None = None self.body(module.body, self.module) @@ -408,11 +409,15 @@ def expression(self, node: nodes.Expression, scope: _ScopeBuilder) -> None: raise TypeError(f"unknown expression: {node!r}") def annotation(self, node: nodes.Expression, scope: _ScopeBuilder, evaluated: bool) -> None: - """Visit an annotation standing in ``scope``. One that is never evaluated still - introduces the scopes of its lambdas and comprehensions, which later passes look - up, but binds nothing in ``scope``: its assignment expressions never run.""" - - if evaluated: + """Visit an annotation standing in ``scope``. Python's compiler declares the + targets of its assignment expressions in ``scope`` whether it is evaluated or not, + so in a function they are locals all the same, which a nested function may declare + ``nonlocal``. A module's or a class's names exist only once assigned: there, one + that is never evaluated binds nothing, and a builtin it names stays the builtin. + Its lambdas and comprehensions introduce their scopes either way, which later + passes look up.""" + + if evaluated or scope.kind is ScopeKind.FUNCTION: self.expression(node, scope) return self.unevaluated = scope diff --git a/tests/test_annotation_bindings.py b/tests/test_annotation_bindings.py index b1bd021..e53720c 100644 --- a/tests/test_annotation_bindings.py +++ b/tests/test_annotation_bindings.py @@ -1,9 +1,10 @@ """What an annotation binds in the scope it stands in. -An annotation evaluated when its definition runs binds the targets of its assignment -expressions there, as any expression does. One that is never evaluated, under -``from __future__ import annotations`` (PEP 563) or on a local variable (PEP 526), binds -nothing there: a builtin it names, such as ``eval``, stays the builtin. Either way, its +Python's compiler declares the target of an assignment expression in an annotation +where the annotation stands, whether it is evaluated or not: in a function, the name is +a local of the function. A module's names exist only once assigned, so an annotation +that is never evaluated there, under ``from __future__ import annotations`` (PEP 563), +binds nothing: a builtin it names, such as ``eval``, stays the builtin. Either way, its lambdas and comprehensions introduce scopes of their own, which keep their bindings. """ @@ -83,10 +84,11 @@ def test_assignment_expressions_after_a_postponed_annotation_still_bind() -> Non assert "found" in scopes.module_scope.bindings -def test_a_postponed_annotation_in_a_function_binds_nothing_there() -> None: +def test_a_postponed_annotation_in_a_function_still_declares_there() -> None: scopes = analyze(POSTPONED + "def outer():\n def inner(x: [(found := i) for i in (1,)]):\n pass\n") - assert "found" not in child(scopes, scopes.module_scope, "outer").bindings + assert "found" in child(scopes, scopes.module_scope, "outer").bindings + assert "found" not in scopes.module_scope.bindings def test_a_builtin_named_in_a_postponed_annotation_stays_the_builtin_in_a_file() -> None: @@ -116,11 +118,12 @@ def test_a_module_variable_annotation_binds_where_it_stands() -> None: assert child(scopes, scopes.module_scope, "").parent == scopes.module_scope.id -def test_a_local_variable_annotation_binds_nothing_in_its_function() -> None: - scopes = analyze("def run():\n pending: [(found := i) for i in (1,)]\n") +@pytest.mark.parametrize("header", ["", POSTPONED]) +def test_a_local_variable_annotation_declares_in_its_function(header: str) -> None: + scopes = analyze(header + "def run():\n pending: [(found := i) for i in (1,)]\n") run = child(scopes, scopes.module_scope, "run") - assert "found" not in run.bindings + assert "found" in run.bindings assert child(scopes, run, "").parent == run.id From 0171ac31b8e6f2e812a79b74ac2842af88895fab Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 03:40:15 +0200 Subject: [PATCH 8/9] test(scopes): cover a nonlocal of a name a postponed annotation declares --- tests/test_annotation_bindings.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_annotation_bindings.py b/tests/test_annotation_bindings.py index e53720c..2731189 100644 --- a/tests/test_annotation_bindings.py +++ b/tests/test_annotation_bindings.py @@ -26,6 +26,15 @@ # The comprehension never runs, and its annotation is a string: ``eval`` stays the builtin. SHADOWING = POSTPONED + "def f(x: [(eval := i) for i in ()]):\n pass\n\n\ndef run():\n eval(input())\n" EVAL_FINDINGS = {("code-injection", 8), ("dangerous-eval", 8)} +# The annotation never runs, yet ``cmd`` is a local of ``run``: the ``nonlocal`` is valid +# and the input reaches the command. +NONLOCAL = ( + "from __future__ import annotations\nimport os\n\ndef run():\n" + " def declared(x: [(cmd := i) for i in ()]):\n pass\n" + " def set_cmd():\n nonlocal cmd\n cmd = input()\n" + " set_cmd()\n os.system(cmd)\n" +) +NONLOCAL_FINDINGS = {("command-injection", 11)} def analyze(text: str) -> ScopeTable: @@ -91,6 +100,18 @@ def test_a_postponed_annotation_in_a_function_still_declares_there() -> None: assert "found" not in scopes.module_scope.bindings +def test_a_nonlocal_of_a_name_a_postponed_annotation_declares_is_followed_in_a_file() -> None: + findings = engine.check(SourceManager().add_source("app.py", NONLOCAL), PLUGINS) + + assert located(findings) == NONLOCAL_FINDINGS + + +def test_a_nonlocal_of_a_name_a_postponed_annotation_declares_is_followed_in_a_project(tmp_path: Path) -> None: + (tmp_path / "app.py").write_text(NONLOCAL, encoding="utf-8") + + assert located(engine.analyze_project(tmp_path, PLUGINS).findings) == NONLOCAL_FINDINGS + + def test_a_builtin_named_in_a_postponed_annotation_stays_the_builtin_in_a_file() -> None: findings = engine.check(SourceManager().add_source("app.py", SHADOWING), PLUGINS) From 3f2eee9b406654cb722be49ee35c7445f98695c7 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 03:40:15 +0200 Subject: [PATCH 9/9] docs: state that annotations declare their names in functions --- docs/usage.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 44273e9..e3dd65c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -915,9 +915,10 @@ assignment expressions (`(y := f(x))`, laid out as an assignment before the stat `yield from`, starred assignment targets (`first, *rest = items`), class keyword arguments such as `metaclass=`, `with` targets of any assignable form and `match` with every pattern kind. An assignment expression in an annotation binds its name as Python 3.11 to -3.13 do, where the annotation stands, unless the module imports `annotations` from -`__future__` or the annotation is a local variable's: Python never evaluates those, so a -builtin they name stays the builtin. `finally` is modelled on the normal path only. A +3.13 do, where the annotation stands: in a function, the name is a local whether the +annotation is evaluated or not; at module level, it exists only once the annotation is +evaluated, so under `from __future__ import annotations` a builtin it names stays the +builtin. `finally` is modelled on the normal path only. A `nonlocal` write made by a nested function flows back to the enclosing variable when the enclosing function calls that nested function directly by name; a nested function passed around and called elsewhere keeps its writes to itself. Where the nested function may