Skip to content

Commit dcc9641

Browse files
committed
[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 '<name>' of '<Parent>' 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.
1 parent 938dbe2 commit dcc9641

2 files changed

Lines changed: 106 additions & 4 deletions

File tree

mypyc/irbuild/classdef.py

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Final
88

99
from mypy.nodes import (
10+
ARG_POS,
1011
EXCLUDED_ENUM_ATTRIBUTES,
1112
TYPE_VAR_TUPLE_KIND,
1213
AssignmentStmt,
@@ -745,6 +746,20 @@ def find_attr_initializers(
745746
) -> tuple[set[str], list[tuple[AssignmentStmt, str]]]:
746747
"""Find initializers of attributes in a class body.
747748
749+
Under separate compilation, only this class's own body is walked, and
750+
generate_attr_defaults_init emits a runtime call to the parent's
751+
__mypyc_defaults_setup so inherited defaults are produced by chaining,
752+
not by inlining. Walking the MRO here would break under separate=True
753+
with mypy's incremental cache: a base class loaded from the cache has
754+
an empty ClassDef.defs.body (mypy/nodes.py::ClassDef.serialize doesn't
755+
serialize the class body), so inherited assignments would be silently
756+
dropped and the subclass's __mypyc_defaults_setup would leave inherited
757+
slots in the "undefined" state at runtime.
758+
759+
Without separate compilation, all modules are parsed in the same pass
760+
and the MRO walk is safe; we keep the original inline-all behavior
761+
there as an optimization (no chain call needed for instance creation).
762+
748763
If provided, the skip arg should be a callable which will return whether
749764
to skip generating a default for an attribute. It will be passed the name of
750765
the attribute and the corresponding AssignmentStmt.
@@ -758,7 +773,12 @@ def find_attr_initializers(
758773
# Pull out all assignments in classes in the mro so we can initialize them
759774
# TODO: Support nested statements
760775
default_assignments: list[tuple[AssignmentStmt, str]] = []
761-
for info in reversed(cdef.info.mro):
776+
if builder.options.separate:
777+
infos: list[TypeInfo] = [cdef.info]
778+
else:
779+
infos = list(reversed(cdef.info.mro))
780+
781+
for info in infos:
762782
if info not in builder.mapper.type_to_ir:
763783
continue
764784
for stmt in info.defn.defs.body:
@@ -800,15 +820,44 @@ def find_attr_initializers(
800820
def generate_attr_defaults_init(
801821
builder: IRBuilder, cdef: ClassDef, default_assignments: list[tuple[AssignmentStmt, str]]
802822
) -> None:
803-
"""Generate an initialization method for default attr values (from class vars)."""
804-
if not default_assignments:
805-
return
823+
"""Generate an initialization method for default attr values (from class vars).
824+
825+
Under separate compilation, the emitted __mypyc_defaults_setup chains to
826+
the nearest ancestor that has the method (Python __init__ style), then
827+
sets only this class's own defaults; inherited defaults are produced by
828+
the chain at runtime. Without separate compilation, find_attr_initializers
829+
has already collected the full MRO's defaults into default_assignments,
830+
so we inline them all as before.
831+
"""
806832
cls = builder.mapper.type_to_ir[cdef.info]
807833
if cls.builtin_base:
808834
return
809835

836+
parent_with_defaults: ClassIR | None = None
837+
if builder.options.separate:
838+
for ancestor in cls.mro[1:]:
839+
if "__mypyc_defaults_setup" in ancestor.method_decls:
840+
parent_with_defaults = ancestor
841+
break
842+
843+
if not default_assignments and parent_with_defaults is None:
844+
return
845+
810846
with builder.enter_method(cls, "__mypyc_defaults_setup", bool_rprimitive):
811847
self_var = builder.self()
848+
849+
# Chain to parent's setup so inherited defaults run first; propagate
850+
# its False return so a parent default that raised still aborts
851+
# instance creation rather than being silently swallowed here.
852+
if parent_with_defaults is not None:
853+
decl = parent_with_defaults.method_decl("__mypyc_defaults_setup")
854+
parent_ok = builder.builder.call(decl, [self_var], [ARG_POS], [None], cdef.line)
855+
fail_block, continue_block = BasicBlock(), BasicBlock()
856+
builder.add(Branch(parent_ok, continue_block, fail_block, Branch.BOOL))
857+
builder.activate_block(fail_block)
858+
builder.add(Return(builder.false()))
859+
builder.activate_block(continue_block)
860+
812861
for stmt, origin_module in default_assignments:
813862
lvalue = stmt.lvalues[0]
814863
assert isinstance(lvalue, NameExpr), lvalue

mypyc/test-data/run-multimodule.test

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1778,3 +1778,56 @@ hello
17781778
[out2]
17791779
empty
17801780
hello
1781+
1782+
[case testIncrementalCrossModuleInheritedAttrDefaultsWithOverride]
1783+
# Regression: same shape as testIncrementalCrossModuleInheritedAttrDefaults,
1784+
# but the subclass adds an attribute of its own, so generate_attr_defaults_init
1785+
# emits a __mypyc_defaults_setup for it. Before the fix, the recompiled
1786+
# subclass walked the parent's ClassDef.defs.body to collect inherited
1787+
# defaults; when the parent was loaded from mypy's incremental cache that
1788+
# body was empty, so the inherited initialization was dropped and any
1789+
# access to an inherited attribute through compiled code raised
1790+
# "AttributeError: attribute '<name>' of '<base>' undefined".
1791+
import other_a
1792+
1793+
def test() -> None:
1794+
c = other_a.Child()
1795+
# Inherited attributes must still be initialized after the subclass
1796+
# has been recompiled against a cache-loaded parent.
1797+
assert c.x == 1
1798+
assert c.y == "hello"
1799+
# Own override is set by the subclass's own __mypyc_defaults_setup.
1800+
assert c.z is True
1801+
# Method defined on the parent reads an inherited attribute through
1802+
# the compiled path; this is what crashes pre-fix.
1803+
assert c.use() == 1
1804+
1805+
[file other_b.py]
1806+
class Parent:
1807+
x: int = 1
1808+
y: str = "hello"
1809+
z: bool = False
1810+
1811+
def use(self) -> int:
1812+
if self.x:
1813+
return 1
1814+
return 0
1815+
1816+
[file other_a.py]
1817+
from other_b import Parent
1818+
1819+
class Child(Parent):
1820+
z: bool = True
1821+
1822+
[file other_a.py.2]
1823+
from other_b import Parent
1824+
1825+
class Child(Parent):
1826+
z: bool = True
1827+
1828+
def _force_recompile() -> int:
1829+
return 1
1830+
1831+
[file driver.py]
1832+
from native import test
1833+
test()

0 commit comments

Comments
 (0)