Skip to content

Commit 7c5a1c4

Browse files
committed
fix(mypyc): preserve inherited class attribute defaults under separate=True
Under separate=True, find_attr_initializers walked the full MRO and pulled default-value AssignmentStmt nodes out of info.defn.defs.body. After mypy loads a base class from its incremental cache, that body is always empty (ClassDef.serialize deliberately doesn't serialize defs). When the subclass got recompiled but the parent stayed cache-loaded, the subclass's emitted __mypyc_defaults_setup silently dropped every inherited initialization. Any access to an inherited attribute through compiled code then raised "AttributeError: attribute X of <base> undefined". This fix changes the strategy under separate=True only: each class's __mypyc_defaults_setup now sets just that class's own defaults, then calls the nearest ancestor's __mypyc_defaults_setup to fold in inherited ones (same shape as Python's __init__ chain). The chain is robust to cache-loaded bases because it doesn't need their AST -- only that the ancestor has a method_decl entry for __mypyc_defaults_setup, which the cached ClassIR retains. Without separate=True the original inline-everything path is preserved as an optimization (the AST is always in memory, so the MRO walk is safe and the chain call would be needless runtime overhead). Added testIncrementalCrossModuleInheritedAttrDefaultsWithOverride to run-multimodule.test which exercises the fix end-to-end under both TestRunSeparate and TestRunMultiFile. It fails on master and passes with the fix.
1 parent e346010 commit 7c5a1c4

2 files changed

Lines changed: 101 additions & 4 deletions

File tree

mypyc/irbuild/classdef.py

Lines changed: 48 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,39 @@ 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 the parent's __mypyc_defaults_setup first, so inherited
850+
# defaults are set before we overwrite any of them with this class's
851+
# overrides below.
852+
if parent_with_defaults is not None:
853+
decl = parent_with_defaults.method_decl("__mypyc_defaults_setup")
854+
builder.builder.call(decl, [self_var], [ARG_POS], [None], cdef.line)
855+
812856
for stmt, origin_module in default_assignments:
813857
lvalue = stmt.lvalues[0]
814858
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
@@ -1680,3 +1680,56 @@ def _force_recompile() -> int:
16801680
[file driver.py]
16811681
from native import test
16821682
test()
1683+
1684+
[case testIncrementalCrossModuleInheritedAttrDefaultsWithOverride]
1685+
# Regression: same shape as testIncrementalCrossModuleInheritedAttrDefaults,
1686+
# but the subclass adds an attribute of its own, so generate_attr_defaults_init
1687+
# emits a __mypyc_defaults_setup for it. Before the fix, the recompiled
1688+
# subclass walked the parent's ClassDef.defs.body to collect inherited
1689+
# defaults; when the parent was loaded from mypy's incremental cache that
1690+
# body was empty, so the inherited initialization was dropped and any
1691+
# access to an inherited attribute through compiled code raised
1692+
# "AttributeError: attribute '<name>' of '<base>' undefined".
1693+
import other_a
1694+
1695+
def test() -> None:
1696+
c = other_a.Child()
1697+
# Inherited attributes must still be initialized after the subclass
1698+
# has been recompiled against a cache-loaded parent.
1699+
assert c.x == 1
1700+
assert c.y == "hello"
1701+
# Own override is set by the subclass's own __mypyc_defaults_setup.
1702+
assert c.z is True
1703+
# Method defined on the parent reads an inherited attribute through
1704+
# the compiled path; this is what crashes pre-fix.
1705+
assert c.use() == 1
1706+
1707+
[file other_b.py]
1708+
class Parent:
1709+
x: int = 1
1710+
y: str = "hello"
1711+
z: bool = False
1712+
1713+
def use(self) -> int:
1714+
if self.x:
1715+
return 1
1716+
return 0
1717+
1718+
[file other_a.py]
1719+
from other_b import Parent
1720+
1721+
class Child(Parent):
1722+
z: bool = True
1723+
1724+
[file other_a.py.2]
1725+
from other_b import Parent
1726+
1727+
class Child(Parent):
1728+
z: bool = True
1729+
1730+
def _force_recompile() -> int:
1731+
return 1
1732+
1733+
[file driver.py]
1734+
from native import test
1735+
test()

0 commit comments

Comments
 (0)