From dcc9641456eae33147c7ddbfd479a0217a33d5da Mon Sep 17 00:00:00 2001 From: George Sittas Date: Wed, 27 May 2026 19:22:57 +0300 Subject: [PATCH] [mypyc] Preserve inherited attribute defaults under separate=True Under `separate=True`, when a subclass is recompiled while its parent is loaded from mypy's incremental cache, parent default-attribute assignments are silently dropped from the subclass's `__mypyc_defaults_setup`. The first read of an inherited default-attr then raises: AttributeError: attribute '' of '' undefined `find_attr_initializers` walks `cdef.info.mro` and reads `info.defn.defs.body` for `AssignmentStmt`s. `ClassDef.serialize` (mypy/nodes.py) does not serialize `defs`, so a cache-loaded parent has `defs = Block([])`; the MRO walk collects no parent assignments and the subclass's emitted setup leaves inherited slots in the undefined-sentinel state. Fix: under `separate=True`, scope `find_attr_initializers` to the subclass's own body and have `generate_attr_defaults_init` emit a chained call to the nearest ancestor with `__mypyc_defaults_setup` before setting own defaults. Each class's setup is responsible only for its own attributes; the chain runs ancestors first, mirroring the `__init__` chain. The ancestor's return value is propagated so a parent default that raised still aborts instance creation. Without `separate=True`, the MRO AST walk is unaffected (all modules parsed in the same pass), preserving the existing inline-all behavior. Adds `testIncrementalCrossModuleInheritedAttrDefaultsWithOverride` to `run-multimodule.test`, which triggers the cache-load path: clean build of a two-module Parent/Child hierarchy, then a `.2` revision of the subclass module to force an incremental rebuild while the parent is served from the cache. The child overrides one inherited default so it emits its own `__mypyc_defaults_setup` (the case the chain composition is required for). The test fails on master without this patch. --- mypyc/irbuild/classdef.py | 57 ++++++++++++++++++++++++++-- mypyc/test-data/run-multimodule.test | 53 ++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/mypyc/irbuild/classdef.py b/mypyc/irbuild/classdef.py index f5d094d142317..e9185e27bde75 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 6ae6c0f2cab9b..bdc361d2fc6ed 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()