diff --git a/mypyc/codegen/emitfunc.py b/mypyc/codegen/emitfunc.py index c854516825af..00c7fd56b899 100644 --- a/mypyc/codegen/emitfunc.py +++ b/mypyc/codegen/emitfunc.py @@ -358,6 +358,9 @@ def get_attr_expr(self, obj: str, op: GetAttr | SetAttr, decl_cl: ClassIR) -> st return f"({cast}{obj})->{self.emitter.attr(op.attr)}" def visit_get_attr(self, op: GetAttr) -> None: + if op.allow_null: + self.get_attr_with_allow_null(op) + return dest = self.reg(op) obj = self.reg(op.obj) rtype = op.class_type @@ -426,6 +429,24 @@ def visit_get_attr(self, op: GetAttr) -> None: elif not always_defined: self.emitter.emit_line("}") + def get_attr_with_allow_null(self, op: GetAttr) -> None: + """Handle GetAttr with allow_null=True which allows NULL without raising AttributeError.""" + dest = self.reg(op) + obj = self.reg(op.obj) + rtype = op.class_type + cl = rtype.class_ir + attr_rtype, decl_cl = cl.attr_details(op.attr) + + # Direct struct access without NULL check + attr_expr = self.get_attr_expr(obj, op, decl_cl) + self.emitter.emit_line(f"{dest} = {attr_expr};") + + # Only emit inc_ref if not NULL + if attr_rtype.is_refcounted and not op.is_borrowed: + self.emitter.emit_line(f"if ({dest} != NULL) {{") + self.emitter.emit_inc_ref(dest, attr_rtype) + self.emitter.emit_line("}") + def next_branch(self) -> Branch | None: if self.op_index + 1 < len(self.ops): next_op = self.ops[self.op_index + 1] diff --git a/mypyc/ir/ops.py b/mypyc/ir/ops.py index eec9c34a965e..9dde658231d8 100644 --- a/mypyc/ir/ops.py +++ b/mypyc/ir/ops.py @@ -777,15 +777,20 @@ class GetAttr(RegisterOp): error_kind = ERR_MAGIC - def __init__(self, obj: Value, attr: str, line: int, *, borrow: bool = False) -> None: + def __init__( + self, obj: Value, attr: str, line: int, *, borrow: bool = False, allow_null: bool = False + ) -> None: super().__init__(line) self.obj = obj self.attr = attr + self.allow_null = allow_null assert isinstance(obj.type, RInstance), "Attribute access not supported: %s" % obj.type self.class_type = obj.type attr_type = obj.type.attr_type(attr) self.type = attr_type - if attr_type.error_overlap: + if allow_null: + self.error_kind = ERR_NEVER + elif attr_type.error_overlap: self.error_kind = ERR_MAGIC_OVERLAPPING self.is_borrowed = borrow and attr_type.is_refcounted diff --git a/mypyc/irbuild/builder.py b/mypyc/irbuild/builder.py index 75e059a5b570..878c5e76df3d 100644 --- a/mypyc/irbuild/builder.py +++ b/mypyc/irbuild/builder.py @@ -708,6 +708,15 @@ 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 be NULL without raising AttributeError. + + This is used for reading spill targets in try/finally blocks where NULL + indicates the non-return path was taken. + """ + assert isinstance(obj.type, RInstance) and obj.type.class_ir.is_ext_class + return self.add(GetAttr(obj, attr, line, allow_null=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))) diff --git a/mypyc/irbuild/statement.py b/mypyc/irbuild/statement.py index 16a0483a8729..5c32d8f1a50c 100644 --- a/mypyc/irbuild/statement.py +++ b/mypyc/irbuild/statement.py @@ -46,6 +46,7 @@ YieldExpr, YieldFromExpr, ) +from mypyc.common import TEMP_ATTR_NAME from mypyc.ir.ops import ( NAMESPACE_MODULE, NO_TRACEBACK_LINE_NO, @@ -653,10 +654,15 @@ def try_finally_resolve_control( if ret_reg: builder.activate_block(rest) return_block, rest = BasicBlock(), BasicBlock() - builder.add(Branch(builder.read(ret_reg), rest, return_block, Branch.IS_ERROR)) + # 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, -1) + else: + ret_val = builder.read(ret_reg) + builder.add(Branch(ret_val, rest, return_block, Branch.IS_ERROR)) builder.activate_block(return_block) - builder.nonlocal_control[-1].gen_return(builder, builder.read(ret_reg), -1) + builder.nonlocal_control[-1].gen_return(builder, ret_val, -1) # TODO: handle break/continue builder.activate_block(rest) diff --git a/mypyc/test-data/run-async.test b/mypyc/test-data/run-async.test index 11ce67077270..2dad720f99cd 100644 --- a/mypyc/test-data/run-async.test +++ b/mypyc/test-data/run-async.test @@ -643,3 +643,306 @@ def test_async_def_contains_two_nested_functions() -> None: [file asyncio/__init__.pyi] def run(x: object) -> object: ... + +[case testAsyncTryFinallyMixedReturn] +# This used to raise an AttributeError, when: +# - the try block contains multiple paths +# - at least one of those explicitly returns +# - at least one of those does not explicitly return +# - the non-returning path is taken at runtime + +import asyncio + + +async def test_mixed_return(b: bool) -> bool: + try: + if b: + return b + finally: + pass + return b + + +async def test_run() -> None: + # Test return path + result1 = await test_mixed_return(True) + assert result1 == True + + # Test non-return path + result2 = await test_mixed_return(False) + assert result2 == False + + +def test_async_try_finally_mixed_return() -> None: + asyncio.run(test_run()) + +[file driver.py] +from native import test_async_try_finally_mixed_return +test_async_try_finally_mixed_return() + +[file asyncio/__init__.pyi] +def run(x: object) -> object: ... + +[case testAsyncWithMixedReturn] +# This used to raise an AttributeError, related to +# testAsyncTryFinallyMixedReturn, this is essentially +# a far more extensive version of that test surfacing +# more edge cases + +import asyncio +from typing import Optional, Type, Literal + + +class AsyncContextManager: + async def __aenter__(self) -> "AsyncContextManager": + return self + + async def __aexit__( + self, + t: Optional[Type[BaseException]], + v: Optional[BaseException], + tb: object, + ) -> Literal[False]: + return False + + +# Simple async functions (generator class) +async def test_gen_1(b: bool) -> bool: + async with AsyncContextManager(): + if b: + return b + return b + + +async def test_gen_2(b: bool) -> bool: + async with AsyncContextManager(): + if b: + return b + else: + return b + + +async def test_gen_3(b: bool) -> bool: + async with AsyncContextManager(): + if b: + return b + else: + pass + return b + + +async def test_gen_4(b: bool) -> bool: + ret: bool + async with AsyncContextManager(): + if b: + ret = b + else: + ret = b + return ret + + +async def test_gen_5(i: int) -> int: + async with AsyncContextManager(): + if i == 1: + return i + elif i == 2: + pass + elif i == 3: + return i + return i + + +async def test_gen_6(i: int) -> int: + async with AsyncContextManager(): + if i == 1: + return i + elif i == 2: + return i + elif i == 3: + return i + return i + + +async def test_gen_7(i: int) -> int: + async with AsyncContextManager(): + if i == 1: + return i + elif i == 2: + return i + elif i == 3: + return i + else: + return i + + +# Async functions with nested functions (environment class) +async def test_env_1(b: bool) -> bool: + def helper() -> bool: + return True + + async with AsyncContextManager(): + if b: + return helper() + return b + + +async def test_env_2(b: bool) -> bool: + def helper() -> bool: + return True + + async with AsyncContextManager(): + if b: + return helper() + else: + return b + + +async def test_env_3(b: bool) -> bool: + def helper() -> bool: + return True + + async with AsyncContextManager(): + if b: + return helper() + else: + pass + return b + + +async def test_env_4(b: bool) -> bool: + def helper() -> bool: + return True + + ret: bool + async with AsyncContextManager(): + if b: + ret = helper() + else: + ret = b + return ret + + +async def test_env_5(i: int) -> int: + def helper() -> int: + return 1 + + async with AsyncContextManager(): + if i == 1: + return helper() + elif i == 2: + pass + elif i == 3: + return i + return i + + +async def test_env_6(i: int) -> int: + def helper() -> int: + return 1 + + async with AsyncContextManager(): + if i == 1: + return helper() + elif i == 2: + return i + elif i == 3: + return i + return i + + +async def test_env_7(i: int) -> int: + def helper() -> int: + return 1 + + async with AsyncContextManager(): + if i == 1: + return helper() + elif i == 2: + return i + elif i == 3: + return i + else: + return i + + +async def run_all_tests() -> None: + # Test simple async functions (generator class) + # test_env_1: mixed return/no-return + assert await test_gen_1(True) is True + assert await test_gen_1(False) is False + + # test_gen_2: all branches return + assert await test_gen_2(True) is True + assert await test_gen_2(False) is False + + # test_gen_3: mixed return/pass + assert await test_gen_3(True) is True + assert await test_gen_3(False) is False + + # test_gen_4: no returns in async with + assert await test_gen_4(True) is True + assert await test_gen_4(False) is False + + # test_gen_5: multiple branches, some return + assert await test_gen_5(0) == 0 + assert await test_gen_5(1) == 1 + assert await test_gen_5(2) == 2 + assert await test_gen_5(3) == 3 + + # test_gen_6: all explicit branches return, implicit fallthrough + assert await test_gen_6(0) == 0 + assert await test_gen_6(1) == 1 + assert await test_gen_6(2) == 2 + assert await test_gen_6(3) == 3 + + # test_gen_7: all branches return including else + assert await test_gen_7(0) == 0 + assert await test_gen_7(1) == 1 + assert await test_gen_7(2) == 2 + assert await test_gen_7(3) == 3 + + # Test async functions with nested functions (environment class) + # test_env_1: mixed return/no-return + assert await test_env_1(True) is True + assert await test_env_1(False) is False + + # test_env_2: all branches return + assert await test_env_2(True) is True + assert await test_env_2(False) is False + + # test_env_3: mixed return/pass + assert await test_env_3(True) is True + assert await test_env_3(False) is False + + # test_env_4: no returns in async with + assert await test_env_4(True) is True + assert await test_env_4(False) is False + + # test_env_5: multiple branches, some return + assert await test_env_5(0) == 0 + assert await test_env_5(1) == 1 + assert await test_env_5(2) == 2 + assert await test_env_5(3) == 3 + + # test_env_6: all explicit branches return, implicit fallthrough + assert await test_env_6(0) == 0 + assert await test_env_6(1) == 1 + assert await test_env_6(2) == 2 + assert await test_env_6(3) == 3 + + # test_env_7: all branches return including else + assert await test_env_7(0) == 0 + assert await test_env_7(1) == 1 + assert await test_env_7(2) == 2 + assert await test_env_7(3) == 3 + + +def test_async_with_mixed_return() -> None: + asyncio.run(run_all_tests()) + +[file driver.py] +from native import test_async_with_mixed_return +test_async_with_mixed_return() + +[file asyncio/__init__.pyi] +def run(x: object) -> object: ...