From 908ead317eaa6898e5e6b0e99f9c7010bde6d305 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Fri, 11 Sep 2026 15:36:20 +0100 Subject: [PATCH 01/17] [mypyc] Promote generator registers live across yields --- mypyc/analysis/dataflow.py | 46 +++++++ mypyc/irbuild/main.py | 8 ++ mypyc/test-data/run-generators.test | 19 +++ mypyc/test/test_generator_spills.py | 60 +++++++++ mypyc/transform/generator_spills.py | 182 ++++++++++++++++++++++++++++ 5 files changed, 315 insertions(+) create mode 100644 mypyc/test/test_generator_spills.py create mode 100644 mypyc/transform/generator_spills.py diff --git a/mypyc/analysis/dataflow.py b/mypyc/analysis/dataflow.py index 480a725a0aecc..3bd1d00363f81 100644 --- a/mypyc/analysis/dataflow.py +++ b/mypyc/analysis/dataflow.py @@ -7,6 +7,7 @@ from typing import Any, Generic, TypeVar from mypyc.ir.ops import ( + ERR_NEVER, Assign, AssignMulti, BasicBlock, @@ -518,6 +519,51 @@ def analyze_live_regs(blocks: list[BasicBlock], cfg: CFG) -> AnalysisResult[Valu ) +def analyze_live_regs_with_exception_edges( + blocks: list[BasicBlock], +) -> dict[BasicBlock, set[Value]]: + """Calculate block-entry liveness before exception handling is inserted. + + Unlike ``get_cfg()``, this models an error edge at each operation that can + raise. This matters when an assignment later in the same block overwrites a + value that the error handler can still read. Returns inserted for a yield + are treated as edges to their continuation blocks. + """ + visitor = LivenessVisitor() + live_in: dict[BasicBlock, set[Value]] = {block: set() for block in blocks} + + while True: + changed = False + for block in reversed(blocks): + terminator = block.terminator + if isinstance(terminator, Return) and terminator.yield_target is not None: + successors = (terminator.yield_target,) + else: + successors = terminator.targets() + + live: set[Value] = set() + for successor in successors: + live.update(live_in[successor]) + + for op in reversed(block.ops): + if ( + block.error_handler is not None + and isinstance(op, RegisterOp) + and op.error_kind != ERR_NEVER + ): + live.update(live_in[block.error_handler]) + gen, kill = op.accept(visitor) + live.difference_update(kill) + live.update(gen) + + if live != live_in[block]: + live_in[block] = live + changed = True + + if not changed: + return live_in + + # Analysis kinds MUST_ANALYSIS = 0 MAYBE_ANALYSIS = 1 diff --git a/mypyc/irbuild/main.py b/mypyc/irbuild/main.py index 6614f76e3a825..bc5354c342033 100644 --- a/mypyc/irbuild/main.py +++ b/mypyc/irbuild/main.py @@ -47,6 +47,7 @@ def f(x: int) -> int: from mypyc.irbuild.visitor import IRBuilderVisitor from mypyc.irbuild.vtable import compute_vtable from mypyc.options import CompilerOptions +from mypyc.transform.generator_spills import promote_generator_registers # The stubs for callable contextmanagers are busted so cast it to the # right type... @@ -123,6 +124,13 @@ def build_ir( result[module.fullname] = module_ir class_irs.extend(builder.classes) + # Generator helper calls are fresh C activations after every suspension. + # Move values that must outlive one activation to the private frame before + # attribute-definedness and the remaining IR transforms run. + for class_ir in class_irs: + if class_ir.env_user_function is not None: + promote_generator_registers(class_ir.env_user_function, class_ir) + analyze_always_defined_attrs(class_irs) # Compute vtables. diff --git a/mypyc/test-data/run-generators.test b/mypyc/test-data/run-generators.test index 8e09fb881310d..fa9df9ae8b003 100644 --- a/mypyc/test-data/run-generators.test +++ b/mypyc/test-data/run-generators.test @@ -1138,3 +1138,22 @@ def test_generator_method_override_still_works() -> None: box[0] = g with assertRaises(ValueError, "generator already executing"): next(g) + +[case testExceptionSurvivesYieldInFinally] +from typing import Iterator +from testutil import assertRaises + +class Marker(Exception): + pass + +def gen() -> Iterator[int]: + try: + raise Marker + finally: + yield 1 + +def test_exception_survives_yield_in_finally() -> None: + it = gen() + assert next(it) == 1 + with assertRaises(Marker): + next(it) diff --git a/mypyc/test/test_generator_spills.py b/mypyc/test/test_generator_spills.py new file mode 100644 index 0000000000000..636ee8b16bddb --- /dev/null +++ b/mypyc/test/test_generator_spills.py @@ -0,0 +1,60 @@ +"""Tests for promoting generator registers onto their frames.""" + +from __future__ import annotations + +import unittest + +from mypyc.common import TEMP_ATTR_NAME +from mypyc.ir.class_ir import ClassIR +from mypyc.ir.ops import GetAttr +from mypyc.test.testutil import build_ir_for_single_file2 +from mypyc.transform.generator_spills import registers_live_across_yield + + +def generator_class(source: str, name: str) -> ClassIR: + module, _, _, _ = build_ir_for_single_file2(source.splitlines()) + return next( + cl for cl in module.classes if cl.name == name and cl.env_user_function is not None + ) + + +class TestGeneratorSpills(unittest.TestCase): + def test_saved_exception_state_is_promoted(self) -> None: + cl = generator_class( + """\ +from typing import Iterator + +def gen() -> Iterator[int]: + try: + yield 1 + finally: + yield 2 +""", + "gen_gen", + ) + helper = cl.env_user_function + assert helper is not None + slots = {name for name in cl.attributes if name.startswith(TEMP_ATTR_NAME + "3_")} + assert len(slots) == 1 + reads = [ + op + for block in helper.blocks + for op in block.ops + if isinstance(op, GetAttr) and op.attr in slots + ] + assert reads and all(read.allow_error_value for read in reads) + assert not registers_live_across_yield(helper) + + def test_address_taken_register_is_not_promoted(self) -> None: + cl = generator_class( + """\ +from typing import Any, Generator + +def gen(values: Any) -> Generator[int, None, None]: + yield from values +""", + "gen_gen", + ) + helper = cl.env_user_function + assert helper is not None + assert not registers_live_across_yield(helper) diff --git a/mypyc/transform/generator_spills.py b/mypyc/transform/generator_spills.py new file mode 100644 index 0000000000000..5b47b5db7bf96 --- /dev/null +++ b/mypyc/transform/generator_spills.py @@ -0,0 +1,182 @@ +"""Move generator registers that survive suspension onto the generator frame.""" + +from __future__ import annotations + +from mypyc.analysis.dataflow import analyze_live_regs_with_exception_edges, cleanup_cfg +from mypyc.common import GENERATOR_ATTRIBUTE_PREFIX, TEMP_ATTR_NAME +from mypyc.ir.class_ir import ClassIR +from mypyc.ir.func_ir import FuncIR +from mypyc.ir.ops import ( + NO_TRACEBACK_LINE_NO, + Assign, + AssignMulti, + BaseAssign, + BasicBlock, + Branch, + GetAttr, + LoadAddress, + LoadErrorValue, + Op, + Register, + SetAttr, + Value, +) +from mypyc.namegen import exported_name + + +def promote_generator_registers(ir: FuncIR, frame: ClassIR) -> None: + """Replace registers live across a suspension with frame attributes. + + This runs before uninitialized-value, exception, and refcount transforms. + """ + cleanup_cfg(ir.blocks) + registers = registers_live_across_yield(ir) + if not registers: + return + + nullable = nullable_registers(ir.blocks) + slots = allocate_slots(frame, registers) + rewrite_registers(ir.blocks, ir.arg_regs[0], slots, nullable) + + # Keep this close to the rewrite: it catches a new op kind that is not + # handled above without restating the liveness analysis. + for block in ir.blocks: + for op in block.ops: + assert not any(source in slots for source in op.sources()) + assert not (isinstance(op, BaseAssign) and op.dest in slots) + + +def registers_live_across_yield(ir: FuncIR) -> list[Register]: + """Return promotable registers live at generator-helper entry.""" + live_in = analyze_live_regs_with_exception_edges(ir.blocks) + excluded = unpromotable_registers(ir.blocks) + registers = [ + value + for value in live_in[ir.blocks[0]] + if isinstance(value, Register) and not value.is_arg and value not in excluded + ] + return order_registers(registers, ir.blocks) + + +def unpromotable_registers(blocks: list[BasicBlock]) -> set[Register]: + """Find registers whose representation requires a real C local.""" + result: set[Register] = set() + for block in blocks: + for op in block.ops: + if isinstance(op, LoadAddress) and isinstance(op.src, Register): + result.add(op.src) + elif isinstance(op, AssignMulti): + result.add(op.dest) + elif ( + isinstance(op, Assign) + and isinstance(op.src, LoadErrorValue) + and op.src.undefines + and op.dest.type.error_overlap + ): + result.add(op.dest) + return result + + +def nullable_registers(blocks: list[BasicBlock]) -> set[Register]: + """Find registers for which the error value is an ordinary state.""" + result: set[Register] = set() + for block in blocks: + for op in block.ops: + if ( + isinstance(op, Branch) + and op.op == Branch.IS_ERROR + and isinstance(op.value, Register) + ): + result.add(op.value) + elif ( + isinstance(op, Assign) + and isinstance(op.src, LoadErrorValue) + and not op.src.undefines + ): + result.add(op.dest) + return result + + +def order_registers(registers: list[Register], blocks: list[BasicBlock]) -> list[Register]: + """Order registers by first appearance for deterministic frame layouts.""" + positions: dict[Register, int] = {} + for block in blocks: + for op in block.ops: + values = op.sources() + if isinstance(op, BaseAssign): + values = [op.dest, *values] + for value in values: + if isinstance(value, Register) and value not in positions: + positions[value] = len(positions) + return sorted(registers, key=positions.__getitem__) + + +def allocate_slots(frame: ClassIR, registers: list[Register]) -> dict[Register, str]: + slots: dict[Register, str] = {} + owner = exported_name(frame.fullname) + for index, register in enumerate(registers): + if register.name: + name = available_attr_name(frame, GENERATOR_ATTRIBUTE_PREFIX + register.name) + else: + name = f"{TEMP_ATTR_NAME}3_{owner}_{index}" + if register.type.error_overlap: + # Compiler temporaries are assigned before use. Recording a + # default avoids allocating an undefinedness bitmap for them. + frame.attrs_with_defaults.add(name) + frame.attributes[name] = register.type + slots[register] = name + return slots + + +def available_attr_name(frame: ClassIR, base: str) -> str: + if not frame.has_attr(base): + return base + suffix = 2 + while frame.has_attr(f"{base}__{suffix}"): + suffix += 1 + return f"{base}__{suffix}" + + +def rewrite_registers( + blocks: list[BasicBlock], + frame_reg: Register, + slots: dict[Register, str], + nullable: set[Register], +) -> None: + for block in blocks: + old_ops = block.ops + block.ops = [] + for op in old_ops: + replacements: dict[Register, GetAttr] = {} + new_sources: list[Value] = [] + for source in op.sources(): + if isinstance(source, Register) and source in slots: + read = replacements.get(source) + if read is None: + read = GetAttr( + frame_reg, + slots[source], + source_line(op, source), + allow_error_value=source in nullable, + ) + block.ops.append(read) + replacements[source] = read + new_sources.append(read) + else: + new_sources.append(source) + op.set_sources(new_sources) + + if isinstance(op, Assign) and op.dest in slots: + block.ops.append( + SetAttr(frame_reg, slots[op.dest], op.src, source_line(op, op.dest)) + ) + else: + block.ops.append(op) + + +def source_line(op: Op, register: Register) -> int: + if op.line >= 0 or op.line == NO_TRACEBACK_LINE_NO: + return op.line + if register.line >= 0: + return register.line + return NO_TRACEBACK_LINE_NO From 9fa7b4caae3f3a383d8bc4195b9e5c4428609071 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Fri, 11 Sep 2026 15:39:31 +0100 Subject: [PATCH 02/17] [mypyc] Keep non-crossing generator temporaries in registers --- mypyc/irbuild/builder.py | 21 ++----------- mypyc/irbuild/nonlocalcontrol.py | 10 ++++-- mypyc/test/test_generator_spills.py | 47 ++++++++++++++++++++++++++++- mypyc/test/test_spill.py | 11 ++++--- mypyc/transform/spill.py | 4 ++- 5 files changed, 65 insertions(+), 28 deletions(-) diff --git a/mypyc/irbuild/builder.py b/mypyc/irbuild/builder.py index ce70dc2eadad6..e36e1925b415d 100644 --- a/mypyc/irbuild/builder.py +++ b/mypyc/irbuild/builder.py @@ -1064,28 +1064,11 @@ def spill(self, value: Value) -> AssignmentTarget: return target def maybe_spill(self, value: Value) -> Value | AssignmentTarget: - """ - Moves a given Value instance into the private frame for generator functions. For - non-generator functions, leaves the Value instance as it is. - - Returns an AssignmentTarget associated with the Value for generator functions and the - original Value itself for non-generator functions. - """ - if self.fn_info.is_generator: - return self.spill(value) + """Return a value that the generator-spill transforms may move later.""" return value def maybe_spill_assignable(self, value: Value) -> Register | AssignmentTarget: - """ - Moves a given Value instance into the private frame for generator functions. For - non-generator functions, allocate a temporary Register. - - Returns an AssignmentTarget associated with the Value for generator functions and an - assignable Register for non-generator functions. - """ - if self.fn_info.is_generator: - return self.spill(value) - + """Return an assignable register for a value.""" if isinstance(value, Register): return value diff --git a/mypyc/irbuild/nonlocalcontrol.py b/mypyc/irbuild/nonlocalcontrol.py index 009a1d6a986bc..a800b5be205ae 100644 --- a/mypyc/irbuild/nonlocalcontrol.py +++ b/mypyc/irbuild/nonlocalcontrol.py @@ -12,6 +12,7 @@ from mypyc.ir.ops import ( ERR_NEVER, NO_TRACEBACK_LINE_NO, + Assign, BasicBlock, Branch, Call, @@ -93,6 +94,11 @@ class GeneratorNonlocalControl(BaseNonlocalControl): """Default nonlocal control in a generator function outside statements.""" def gen_return(self, builder: IRBuilder, value: Value, line: int) -> None: + # Frame cleanup may clear an attribute holding the return value. Copy it + # first so later spill transforms can place any required load here. + result = Register(value.type) + builder.add(Assign(result, value, line)) + gen_generator_func_cleanup(builder, line) # Raise a StopIteration containing a field for the value that @@ -120,7 +126,7 @@ def gen_return(self, builder: IRBuilder, value: Value, line: int) -> None: builder.activate_block(true) # The default/slow path is to raise a StopIteration exception with # return value. - builder.call_c(set_stop_iteration_value, [value], NO_TRACEBACK_LINE_NO) + builder.call_c(set_stop_iteration_value, [result], NO_TRACEBACK_LINE_NO) builder.add(Unreachable()) builder.builder.pop_error_handler() @@ -128,7 +134,7 @@ def gen_return(self, builder: IRBuilder, value: Value, line: int) -> None: # The fast path is to store return value via caller-provided pointer # instead of raising an exception. This can only be used when the # caller is a native function. - builder.add(SetMem(object_rprimitive, stop_iter_reg, value)) + builder.add(SetMem(object_rprimitive, stop_iter_reg, result)) builder.add(Return(Integer(0, object_rprimitive))) diff --git a/mypyc/test/test_generator_spills.py b/mypyc/test/test_generator_spills.py index 636ee8b16bddb..bc7b8557128d2 100644 --- a/mypyc/test/test_generator_spills.py +++ b/mypyc/test/test_generator_spills.py @@ -6,7 +6,7 @@ from mypyc.common import TEMP_ATTR_NAME from mypyc.ir.class_ir import ClassIR -from mypyc.ir.ops import GetAttr +from mypyc.ir.ops import Assign, GetAttr from mypyc.test.testutil import build_ir_for_single_file2 from mypyc.transform.generator_spills import registers_live_across_yield @@ -58,3 +58,48 @@ def gen(values: Any) -> Generator[int, None, None]: helper = cl.env_user_function assert helper is not None assert not registers_live_across_yield(helper) + + def test_loop_state_only_crossing_a_yield_uses_a_slot(self) -> None: + source = """\ +from typing import Iterator + +def before_yield(values: list[int]) -> Iterator[int]: + total = 0 + for value in values: + total += value + yield total + +def inside_loop(values: list[int]) -> Iterator[int]: + for value in values: + yield value +""" + before = generator_class(source, "before_yield_gen") + inside = generator_class(source, "inside_loop_gen") + before_slots = { + name for name in before.attributes if name.startswith(TEMP_ATTR_NAME + "3_") + } + inside_slots = { + name for name in inside.attributes if name.startswith(TEMP_ATTR_NAME + "3_") + } + assert not before_slots + assert len(inside_slots) == 1 + + def test_return_value_is_copied_before_frame_cleanup(self) -> None: + cl = generator_class( + """\ +from typing import Any + +async def collect(values: Any) -> Any: + return {value: value async for value in values} +""", + "collect_gen", + ) + helper = cl.env_user_function + assert helper is not None + result_copies = [ + op + for block in helper.blocks + for op in block.ops + if isinstance(op, Assign) and not op.dest.is_arg + ] + assert result_copies diff --git a/mypyc/test/test_spill.py b/mypyc/test/test_spill.py index 9e2831b196c90..9cfa15696bfd1 100644 --- a/mypyc/test/test_spill.py +++ b/mypyc/test/test_spill.py @@ -24,11 +24,12 @@ def make() -> str: def outer(): def nested(value: str): + result = "" for item in [value]: - # The loop iterator uses IR-builder-managed spill slots, while the - # result of make() is spilled later by the spill transform. - return make() + (yield item) - return "unreachable" + # The loop iterator is promoted from a register during IR building, + # while the result of make() is spilled by the later transform. + result += make() + (yield item) + return result return nested("right") """ module, _, _, _ = build_ir_for_single_file2(source.splitlines()) @@ -45,8 +46,8 @@ def nested(value: str): # is protected by the running flag and can use plain attribute access. assert frame.attrs_are_thread_confined() assert NEXT_LABEL_ATTR_NAME in frame.attributes - assert any(name.startswith(TEMP_ATTR_NAME + "1_") for name in frame.attributes) assert any(name.startswith(TEMP_ATTR_NAME + "2_") for name in frame.attributes) + assert any(name.startswith(TEMP_ATTR_NAME + "3_") for name in frame.attributes) # Source-level variables stay in the shared environment. assert GENERATOR_ATTRIBUTE_PREFIX + "value" in environment.attributes diff --git a/mypyc/transform/spill.py b/mypyc/transform/spill.py index ee07edf24328f..63555d850c06b 100644 --- a/mypyc/transform/spill.py +++ b/mypyc/transform/spill.py @@ -30,7 +30,9 @@ def insert_spills(ir: FuncIR, frame: ClassIR) -> None: entry_live = live.before[ir.blocks[0], 0] entry_live = {op for op in entry_live if not (isinstance(op, Register) and op.is_arg)} - # TODO: Actually for now, no Registers at all -- we keep the manual spills + # Registers that cross a suspension are moved to the frame earlier by + # promote_generator_registers(). Address-taken and RArray registers stay + # as C locals and are intentionally ignored here. entry_live = {op for op in entry_live if not isinstance(op, Register)} ir.blocks = spill_regs(ir.blocks, frame, entry_live, live, ir.arg_regs[0]) From 03d0efbc3d6c2c4c88a4c5a7bfa820161a1f2f19 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Fri, 11 Sep 2026 15:45:20 +0100 Subject: [PATCH 03/17] [mypyc] Keep non-crossing generator locals in registers --- mypyc/irbuild/builder.py | 23 ++++++++----- mypyc/irbuild/prebuildvisitor.py | 18 ++++++++++ mypyc/irbuild/statement.py | 2 +- mypyc/test-data/run-generators.test | 53 +++++++++++++++++++++++++++++ mypyc/test/test_generator_spills.py | 39 ++++++++++++++++++++- 5 files changed, 124 insertions(+), 11 deletions(-) diff --git a/mypyc/irbuild/builder.py b/mypyc/irbuild/builder.py index e36e1925b415d..115d6add49fd9 100644 --- a/mypyc/irbuild/builder.py +++ b/mypyc/irbuild/builder.py @@ -263,6 +263,7 @@ def __init__( self.fdefs_to_decorators = pbv.funcs_to_decorators self.module_import_groups = pbv.module_import_groups self.comprehension_to_fitem = pbv.comprehension_to_fitem + self.deleted_vars = pbv.deleted_vars self.singledispatch_impls = singledispatch_impls @@ -749,9 +750,12 @@ def get_assignment_target( if line == -1: line = lvalue.line if isinstance(lvalue, NameExpr): - # If we are visiting a decorator, then the SymbolNode we really want to be looking at - # is the function that is decorated, not the entire Decorator node itself. + # Use the concrete implementation as the symbol-table key for + # decorated and overloaded functions. symbol = lvalue.node + if isinstance(symbol, OverloadedFuncDef): + assert symbol.impl is not None + symbol = symbol.impl if isinstance(symbol, Decorator): symbol = symbol.func if symbol is None: @@ -766,12 +770,14 @@ def get_assignment_target( reg_type = self.type_to_rtype(symbol.type) else: reg_type = self.node_type(lvalue) - # If the function is a generator function, then first define a new variable - # in the current function's environment class. Next, define a target that - # refers to the newly defined variable in that environment class. Add the - # target to the table containing class environment variables, as well as the - # current environment. - if self.fn_info.is_generator or self.fn_info.is_coroutine: + # A deleted error-overlap value needs the environment's + # definedness bitmap. Other generator locals start in + # registers and are promoted later if they cross a yield. + if ( + (self.fn_info.is_generator or self.fn_info.is_coroutine) + and reg_type.error_overlap + and symbol in self.deleted_vars + ): return self.add_var_to_env_class( symbol, reg_type, @@ -780,7 +786,6 @@ def get_assignment_target( prefix=GENERATOR_ATTRIBUTE_PREFIX, ) - # Otherwise define a new local variable. return self.add_local_reg(symbol, reg_type) else: # Assign to a previously defined variable. diff --git a/mypyc/irbuild/prebuildvisitor.py b/mypyc/irbuild/prebuildvisitor.py index 232fcc55a0a77..8aa4c5b11cf43 100644 --- a/mypyc/irbuild/prebuildvisitor.py +++ b/mypyc/irbuild/prebuildvisitor.py @@ -4,6 +4,7 @@ AssignmentStmt, Block, Decorator, + DelStmt, DictionaryComprehension, Expression, FuncDef, @@ -11,12 +12,14 @@ GeneratorExpr, Import, LambdaExpr, + ListExpr, MemberExpr, MypyFile, NameExpr, Node, StrExpr, SymbolNode, + TupleExpr, Var, ) from mypy.traverser import ExtendedTraverserVisitor, TraverserVisitor @@ -94,6 +97,10 @@ def __init__( # All property setters encountered so far. self.prop_setters: set[FuncDef] = set() + # Local variables that occur in a del statement. This is needed before + # their first assignment is lowered (see IRBuilder.get_assignment_target). + self.deleted_vars: set[SymbolNode] = set() + # A map from any function that contains nested functions to # a set of all the functions that are nested within it. self.encapsulating_funcs: dict[FuncItem, list[FuncItem]] = {} @@ -147,6 +154,17 @@ def visit_block(self, block: Block) -> None: super().visit_block(block) self._current_import_group = None + def visit_del_stmt(self, stmt: DelStmt) -> None: + self.record_deleted_names(stmt.expr) + super().visit_del_stmt(stmt) + + def record_deleted_names(self, expr: Expression) -> None: + if isinstance(expr, NameExpr) and isinstance(expr.node, Var): + self.deleted_vars.add(expr.node) + elif isinstance(expr, (ListExpr, TupleExpr)): + for item in expr.items: + self.record_deleted_names(item) + def visit_decorator(self, dec: Decorator) -> None: if dec.decorators: # Only add the function being decorated if there exist diff --git a/mypyc/irbuild/statement.py b/mypyc/irbuild/statement.py index d978265339c45..ae99cb9899910 100644 --- a/mypyc/irbuild/statement.py +++ b/mypyc/irbuild/statement.py @@ -1285,7 +1285,7 @@ def transform_del_item(builder: IRBuilder, target: AssignmentTarget, line: int) # Delete a local by assigning an error value to it, which will # prompt the insertion of uninit checks. builder.add( - Assign(target.register, builder.add(LoadErrorValue(target.type, undefines=True))) + Assign(target.register, builder.add(LoadErrorValue(target.type, undefines=True)), line) ) elif isinstance(target, AssignmentTargetTuple): for subtarget in target.items: diff --git a/mypyc/test-data/run-generators.test b/mypyc/test-data/run-generators.test index fa9df9ae8b003..6f41588876f45 100644 --- a/mypyc/test-data/run-generators.test +++ b/mypyc/test-data/run-generators.test @@ -1157,3 +1157,56 @@ def test_exception_survives_yield_in_finally() -> None: assert next(it) == 1 with assertRaises(Marker): next(it) + +[case testCrossingLocalOnExceptionOnlyPath] +from typing import Iterator + +class InnerError(Exception): + pass + +class AssignmentError(Exception): + pass + +def fail_assignment() -> str: + raise AssignmentError + +def through_handler() -> Iterator[str]: + value = "old handler value" + try: + try: + yield "first" + raise InnerError + except InnerError: + value = fail_assignment() + except AssignmentError: + yield value + +def through_finally() -> Iterator[str]: + value = "old finally value" + try: + try: + yield "first" + raise InnerError + finally: + value = fail_assignment() + except AssignmentError: + yield value + +def test_crossing_local_on_exception_only_path() -> None: + for it, expected in [ + (through_handler(), "old handler value"), + (through_finally(), "old finally value"), + ]: + assert next(it) == "first" + assert next(it) == expected + +[case testDelLocalInGenerator] +from typing import Iterator + +def gen() -> Iterator[int]: + value = "temporary" + del value + yield 1 + +def test_del_local_in_generator() -> None: + assert list(gen()) == [1] diff --git a/mypyc/test/test_generator_spills.py b/mypyc/test/test_generator_spills.py index bc7b8557128d2..bd7bc375363a4 100644 --- a/mypyc/test/test_generator_spills.py +++ b/mypyc/test/test_generator_spills.py @@ -4,7 +4,7 @@ import unittest -from mypyc.common import TEMP_ATTR_NAME +from mypyc.common import GENERATOR_ATTRIBUTE_PREFIX, TEMP_ATTR_NAME from mypyc.ir.class_ir import ClassIR from mypyc.ir.ops import Assign, GetAttr from mypyc.test.testutil import build_ir_for_single_file2 @@ -103,3 +103,40 @@ async def collect(values: Any) -> Any: if isinstance(op, Assign) and not op.dest.is_arg ] assert result_copies + + def test_only_crossing_named_local_is_on_frame(self) -> None: + cl = generator_class( + """\ +from typing import Generator + +def gen() -> Generator[int, None, int]: + local = 1 + crossing = 2 + yield local + return crossing +""", + "gen_gen", + ) + variables = { + name.removeprefix(GENERATOR_ATTRIBUTE_PREFIX) + for name in cl.attributes + if name.startswith(GENERATOR_ATTRIBUTE_PREFIX) + } + assert "crossing" in variables + assert "local" not in variables + + def test_captured_local_stays_in_environment(self) -> None: + module, _, _, _ = build_ir_for_single_file2("""\ +from typing import Any, Iterator + +def gen() -> Iterator[Any]: + captured = "value" + def get() -> str: + return captured + yield get +""".splitlines()) + frame = next(cl for cl in module.classes if cl.name == "gen_gen") + environment = next( + cl for cl in module.classes if GENERATOR_ATTRIBUTE_PREFIX + "captured" in cl.attributes + ) + assert environment is not frame From af7fbb39b3ab7ba0a73ec7887b1ab594f94a683b Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Fri, 11 Sep 2026 15:53:24 +0100 Subject: [PATCH 04/17] [mypyc] Preserve source lines on generator yields --- mypyc/irbuild/statement.py | 2 +- mypyc/test-data/run-generators.test | 25 ++++++++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/mypyc/irbuild/statement.py b/mypyc/irbuild/statement.py index ae99cb9899910..694dec410add2 100644 --- a/mypyc/irbuild/statement.py +++ b/mypyc/irbuild/statement.py @@ -1308,7 +1308,7 @@ def emit_yield(builder: IRBuilder, val: Value, line: int) -> Value: next_label = len(cls.continuation_blocks) cls.continuation_blocks.append(next_block) builder.assign(cls.next_label_target, Integer(next_label), line) - builder.add(Return(retval, yield_target=next_block)) + builder.add(Return(retval, line, yield_target=next_block)) builder.activate_block(next_block) add_raise_exception_blocks_to_generator_class(builder, line) diff --git a/mypyc/test-data/run-generators.test b/mypyc/test-data/run-generators.test index 6f41588876f45..5611f2964d4c4 100644 --- a/mypyc/test-data/run-generators.test +++ b/mypyc/test-data/run-generators.test @@ -1203,10 +1203,25 @@ def test_crossing_local_on_exception_only_path() -> None: [case testDelLocalInGenerator] from typing import Iterator -def gen() -> Iterator[int]: - value = "temporary" - del value - yield 1 +from testutil import assertRaises + +def crossing(delete: bool) -> Iterator[str]: + value = "crossing" + yield value + if delete: + del value + yield value + +def non_crossing(delete: bool) -> Iterator[str]: + value = "local" + if delete: + del value + yield value def test_del_local_in_generator() -> None: - assert list(gen()) == [1] + assert list(crossing(False)) == ["crossing", "crossing"] + assert list(non_crossing(False)) == ["local"] + with assertRaises(AttributeError, "attribute 'value' of 'crossing_gen' undefined"): + list(crossing(True)) + with assertRaises(UnboundLocalError, 'local variable "value" referenced before assignment'): + list(non_crossing(True)) From b88bc21d4a4323bf10a135a79478badd48eaff38 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Fri, 11 Sep 2026 15:54:58 +0100 Subject: [PATCH 05/17] [mypyc] Test generator register edge cases --- mypyc/test-data/irbuild-i64.test | 12 +++++ mypyc/test-data/run-async.test | 32 ++++++++++++ mypyc/test-data/run-floats.test | 13 +++++ mypyc/test-data/run-i64.test | 25 +++++++++ mypyc/test/test_generator_spills.py | 79 +++++++++++++++++++++++------ 5 files changed, 146 insertions(+), 15 deletions(-) diff --git a/mypyc/test-data/irbuild-i64.test b/mypyc/test-data/irbuild-i64.test index 4cea7894052aa..40f88067a2623 100644 --- a/mypyc/test-data/irbuild-i64.test +++ b/mypyc/test-data/irbuild-i64.test @@ -2203,3 +2203,15 @@ L3: r10 = (1, r6) t = r10 return 1 + +[case testDeleteI64LocalInGenerator] +from typing import Iterator + +from mypy_extensions import i64 + +def gen() -> Iterator[i64]: + value: i64 = 1 + yield value + del value # E: "__mypyc_generator_attribute__value" cannot be deleted \ + # N: Using "__deletable__ = ['']" in the class body enables "del obj." + yield 0 diff --git a/mypyc/test-data/run-async.test b/mypyc/test-data/run-async.test index bdffc658486a1..804a7ca7e8c91 100644 --- a/mypyc/test-data/run-async.test +++ b/mypyc/test-data/run-async.test @@ -135,6 +135,38 @@ async def sleep(t: float) -> None: ... [typing fixtures/typing-full.pyi] +[case testRunAsyncLocalsAcrossAwait] +import asyncio +from typing import List + +async def increment(value: int) -> int: + await asyncio.sleep(0) + return value + 1 + +async def accumulate(count: int) -> int: + total = 0 + for value in range(count): + total += await increment(value) + return total + +async def return_through_finally(log: List[str]) -> int: + try: + return 7 + finally: + await asyncio.sleep(0) + log.append("finally") + +async def test_async_locals_across_await() -> None: + assert await accumulate(4) == 10 + log: List[str] = [] + assert await return_through_finally(log) == 7 + assert log == ["finally"] + +[file asyncio/__init__.pyi] +async def sleep(delay: float) -> None: ... + +[typing fixtures/typing-full.pyi] + [case testRunAsyncAwaitInVariousPositions] from typing import cast, Any diff --git a/mypyc/test-data/run-floats.test b/mypyc/test-data/run-floats.test index 424d52cdb0d55..08a5fbbb12c78 100644 --- a/mypyc/test-data/run-floats.test +++ b/mypyc/test-data/run-floats.test @@ -543,3 +543,16 @@ class subc(float): [file userdefinedfloat.py] class float: pass + +[case testFloatErrorValueAcrossYield] +from typing import Iterator + +from testutil import FLOAT_MAGIC + +def gen() -> Iterator[float]: + value = FLOAT_MAGIC + yield 0.0 + yield value + +def test_float_error_value_across_yield() -> None: + assert list(gen()) == [0.0, FLOAT_MAGIC] diff --git a/mypyc/test-data/run-i64.test b/mypyc/test-data/run-i64.test index 1a2d6fbbecfbe..59630e65cd67f 100644 --- a/mypyc/test-data/run-i64.test +++ b/mypyc/test-data/run-i64.test @@ -1556,3 +1556,28 @@ def test_dunder_arg_check() -> None: 2**63 in o with assertRaises(IndexError): 1 in o + +[case testI64DefinednessAcrossYield] +from typing import Final, Iterator + +from mypy_extensions import i64 +from testutil import assertRaises + +MAGIC: Final = -113 + +def error_value() -> Iterator[i64]: + value: i64 = MAGIC + yield 0 + yield value + +def conditional(define: bool) -> Iterator[i64]: + if define: + value: i64 = 5 + yield 0 + yield value + +def test_i64_definedness_across_yield() -> None: + assert list(error_value()) == [0, MAGIC] + assert list(conditional(True)) == [0, 5] + with assertRaises(AttributeError): + list(conditional(False)) diff --git a/mypyc/test/test_generator_spills.py b/mypyc/test/test_generator_spills.py index bd7bc375363a4..82e321f37d2b6 100644 --- a/mypyc/test/test_generator_spills.py +++ b/mypyc/test/test_generator_spills.py @@ -13,9 +13,26 @@ def generator_class(source: str, name: str) -> ClassIR: module, _, _, _ = build_ir_for_single_file2(source.splitlines()) - return next( - cl for cl in module.classes if cl.name == name and cl.env_user_function is not None - ) + matches = [ + cl + for cl in module.classes + if (cl.name == name or cl.name.startswith(name + "___")) + and cl.env_user_function is not None + ] + assert len(matches) == 1 + return matches[0] + + +def promoted_slots(cl: ClassIR) -> set[str]: + return {name for name in cl.attributes if name.startswith(TEMP_ATTR_NAME + "3_")} + + +def frame_variables(cl: ClassIR) -> set[str]: + return { + name.removeprefix(GENERATOR_ATTRIBUTE_PREFIX) + for name in cl.attributes + if name.startswith(GENERATOR_ATTRIBUTE_PREFIX) + } class TestGeneratorSpills(unittest.TestCase): @@ -34,7 +51,7 @@ def gen() -> Iterator[int]: ) helper = cl.env_user_function assert helper is not None - slots = {name for name in cl.attributes if name.startswith(TEMP_ATTR_NAME + "3_")} + slots = promoted_slots(cl) assert len(slots) == 1 reads = [ op @@ -75,12 +92,8 @@ def inside_loop(values: list[int]) -> Iterator[int]: """ before = generator_class(source, "before_yield_gen") inside = generator_class(source, "inside_loop_gen") - before_slots = { - name for name in before.attributes if name.startswith(TEMP_ATTR_NAME + "3_") - } - inside_slots = { - name for name in inside.attributes if name.startswith(TEMP_ATTR_NAME + "3_") - } + before_slots = promoted_slots(before) + inside_slots = promoted_slots(inside) assert not before_slots assert len(inside_slots) == 1 @@ -117,11 +130,7 @@ def gen() -> Generator[int, None, int]: """, "gen_gen", ) - variables = { - name.removeprefix(GENERATOR_ATTRIBUTE_PREFIX) - for name in cl.attributes - if name.startswith(GENERATOR_ATTRIBUTE_PREFIX) - } + variables = frame_variables(cl) assert "crossing" in variables assert "local" not in variables @@ -140,3 +149,43 @@ def get() -> str: cl for cl in module.classes if GENERATOR_ATTRIBUTE_PREFIX + "captured" in cl.attributes ) assert environment is not frame + + def test_nested_finally_promotes_each_saved_exception(self) -> None: + cl = generator_class( + """\ +from typing import Iterator + +def gen() -> Iterator[int]: + try: + try: + yield 1 + finally: + yield 2 + finally: + yield 3 +""", + "gen_gen", + ) + helper = cl.env_user_function + assert helper is not None + slots = promoted_slots(cl) + assert len(slots) == 2 + assert not registers_live_across_yield(helper) + + def test_recursive_generator_reference_is_rematerialized(self) -> None: + cl = generator_class( + """\ +from typing import Any, Iterator + +def outer(edges: list[list[int]]) -> Any: + def walk(node: int) -> Iterator[int]: + for child in edges[node]: + yield from walk(child) + yield node + return walk +""", + "walk_gen", + ) + # The recursive reference comes from the closure again on every resume, + # so it doesn't need a second copy on the generator frame. + assert "walk" not in frame_variables(cl) From f794f23eea19cb7c8561b8362dfcc26e731639f7 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Mon, 14 Sep 2026 13:35:16 +0100 Subject: [PATCH 06/17] [mypyc] Remove obsolete maybe_spill helper --- mypyc/irbuild/builder.py | 11 ------- mypyc/irbuild/expression.py | 2 +- mypyc/irbuild/for_helpers.py | 51 ++++++++++++++------------------ mypyc/irbuild/nonlocalcontrol.py | 2 +- mypyc/irbuild/statement.py | 8 ++--- 5 files changed, 28 insertions(+), 46 deletions(-) diff --git a/mypyc/irbuild/builder.py b/mypyc/irbuild/builder.py index 115d6add49fd9..d4e6b088d806a 100644 --- a/mypyc/irbuild/builder.py +++ b/mypyc/irbuild/builder.py @@ -1061,17 +1061,6 @@ def make_spill_target(self, type: RType) -> AssignmentTarget: target = self.add_var_to_class(Var(name), type, frame.ir, frame.self_reg) return target - def spill(self, value: Value) -> AssignmentTarget: - """Moves a given Value instance into the private generator frame.""" - target = self.make_spill_target(value.type) - # Shouldn't be able to fail - self.assign(target, value, NO_TRACEBACK_LINE_NO) - return target - - def maybe_spill(self, value: Value) -> Value | AssignmentTarget: - """Return a value that the generator-spill transforms may move later.""" - return value - def maybe_spill_assignable(self, value: Value) -> Register | AssignmentTarget: """Return an assignable register for a value.""" if isinstance(value, Register): diff --git a/mypyc/irbuild/expression.py b/mypyc/irbuild/expression.py index 6681fcd0825a5..e3b9ad0a2b1a9 100644 --- a/mypyc/irbuild/expression.py +++ b/mypyc/irbuild/expression.py @@ -1415,7 +1415,7 @@ def transform_dictionary_comprehension(builder: IRBuilder, o: DictionaryComprehe def _dict_comp_body(builder: IRBuilder, o: DictionaryComprehension) -> Value: - d = builder.maybe_spill(builder.call_c(dict_new_op, [], o.line)) + d = builder.call_c(dict_new_op, [], o.line) loop_params = list(zip(o.indices, o.sequences, o.condlists, o.is_async)) def gen_inner_stmts() -> None: diff --git a/mypyc/irbuild/for_helpers.py b/mypyc/irbuild/for_helpers.py index cecc69a07e274..ae40a118a6edc 100644 --- a/mypyc/irbuild/for_helpers.py +++ b/mypyc/irbuild/for_helpers.py @@ -322,7 +322,7 @@ def set_item(x: Value, y: Value, z: Value, line: int) -> None: if val is not None: return val - list_ops = builder.maybe_spill(builder.new_list_op([], gen.line)) + list_ops = builder.new_list_op([], gen.line) loop_params = list(zip(gen.indices, gen.sequences, gen.condlists, gen.is_async)) @@ -358,7 +358,7 @@ def translate_set_comprehension(builder: IRBuilder, gen: GeneratorExpr) -> Value if raise_error_if_contains_unreachable_names(builder, gen): return builder.none() - set_ops = builder.maybe_spill(builder.new_set_op([], gen.line)) + set_ops = builder.new_set_op([], gen.line) loop_params = list(zip(gen.indices, gen.sequences, gen.condlists, gen.is_async)) def gen_inner_stmts() -> None: @@ -688,7 +688,7 @@ def gen_step(self) -> None: def gen_cleanup(self) -> None: """Generate post-loop cleanup (if needed).""" - def load_len(self, expr: Value | AssignmentTarget) -> Value: + def load_len(self, expr: Value) -> Value: """A helper to get collection length, used by several subclasses.""" return self.builder.builder.builtin_len( self.builder.read(expr, self.line), self.line, use_pyssize_t=True @@ -703,13 +703,11 @@ def need_cleanup(self) -> bool: return True def init(self, expr_reg: Value, target_type: RType) -> None: - # Define targets to contain the expression, along with the iterator that will be used - # for the for-loop. If we are inside of a generator function, spill these into the - # private generator frame. + # Define a target containing the iterator used by the for-loop. The generator spill + # transform will promote it to the private generator frame if needed. builder = self.builder iter_reg = builder.primitive_op(iter_op, [expr_reg], self.line) - builder.maybe_spill(expr_reg) - self.iter_target = builder.maybe_spill(iter_reg) + self.iter_target = iter_reg self.target_type = target_type def gen_condition(self) -> None: @@ -752,10 +750,9 @@ def need_cleanup(self) -> bool: return True def init(self, expr_reg: Value, target_type: RType) -> None: - # Define target to contains the generator expression. It's also the iterator. - # If we are inside a generator function, spill these into the private generator frame. - builder = self.builder - self.iter_target = builder.maybe_spill(expr_reg) + # The generator expression is also the iterator. The generator spill transform will + # promote it to the private generator frame if needed. + self.iter_target = expr_reg self.target_type = target_type def gen_condition(self) -> None: @@ -808,14 +805,11 @@ class ForAsyncIterable(ForGenerator): """Generate IR for an async for loop.""" def init(self, expr_reg: Value, target_type: RType) -> None: - # Define targets to contain the expression, along with the - # iterator that will be used for the for-loop. We are inside - # of a generator function, so we will spill these into - # the private generator frame. + # Define a target containing the iterator used by the for-loop. The generator spill + # transform will promote it to the private generator frame if needed. builder = self.builder iter_reg = builder.call_c(aiter_op, [expr_reg], self.line) - builder.maybe_spill(expr_reg) - self.iter_target = builder.maybe_spill(iter_reg) + self.iter_target = iter_reg self.target_type = target_type self.stop_reg = Register(bool_rprimitive) @@ -895,7 +889,7 @@ class ForSequence(ForGenerator): Supports iterating in both forward and reverse. """ - length_reg: Value | AssignmentTarget | None + length_reg: Value | None def init( self, expr_reg: Value, target_type: RType, reverse: bool, length: Value | None = None @@ -908,13 +902,12 @@ def init( # Record a Value indicating the length of the sequence, if known at compile time. self.length = length self.reverse = reverse - # Define target to contain the expression, along with the index that will be used - # for the for-loop. If we are inside of a generator function, spill these into the - # private generator frame. - self.expr_target = builder.maybe_spill(expr_reg) + # The generator spill transform will promote loop state to the private generator frame + # if needed. + self.expr_target = expr_reg if is_immutable_rprimitive(expr_reg.type): # If the expression is an immutable type, we can load the length just once. - self.length_reg = builder.maybe_spill(self.length or self.load_len(self.expr_target)) + self.length_reg = self.length or self.load_len(self.expr_target) else: # Otherwise, even if the length is known, we must recalculate the length # at every iteration for compatibility with python semantics. @@ -1011,15 +1004,15 @@ def init(self, expr_reg: Value, target_type: RType) -> None: builder = self.builder self.target_type = target_type - # Spill some values so they can be read across yield. - self.expr_target = builder.maybe_spill(expr_reg) + # The generator spill transform will promote loop state that crosses a yield. + self.expr_target = expr_reg offset = Integer(0) self.offset_target = builder.maybe_spill_assignable(offset) - self.size = builder.maybe_spill(self.load_len(self.expr_target)) + self.size = self.load_len(self.expr_target) # For dict class (not a subclass) this is the dictionary itself. iter_reg = builder.call_c(self.dict_iter_op, [expr_reg], self.line) - self.iter_target = builder.maybe_spill(iter_reg) + self.iter_target = iter_reg def gen_condition(self) -> None: """Get next key/value pair, set new offset, and check if we should continue.""" @@ -1133,7 +1126,7 @@ def init(self, start_reg: Value, end_reg: Value, step: int) -> None: self.start_reg = start_reg self.end_reg = end_reg self.step = step - self.end_target = builder.maybe_spill(end_reg) + self.end_target = end_reg if is_short_int_rprimitive(start_reg.type) and is_short_int_rprimitive(end_reg.type): index_type: RType = short_int_rprimitive elif is_fixed_width_rtype(end_reg.type): diff --git a/mypyc/irbuild/nonlocalcontrol.py b/mypyc/irbuild/nonlocalcontrol.py index a800b5be205ae..3e02525d0d919 100644 --- a/mypyc/irbuild/nonlocalcontrol.py +++ b/mypyc/irbuild/nonlocalcontrol.py @@ -213,7 +213,7 @@ class ExceptNonlocalControl(CleanupNonlocalControl): This is super annoying. """ - def __init__(self, outer: NonlocalControl, saved: Value | AssignmentTarget) -> None: + def __init__(self, outer: NonlocalControl, saved: Value) -> None: super().__init__(outer) self.saved = saved diff --git a/mypyc/irbuild/statement.py b/mypyc/irbuild/statement.py index 694dec410add2..2db70922ff902 100644 --- a/mypyc/irbuild/statement.py +++ b/mypyc/irbuild/statement.py @@ -713,7 +713,7 @@ def transform_try_except( # exception is raised, based on the exception in exc_info. builder.builder.push_error_handler(double_except_block) builder.activate_block(except_entry) - old_exc = builder.maybe_spill(builder.call_c(error_catch_op, [], line)) + old_exc = builder.call_c(error_catch_op, [], line) # Compile the except blocks with the nonlocal control flow overridden to clear exc_info builder.nonlocal_control.append(ExceptNonlocalControl(builder.nonlocal_control[-1], old_exc)) @@ -1133,10 +1133,10 @@ def transform_with( exit_ = None else: typ = builder.primitive_op(type_op, [mgr_v], line) - exit_ = builder.maybe_spill(builder.py_get_attr(typ, f"__{al}exit__", line)) + exit_ = builder.py_get_attr(typ, f"__{al}exit__", line) value = builder.py_call(builder.py_get_attr(typ, f"__{al}enter__", line), [mgr_v], line) - mgr = builder.maybe_spill(mgr_v) + mgr = mgr_v exc = builder.maybe_spill_assignable(builder.true()) if is_async: value = emit_await(builder, value, line) @@ -1210,7 +1210,7 @@ def transform_with_lock( # __enter__: acquire the lock value = builder.primitive_op(lock_acquire_op, [mgr_v], line) - mgr = builder.maybe_spill(mgr_v) + mgr = mgr_v def try_body() -> None: if target: From b9d3027d23dbddf29972f60b7fefe3f03f7003db Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Mon, 14 Sep 2026 13:35:42 +0100 Subject: [PATCH 07/17] [mypyc] Rename assignable register helper --- mypyc/irbuild/builder.py | 4 ++-- mypyc/irbuild/for_helpers.py | 8 ++++---- mypyc/irbuild/statement.py | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mypyc/irbuild/builder.py b/mypyc/irbuild/builder.py index d4e6b088d806a..a785a64ee355c 100644 --- a/mypyc/irbuild/builder.py +++ b/mypyc/irbuild/builder.py @@ -1061,8 +1061,8 @@ def make_spill_target(self, type: RType) -> AssignmentTarget: target = self.add_var_to_class(Var(name), type, frame.ir, frame.self_reg) return target - def maybe_spill_assignable(self, value: Value) -> Register | AssignmentTarget: - """Return an assignable register for a value.""" + def ensure_register(self, value: Value) -> Register: + """Return an assignable register containing a value.""" if isinstance(value, Register): return value diff --git a/mypyc/irbuild/for_helpers.py b/mypyc/irbuild/for_helpers.py index ae40a118a6edc..a73741d234137 100644 --- a/mypyc/irbuild/for_helpers.py +++ b/mypyc/irbuild/for_helpers.py @@ -920,7 +920,7 @@ def init( else: len_val = self.load_len(self.expr_target) index_reg = builder.builder.int_sub(len_val, 1) - self.index_target = builder.maybe_spill_assignable(index_reg) + self.index_target = builder.ensure_register(index_reg) self.target_type = target_type def gen_condition(self) -> None: @@ -1007,7 +1007,7 @@ def init(self, expr_reg: Value, target_type: RType) -> None: # The generator spill transform will promote loop state that crosses a yield. self.expr_target = expr_reg offset = Integer(0) - self.offset_target = builder.maybe_spill_assignable(offset) + self.offset_target = builder.ensure_register(offset) self.size = self.load_len(self.expr_target) # For dict class (not a subclass) this is the dictionary itself. @@ -1135,7 +1135,7 @@ def init(self, start_reg: Value, end_reg: Value, step: int) -> None: index_type = int_rprimitive index_reg = Register(index_type, line=self.line) builder.assign(index_reg, start_reg, self.line) - self.index_reg = builder.maybe_spill_assignable(index_reg) + self.index_reg = builder.ensure_register(index_reg) # Initialize loop index to 0. Assert that the index target is assignable. self.index_target: Register | AssignmentTarget = builder.get_assignment_target(self.index) builder.assign(self.index_target, builder.read(self.index_reg, self.line), self.line) @@ -1189,7 +1189,7 @@ def init(self) -> None: # Create a register to store the state of the loop index and # initialize this register along with the loop index to 0. zero = Integer(0) - self.index_reg = builder.maybe_spill_assignable(zero) + self.index_reg = builder.ensure_register(zero) self.index_target: Register | AssignmentTarget = builder.get_assignment_target(self.index) def gen_step(self) -> None: diff --git a/mypyc/irbuild/statement.py b/mypyc/irbuild/statement.py index 2db70922ff902..1518338771172 100644 --- a/mypyc/irbuild/statement.py +++ b/mypyc/irbuild/statement.py @@ -1137,7 +1137,7 @@ def transform_with( value = builder.py_call(builder.py_get_attr(typ, f"__{al}enter__", line), [mgr_v], line) mgr = mgr_v - exc = builder.maybe_spill_assignable(builder.true()) + exc = builder.ensure_register(builder.true()) if is_async: value = emit_await(builder, value, line) @@ -1347,7 +1347,7 @@ def emit_yield_from_or_await( else: iter_val = builder.call_c(get_op, [val], line) - iter_reg = builder.maybe_spill_assignable(iter_val) + iter_reg = builder.ensure_register(iter_val) stop_block, main_block, done_block = BasicBlock(), BasicBlock(), BasicBlock() From 1aac723b280450639af1078ef532ef54ac1b207b Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Mon, 14 Sep 2026 13:38:32 +0100 Subject: [PATCH 08/17] [mypyc] Simplify generator spill value filtering --- mypyc/transform/spill.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/mypyc/transform/spill.py b/mypyc/transform/spill.py index 63555d850c06b..c31e264b0ea3e 100644 --- a/mypyc/transform/spill.py +++ b/mypyc/transform/spill.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Collection -from typing import cast from mypyc.analysis.dataflow import AnalysisResult, analyze_live_regs, get_cfg from mypyc.common import TEMP_ATTR_NAME @@ -27,15 +26,12 @@ def insert_spills(ir: FuncIR, frame: ClassIR) -> None: cfg = get_cfg(ir.blocks, use_yields=True) live = analyze_live_regs(ir.blocks, cfg) - entry_live = live.before[ir.blocks[0], 0] - - entry_live = {op for op in entry_live if not (isinstance(op, Register) and op.is_arg)} # Registers that cross a suspension are moved to the frame earlier by # promote_generator_registers(). Address-taken and RArray registers stay # as C locals and are intentionally ignored here. - entry_live = {op for op in entry_live if not isinstance(op, Register)} + to_spill = {value for value in live.before[ir.blocks[0], 0] if isinstance(value, Op)} - ir.blocks = spill_regs(ir.blocks, frame, entry_live, live, ir.arg_regs[0]) + ir.blocks = spill_regs(ir.blocks, frame, to_spill, live, ir.arg_regs[0]) def sort_values(values: Collection[Op], blocks: list[BasicBlock]) -> list[Op]: @@ -54,14 +50,13 @@ def sort_values(values: Collection[Op], blocks: list[BasicBlock]) -> list[Op]: def spill_regs( blocks: list[BasicBlock], frame: ClassIR, - to_spill: set[Value], + to_spill: set[Op], live: AnalysisResult[Value], frame_reg: Register, ) -> list[BasicBlock]: spill_locs = {} - # Sort values to make the order deterministic. All the spilled values are - # known to be Op instances, so the cast is safe. - for i, val in enumerate(sort_values(cast(set[Op], to_spill), blocks)): + # Sort values to make the order deterministic. + for i, val in enumerate(sort_values(to_spill, blocks)): # Generator classes for overriding methods can inherit from one another. Include the # module-qualified owning class name so unrelated helper spills don't alias an inherited # struct field. From 4b94ebd6cb1e6b9cbff9486df94e3574ae3fdf8d Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Mon, 14 Sep 2026 13:40:35 +0100 Subject: [PATCH 09/17] [mypyc] Reuse function definition normalization helper --- mypyc/irbuild/builder.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/mypyc/irbuild/builder.py b/mypyc/irbuild/builder.py index a785a64ee355c..05460fbc0be2f 100644 --- a/mypyc/irbuild/builder.py +++ b/mypyc/irbuild/builder.py @@ -152,7 +152,7 @@ AssignmentTargetRegister, AssignmentTargetTuple, ) -from mypyc.irbuild.util import bytes_from_str, is_constant +from mypyc.irbuild.util import bytes_from_str, get_func_def, is_constant from mypyc.irbuild.vec import vec_set_item from mypyc.namegen import exported_name from mypyc.options import CompilerOptions @@ -753,11 +753,8 @@ def get_assignment_target( # Use the concrete implementation as the symbol-table key for # decorated and overloaded functions. symbol = lvalue.node - if isinstance(symbol, OverloadedFuncDef): - assert symbol.impl is not None - symbol = symbol.impl - if isinstance(symbol, Decorator): - symbol = symbol.func + if isinstance(symbol, Decorator | OverloadedFuncDef): + symbol = get_func_def(symbol) if symbol is None: # Semantic analyzer doesn't create ad-hoc Vars for special forms. assert lvalue.is_special_form From 7d5dfd82b40146414dd3644a5bffe17629682673 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Mon, 14 Sep 2026 13:53:14 +0100 Subject: [PATCH 10/17] [mypyc] Let spill pass handle finally return values --- mypyc/irbuild/builder.py | 22 +--------------------- mypyc/irbuild/nonlocalcontrol.py | 11 ++--------- mypyc/irbuild/statement.py | 17 ++++++----------- 3 files changed, 9 insertions(+), 41 deletions(-) diff --git a/mypyc/irbuild/builder.py b/mypyc/irbuild/builder.py index 05460fbc0be2f..dc215a7fc3a51 100644 --- a/mypyc/irbuild/builder.py +++ b/mypyc/irbuild/builder.py @@ -75,7 +75,6 @@ KEEP_ALIVE_WHOLE_EXPRESSION, MODULE_PREFIX, SELF_NAME, - TEMP_ATTR_NAME, shared_lib_name, ) from mypyc.crash import catch_errors @@ -249,11 +248,8 @@ def __init__( self.callable_class_names: set[str] = set() self.options = options - # These variables keep track of the number of lambdas, implicit indices, and implicit - # iterators instantiated so we avoid name conflicts. The indices and iterators are - # instantiated from for-loops. + # Keep track of the number of lambdas instantiated so we avoid name conflicts. self.lambda_counter = 0 - self.temp_counter = 0 # These variables are populated from the first-pass PreBuildVisitor. self.free_variables = pbv.free_variables @@ -858,11 +854,6 @@ def read( assert False, "Unsupported lvalue: %r" % target - def read_nullable_attr(self, obj: Value, attr: str, line: int = -1) -> Value: - """Read an attribute that might have an error value without raising AttributeError.""" - assert isinstance(obj.type, RInstance) and obj.type.class_ir.is_ext_class - return self.add(GetAttr(obj, attr, line, allow_error_value=True)) - def assign(self, target: Register | AssignmentTarget, rvalue_reg: Value, line: int) -> None: if isinstance(target, Register): self.add(Assign(target, self.coerce_rvalue(rvalue_reg, target.type, line), line)) @@ -1047,17 +1038,6 @@ def push_loop_stack(self, continue_block: BasicBlock, break_block: BasicBlock) - def pop_loop_stack(self) -> None: self.nonlocal_control.pop() - def make_spill_target(self, type: RType) -> AssignmentTarget: - """Moves a given Value instance into the private generator frame.""" - frame = self.fn_info.generator_class - # Generator classes for overriding methods can inherit from one another. Include the - # module-qualified owning class name so unrelated helper spills don't alias an inherited - # struct field. - name = f"{TEMP_ATTR_NAME}1_{exported_name(frame.ir.fullname)}_{self.temp_counter}" - self.temp_counter += 1 - target = self.add_var_to_class(Var(name), type, frame.ir, frame.self_reg) - return target - def ensure_register(self, value: Value) -> Register: """Return an assignable register containing a value.""" if isinstance(value, Register): diff --git a/mypyc/irbuild/nonlocalcontrol.py b/mypyc/irbuild/nonlocalcontrol.py index 3e02525d0d919..60bddcd475035 100644 --- a/mypyc/irbuild/nonlocalcontrol.py +++ b/mypyc/irbuild/nonlocalcontrol.py @@ -25,7 +25,6 @@ Value, ) from mypyc.ir.rtypes import object_rprimitive -from mypyc.irbuild.targets import AssignmentTarget from mypyc.primitives.exc_ops import restore_exc_info_op, set_stop_iteration_value if TYPE_CHECKING: @@ -184,7 +183,7 @@ class TryFinallyNonlocalControl(NonlocalControl): def __init__(self, target: BasicBlock) -> None: self.target = target - self.ret_reg: None | Register | AssignmentTarget = None + self.ret_reg: Register | None = None def gen_break(self, builder: IRBuilder, line: int) -> None: builder.error("break inside try/finally block is unimplemented", line) @@ -194,13 +193,7 @@ def gen_continue(self, builder: IRBuilder, line: int) -> None: def gen_return(self, builder: IRBuilder, value: Value, line: int) -> None: if self.ret_reg is None: - if builder.fn_info.is_generator: - self.ret_reg = builder.make_spill_target(builder.ret_types[-1]) - else: - self.ret_reg = Register(builder.ret_types[-1]) - # assert needed because of apparent mypy bug... it loses track of the union - # and infers the type as object - assert isinstance(self.ret_reg, (Register, AssignmentTarget)), self.ret_reg + self.ret_reg = Register(builder.ret_types[-1]) builder.assign(self.ret_reg, value, line) builder.add(Goto(self.target)) diff --git a/mypyc/irbuild/statement.py b/mypyc/irbuild/statement.py index 1518338771172..e1b318e8bf514 100644 --- a/mypyc/irbuild/statement.py +++ b/mypyc/irbuild/statement.py @@ -49,7 +49,7 @@ YieldExpr, YieldFromExpr, ) -from mypyc.common import KEEP_ALIVE_SHORT_LIVED, KEEP_ALIVE_WHOLE_EXPRESSION, TEMP_ATTR_NAME +from mypyc.common import KEEP_ALIVE_SHORT_LIVED, KEEP_ALIVE_WHOLE_EXPRESSION from mypyc.ir.ops import ( ERR_NEVER, NAMESPACE_MODULE, @@ -793,7 +793,7 @@ def try_finally_try( return_entry: BasicBlock, main_entry: BasicBlock, try_body: GenFunc, -) -> Register | AssignmentTarget | None: +) -> Register | None: # Compile the try block with an error handler control = TryFinallyNonlocalControl(return_entry) builder.builder.push_error_handler(err_handler) @@ -814,7 +814,7 @@ def try_finally_entry_blocks( return_entry: BasicBlock, main_entry: BasicBlock, finally_block: BasicBlock, - ret_reg: Register | AssignmentTarget | None, + ret_reg: Register | None, ) -> Value: line = builder.fn_info.fitem.line old_exc = Register(exc_rtuple, line=line) @@ -859,7 +859,7 @@ def try_finally_resolve_control( cleanup_block: BasicBlock, finally_control: FinallyNonlocalControl, old_exc: Value, - ret_reg: Register | AssignmentTarget | None, + ret_reg: Register | None, ) -> BasicBlock: """Resolve the control flow out of a finally block. @@ -880,15 +880,10 @@ def try_finally_resolve_control( if ret_reg: builder.activate_block(rest) return_block, rest = BasicBlock(), BasicBlock() - # For spill targets in try/finally, use nullable read to avoid AttributeError - if isinstance(ret_reg, AssignmentTargetAttr) and ret_reg.attr.startswith(TEMP_ATTR_NAME): - ret_val = builder.read_nullable_attr(ret_reg.obj, ret_reg.attr, line) - else: - ret_val = builder.read(ret_reg, line) - builder.add(Branch(ret_val, rest, return_block, Branch.IS_ERROR, line)) + builder.add(Branch(ret_reg, rest, return_block, Branch.IS_ERROR, line)) builder.activate_block(return_block) - builder.nonlocal_control[-1].gen_return(builder, ret_val, line) + builder.nonlocal_control[-1].gen_return(builder, ret_reg, line) # TODO: handle break/continue builder.activate_block(rest) From 594d985140587a94d8e8c6231f64475bed04bc89 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Mon, 14 Sep 2026 13:55:27 +0100 Subject: [PATCH 11/17] [mypyc] Simplify generator local handling --- mypyc/irbuild/builder.py | 2 +- mypyc/irbuild/prebuildvisitor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mypyc/irbuild/builder.py b/mypyc/irbuild/builder.py index dc215a7fc3a51..f9964f936d989 100644 --- a/mypyc/irbuild/builder.py +++ b/mypyc/irbuild/builder.py @@ -767,7 +767,7 @@ def get_assignment_target( # definedness bitmap. Other generator locals start in # registers and are promoted later if they cross a yield. if ( - (self.fn_info.is_generator or self.fn_info.is_coroutine) + self.fn_info.is_generator and reg_type.error_overlap and symbol in self.deleted_vars ): diff --git a/mypyc/irbuild/prebuildvisitor.py b/mypyc/irbuild/prebuildvisitor.py index 8aa4c5b11cf43..2d9535f06a52c 100644 --- a/mypyc/irbuild/prebuildvisitor.py +++ b/mypyc/irbuild/prebuildvisitor.py @@ -99,7 +99,7 @@ def __init__( # Local variables that occur in a del statement. This is needed before # their first assignment is lowered (see IRBuilder.get_assignment_target). - self.deleted_vars: set[SymbolNode] = set() + self.deleted_vars: set[Var] = set() # A map from any function that contains nested functions to # a set of all the functions that are nested within it. From cd23eaf4f178fe50bb6d92c0de3b14f5f12b96f8 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Mon, 14 Sep 2026 14:42:45 +0100 Subject: [PATCH 12/17] Add TODO --- mypyc/analysis/dataflow.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mypyc/analysis/dataflow.py b/mypyc/analysis/dataflow.py index 3bd1d00363f81..babf7fbc6c87d 100644 --- a/mypyc/analysis/dataflow.py +++ b/mypyc/analysis/dataflow.py @@ -524,10 +524,14 @@ def analyze_live_regs_with_exception_edges( ) -> dict[BasicBlock, set[Value]]: """Calculate block-entry liveness before exception handling is inserted. - Unlike ``get_cfg()``, this models an error edge at each operation that can + Unlike get_cfg(), this models an error edge at each operation that can raise. This matters when an assignment later in the same block overwrites a value that the error handler can still read. Returns inserted for a yield are treated as edges to their continuation blocks. + + TODO: Unlike run_analysis(), this supports edges from the middle of basic + blocks, so we reimplement data flow analysis here. Figure out a way + to unify this with run_analysis(). """ visitor = LivenessVisitor() live_in: dict[BasicBlock, set[Value]] = {block: set() for block in blocks} From deac4a20da3a653a65a45b8b6fb9b38b7753f4b6 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Mon, 14 Sep 2026 14:58:48 +0100 Subject: [PATCH 13/17] Minor refactoring --- mypyc/irbuild/for_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypyc/irbuild/for_helpers.py b/mypyc/irbuild/for_helpers.py index a73741d234137..56a82c779d96e 100644 --- a/mypyc/irbuild/for_helpers.py +++ b/mypyc/irbuild/for_helpers.py @@ -1135,7 +1135,7 @@ def init(self, start_reg: Value, end_reg: Value, step: int) -> None: index_type = int_rprimitive index_reg = Register(index_type, line=self.line) builder.assign(index_reg, start_reg, self.line) - self.index_reg = builder.ensure_register(index_reg) + self.index_reg = index_reg # Initialize loop index to 0. Assert that the index target is assignable. self.index_target: Register | AssignmentTarget = builder.get_assignment_target(self.index) builder.assign(self.index_target, builder.read(self.index_reg, self.line), self.line) From b03a2a772e0f3024e5b7c3230db71c7e233b3b63 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Mon, 14 Sep 2026 15:07:04 +0100 Subject: [PATCH 14/17] Test tweaks --- mypyc/test-data/run-async.test | 12 ++++++++++++ mypyc/test-data/run-generators.test | 8 ++++++-- mypyc/test/test_generator_spills.py | 5 ++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/mypyc/test-data/run-async.test b/mypyc/test-data/run-async.test index 804a7ca7e8c91..ad257bf93729d 100644 --- a/mypyc/test-data/run-async.test +++ b/mypyc/test-data/run-async.test @@ -139,6 +139,8 @@ async def sleep(t: float) -> None: ... import asyncio from typing import List +from testutil import assertRaises + async def increment(value: int) -> int: await asyncio.sleep(0) return value + 1 @@ -156,11 +158,21 @@ async def return_through_finally(log: List[str]) -> int: await asyncio.sleep(0) log.append("finally") +async def deleted_across_await(delete: bool) -> str: + value = "live" + if delete: + del value + await asyncio.sleep(0) + return value + async def test_async_locals_across_await() -> None: assert await accumulate(4) == 10 log: List[str] = [] assert await return_through_finally(log) == 7 assert log == ["finally"] + assert await deleted_across_await(False) == "live" + with assertRaises(AttributeError): + await deleted_across_await(True) [file asyncio/__init__.pyi] async def sleep(delay: float) -> None: ... diff --git a/mypyc/test-data/run-generators.test b/mypyc/test-data/run-generators.test index 5611f2964d4c4..d55da8fbefa35 100644 --- a/mypyc/test-data/run-generators.test +++ b/mypyc/test-data/run-generators.test @@ -1210,6 +1210,7 @@ def crossing(delete: bool) -> Iterator[str]: yield value if delete: del value + yield "pause" yield value def non_crossing(delete: bool) -> Iterator[str]: @@ -1219,9 +1220,12 @@ def non_crossing(delete: bool) -> Iterator[str]: yield value def test_del_local_in_generator() -> None: - assert list(crossing(False)) == ["crossing", "crossing"] + assert list(crossing(False)) == ["crossing", "pause", "crossing"] assert list(non_crossing(False)) == ["local"] + it = crossing(True) + assert next(it) == "crossing" + assert next(it) == "pause" with assertRaises(AttributeError, "attribute 'value' of 'crossing_gen' undefined"): - list(crossing(True)) + next(it) with assertRaises(UnboundLocalError, 'local variable "value" referenced before assignment'): list(non_crossing(True)) diff --git a/mypyc/test/test_generator_spills.py b/mypyc/test/test_generator_spills.py index 82e321f37d2b6..440ebb068675e 100644 --- a/mypyc/test/test_generator_spills.py +++ b/mypyc/test/test_generator_spills.py @@ -1,4 +1,7 @@ -"""Tests for promoting generator registers onto their frames.""" +"""Tests for promoting generator registers onto their frames. + +These don't use the normal data-driven tests, since generators produce very verbose IR. +""" from __future__ import annotations From c758238276b462239448163a840ed554cdb9a3d8 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Mon, 14 Sep 2026 15:19:39 +0100 Subject: [PATCH 15/17] Comment updates --- mypyc/test-data/irbuild-i64.test | 1 + mypyc/transform/generator_spills.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/mypyc/test-data/irbuild-i64.test b/mypyc/test-data/irbuild-i64.test index 40f88067a2623..6441e806cdf33 100644 --- a/mypyc/test-data/irbuild-i64.test +++ b/mypyc/test-data/irbuild-i64.test @@ -2212,6 +2212,7 @@ from mypy_extensions import i64 def gen() -> Iterator[i64]: value: i64 = 1 yield value + # TODO: Improve the error message for generator locals. del value # E: "__mypyc_generator_attribute__value" cannot be deleted \ # N: Using "__deletable__ = ['']" in the class body enables "del obj." yield 0 diff --git a/mypyc/transform/generator_spills.py b/mypyc/transform/generator_spills.py index 5b47b5db7bf96..72c0ba22d1964 100644 --- a/mypyc/transform/generator_spills.py +++ b/mypyc/transform/generator_spills.py @@ -143,6 +143,9 @@ def rewrite_registers( slots: dict[Register, str], nullable: set[Register], ) -> None: + """Replace promoted register accesses with generator frame attribute accesses.""" + # IRTransform maps each value to one replacement, but mutable registers need a fresh + # attribute read at each use, so we can't use IRTransform here. for block in blocks: old_ops = block.ops block.ops = [] From ae2a11f2fa01ba31bd4e5df083ce06f2ade4fa96 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Mon, 14 Sep 2026 15:31:33 +0100 Subject: [PATCH 16/17] Comment updates --- mypyc/transform/spill.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/mypyc/transform/spill.py b/mypyc/transform/spill.py index c31e264b0ea3e..313e795262131 100644 --- a/mypyc/transform/spill.py +++ b/mypyc/transform/spill.py @@ -1,4 +1,7 @@ -"""Insert spills for values that are live across yields.""" +"""Spill operation results that are live across generator suspension points. + +Registers are spilled in an earlier pass. +""" from __future__ import annotations @@ -26,9 +29,11 @@ def insert_spills(ir: FuncIR, frame: ClassIR) -> None: cfg = get_cfg(ir.blocks, use_yields=True) live = analyze_live_regs(ir.blocks, cfg) - # Registers that cross a suspension are moved to the frame earlier by - # promote_generator_registers(). Address-taken and RArray registers stay - # as C locals and are intentionally ignored here. + # Registers needing frame storage were handled earlier by promote_generator_registers(). + # Argument, address-taken, and RArray registers stay as C locals. + + # Each generator helper invocation starts at the resume dispatch, so an operation result + # live at entry must have survived a previous suspension. to_spill = {value for value in live.before[ir.blocks[0], 0] if isinstance(value, Op)} ir.blocks = spill_regs(ir.blocks, frame, to_spill, live, ir.arg_regs[0]) From acf2b03b86c5b8fe1e68614e1f40ea8e76292978 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Mon, 14 Sep 2026 15:51:35 +0100 Subject: [PATCH 17/17] Lint --- mypyc/irbuild/statement.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/mypyc/irbuild/statement.py b/mypyc/irbuild/statement.py index 0cdb7371cd1cc..a3e571b633776 100644 --- a/mypyc/irbuild/statement.py +++ b/mypyc/irbuild/statement.py @@ -49,11 +49,7 @@ YieldExpr, YieldFromExpr, ) -from mypyc.common import ( - GENERATOR_HELPER_NAME, - KEEP_ALIVE_SHORT_LIVED, - KEEP_ALIVE_WHOLE_EXPRESSION, -) +from mypyc.common import GENERATOR_HELPER_NAME, KEEP_ALIVE_SHORT_LIVED, KEEP_ALIVE_WHOLE_EXPRESSION from mypyc.ir.ops import ( ERR_NEVER, NAMESPACE_MODULE,