diff --git a/mypyc/irbuild/classdef.py b/mypyc/irbuild/classdef.py index f5d094d14231..e9185e27bde7 100644 --- a/mypyc/irbuild/classdef.py +++ b/mypyc/irbuild/classdef.py @@ -7,6 +7,7 @@ from typing import Final from mypy.nodes import ( + ARG_POS, EXCLUDED_ENUM_ATTRIBUTES, TYPE_VAR_TUPLE_KIND, AssignmentStmt, @@ -745,6 +746,20 @@ def find_attr_initializers( ) -> tuple[set[str], list[tuple[AssignmentStmt, str]]]: """Find initializers of attributes in a class body. + Under separate compilation, only this class's own body is walked, and + generate_attr_defaults_init emits a runtime call to the parent's + __mypyc_defaults_setup so inherited defaults are produced by chaining, + not by inlining. Walking the MRO here would break under separate=True + with mypy's incremental cache: a base class loaded from the cache has + an empty ClassDef.defs.body (mypy/nodes.py::ClassDef.serialize doesn't + serialize the class body), so inherited assignments would be silently + dropped and the subclass's __mypyc_defaults_setup would leave inherited + slots in the "undefined" state at runtime. + + Without separate compilation, all modules are parsed in the same pass + and the MRO walk is safe; we keep the original inline-all behavior + there as an optimization (no chain call needed for instance creation). + If provided, the skip arg should be a callable which will return whether to skip generating a default for an attribute. It will be passed the name of the attribute and the corresponding AssignmentStmt. @@ -758,7 +773,12 @@ def find_attr_initializers( # Pull out all assignments in classes in the mro so we can initialize them # TODO: Support nested statements default_assignments: list[tuple[AssignmentStmt, str]] = [] - for info in reversed(cdef.info.mro): + if builder.options.separate: + infos: list[TypeInfo] = [cdef.info] + else: + infos = list(reversed(cdef.info.mro)) + + for info in infos: if info not in builder.mapper.type_to_ir: continue for stmt in info.defn.defs.body: @@ -800,15 +820,44 @@ def find_attr_initializers( def generate_attr_defaults_init( builder: IRBuilder, cdef: ClassDef, default_assignments: list[tuple[AssignmentStmt, str]] ) -> None: - """Generate an initialization method for default attr values (from class vars).""" - if not default_assignments: - return + """Generate an initialization method for default attr values (from class vars). + + Under separate compilation, the emitted __mypyc_defaults_setup chains to + the nearest ancestor that has the method (Python __init__ style), then + sets only this class's own defaults; inherited defaults are produced by + the chain at runtime. Without separate compilation, find_attr_initializers + has already collected the full MRO's defaults into default_assignments, + so we inline them all as before. + """ cls = builder.mapper.type_to_ir[cdef.info] if cls.builtin_base: return + parent_with_defaults: ClassIR | None = None + if builder.options.separate: + for ancestor in cls.mro[1:]: + if "__mypyc_defaults_setup" in ancestor.method_decls: + parent_with_defaults = ancestor + break + + if not default_assignments and parent_with_defaults is None: + return + with builder.enter_method(cls, "__mypyc_defaults_setup", bool_rprimitive): self_var = builder.self() + + # Chain to parent's setup so inherited defaults run first; propagate + # its False return so a parent default that raised still aborts + # instance creation rather than being silently swallowed here. + if parent_with_defaults is not None: + decl = parent_with_defaults.method_decl("__mypyc_defaults_setup") + parent_ok = builder.builder.call(decl, [self_var], [ARG_POS], [None], cdef.line) + fail_block, continue_block = BasicBlock(), BasicBlock() + builder.add(Branch(parent_ok, continue_block, fail_block, Branch.BOOL)) + builder.activate_block(fail_block) + builder.add(Return(builder.false())) + builder.activate_block(continue_block) + for stmt, origin_module in default_assignments: lvalue = stmt.lvalues[0] assert isinstance(lvalue, NameExpr), lvalue diff --git a/mypyc/test-data/run-multimodule.test b/mypyc/test-data/run-multimodule.test index 6ae6c0f2cab9..bdc361d2fc6e 100644 --- a/mypyc/test-data/run-multimodule.test +++ b/mypyc/test-data/run-multimodule.test @@ -1778,3 +1778,56 @@ hello [out2] empty hello + +[case testIncrementalCrossModuleInheritedAttrDefaultsWithOverride] +# Regression: same shape as testIncrementalCrossModuleInheritedAttrDefaults, +# but the subclass adds an attribute of its own, so generate_attr_defaults_init +# emits a __mypyc_defaults_setup for it. Before the fix, the recompiled +# subclass walked the parent's ClassDef.defs.body to collect inherited +# defaults; when the parent was loaded from mypy's incremental cache that +# body was empty, so the inherited initialization was dropped and any +# access to an inherited attribute through compiled code raised +# "AttributeError: attribute '' of '' undefined". +import other_a + +def test() -> None: + c = other_a.Child() + # Inherited attributes must still be initialized after the subclass + # has been recompiled against a cache-loaded parent. + assert c.x == 1 + assert c.y == "hello" + # Own override is set by the subclass's own __mypyc_defaults_setup. + assert c.z is True + # Method defined on the parent reads an inherited attribute through + # the compiled path; this is what crashes pre-fix. + assert c.use() == 1 + +[file other_b.py] +class Parent: + x: int = 1 + y: str = "hello" + z: bool = False + + def use(self) -> int: + if self.x: + return 1 + return 0 + +[file other_a.py] +from other_b import Parent + +class Child(Parent): + z: bool = True + +[file other_a.py.2] +from other_b import Parent + +class Child(Parent): + z: bool = True + +def _force_recompile() -> int: + return 1 + +[file driver.py] +from native import test +test()