Skip to content

Commit 52de0c7

Browse files
authored
[mypyc] Preserve inherited attribute defaults under separate=True (#21547)
Fixes #21542 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. This PR implements the fix discussed in the linked issue.
1 parent bfa3b88 commit 52de0c7

7 files changed

Lines changed: 320 additions & 94 deletions

File tree

mypyc/codegen/emitclass.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
BITMAP_BITS,
3030
BITMAP_TYPE,
3131
CPYFUNCTION_NAME,
32+
MYPYC_DEFAULTS_SETUP,
3233
NATIVE_PREFIX,
3334
PREFIX,
3435
REG_PREFIX,
@@ -285,7 +286,7 @@ def emit_line() -> None:
285286

286287
# If the class has a method to initialize default attribute
287288
# values, we need to call it during initialization.
288-
defaults_fn = cl.get_method("__mypyc_defaults_setup")
289+
defaults_fn = cl.get_method(MYPYC_DEFAULTS_SETUP)
289290

290291
# If there is a __init__ method, we'll use it in the native constructor.
291292
init_fn = cl.get_method("__init__")

mypyc/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
LAMBDA_NAME: Final = "__mypyc_lambda__"
2525
PROPSET_PREFIX: Final = "__mypyc_setter__"
2626
SELF_NAME: Final = "__mypyc_self__"
27+
MYPYC_DEFAULTS_SETUP: Final = "__mypyc_defaults_setup"
2728
GENERATOR_ATTRIBUTE_PREFIX: Final = "__mypyc_generator_attribute__"
2829
CPYFUNCTION_NAME = "__cpyfunction__"
2930

mypyc/irbuild/classdef.py

Lines changed: 92 additions & 91 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,
@@ -21,15 +22,14 @@
2122
NameExpr,
2223
OverloadedFuncDef,
2324
PassStmt,
24-
RefExpr,
2525
StrExpr,
2626
TempNode,
2727
TypeInfo,
2828
TypeParam,
2929
is_class_var,
3030
)
3131
from mypy.types import Instance, UnboundType, get_proper_type
32-
from mypyc.common import PROPSET_PREFIX
32+
from mypyc.common import MYPYC_DEFAULTS_SETUP, PROPSET_PREFIX
3333
from mypyc.ir.class_ir import ClassIR, NonExtClassInfo
3434
from mypyc.ir.func_ir import FuncDecl, FuncSignature
3535
from mypyc.ir.ops import (
@@ -48,15 +48,7 @@
4848
TupleSet,
4949
Value,
5050
)
51-
from mypyc.ir.rtypes import (
52-
RType,
53-
bool_rprimitive,
54-
dict_rprimitive,
55-
is_none_rprimitive,
56-
is_object_rprimitive,
57-
is_optional_type,
58-
object_rprimitive,
59-
)
51+
from mypyc.ir.rtypes import RType, bool_rprimitive, dict_rprimitive, object_rprimitive
6052
from mypyc.irbuild.builder import IRBuilder, create_type_params
6153
from mypyc.irbuild.function import (
6254
gen_property_getter_ir,
@@ -66,7 +58,13 @@
6658
load_type,
6759
)
6860
from mypyc.irbuild.prepare import GENERATOR_HELPER_NAME
69-
from mypyc.irbuild.util import dataclass_type, get_func_def, is_constant, is_dataclass_decorator
61+
from mypyc.irbuild.util import (
62+
dataclass_type,
63+
default_attr_name,
64+
get_func_def,
65+
is_constant,
66+
is_dataclass_decorator,
67+
)
7068
from mypyc.primitives.dict_ops import dict_new_op, exact_dict_set_item_op
7169
from mypyc.primitives.generic_ops import (
7270
iter_op,
@@ -322,10 +320,6 @@ def __init__(self, builder: IRBuilder, cdef: ClassDef) -> None:
322320
def class_body_obj(self) -> Value | None:
323321
return self.type_obj
324322

325-
def skip_attr_default(self, name: str, stmt: AssignmentStmt) -> bool:
326-
"""Controls whether to skip generating a default for an attribute."""
327-
return False
328-
329323
def add_method(self, fdef: FuncDef) -> None:
330324
handle_ext_method(self.builder, self.cdef, fdef)
331325

@@ -348,11 +342,18 @@ def finalize(self, ir: ClassIR) -> None:
348342
# Call __init_subclass__ after class attributes have been set
349343
self.builder.call_c(py_init_subclass_op, [self.type_obj], self.cdef.line)
350344

351-
attrs_with_defaults, default_assignments = find_attr_initializers(
352-
self.builder, self.cdef, self.skip_attr_default
353-
)
354-
ir.attrs_with_defaults.update(attrs_with_defaults)
355-
generate_attr_defaults_init(self.builder, self.cdef, default_assignments)
345+
# Under separate compilation, prepare.py pre-registers the decl iff
346+
# the class has its own default attribute assignments to emit, so we
347+
# can skip the body walk entirely when it isn't present. Without
348+
# separate compilation, find_attr_initializers walks the MRO so that
349+
# inherited defaults are reflected in ir.attrs_with_defaults (relied
350+
# on by the attribute-definedness analysis), so we always run it.
351+
if not self.builder.options.separate or MYPYC_DEFAULTS_SETUP in ir.method_decls:
352+
attrs_with_defaults, default_assignments = find_attr_initializers(
353+
self.builder, self.cdef
354+
)
355+
ir.attrs_with_defaults.update(attrs_with_defaults)
356+
generate_attr_defaults_init(self.builder, self.cdef, default_assignments)
356357
create_ne_from_eq(self.builder, self.cdef)
357358

358359

@@ -380,9 +381,6 @@ def create_non_ext_info(self) -> NonExtClassInfo:
380381
self.builder.add(LoadAddress(type_object_op.type, type_object_op.src, self.cdef.line)),
381382
)
382383

383-
def skip_attr_default(self, name: str, stmt: AssignmentStmt) -> bool:
384-
return stmt.type is not None
385-
386384
def get_type_annotation(self, stmt: AssignmentStmt) -> TypeInfo | None:
387385
# We populate __annotations__ because dataclasses uses it to determine
388386
# which attributes to compute on.
@@ -445,9 +443,6 @@ class AttrsClassBuilder(DataClassBuilder):
445443

446444
add_annotations_to_dict = False
447445

448-
def skip_attr_default(self, name: str, stmt: AssignmentStmt) -> bool:
449-
return True
450-
451446
def get_type_annotation(self, stmt: AssignmentStmt) -> TypeInfo | None:
452447
if isinstance(stmt.rvalue, CallExpr):
453448
# find the type arg in `attr.ib(type=str)`
@@ -741,74 +736,100 @@ def add_non_ext_class_attr(
741736

742737

743738
def find_attr_initializers(
744-
builder: IRBuilder, cdef: ClassDef, skip: Callable[[str, AssignmentStmt], bool] | None = None
739+
builder: IRBuilder, cdef: ClassDef
745740
) -> tuple[set[str], list[tuple[AssignmentStmt, str]]]:
746741
"""Find initializers of attributes in a class body.
747742
748-
If provided, the skip arg should be a callable which will return whether
749-
to skip generating a default for an attribute. It will be passed the name of
750-
the attribute and the corresponding AssignmentStmt.
743+
Under separate compilation, only this class's own body is walked, and
744+
generate_attr_defaults_init emits a runtime call to the parent's
745+
__mypyc_defaults_setup so inherited defaults are produced by chaining,
746+
not by inlining. Walking the MRO here would break under separate=True
747+
with mypy's incremental cache: a base class loaded from the cache has
748+
an empty ClassDef.defs.body (mypy/nodes.py::ClassDef.serialize doesn't
749+
serialize the class body), so inherited assignments would be silently
750+
dropped and the subclass's __mypyc_defaults_setup would leave inherited
751+
slots in the "undefined" state at runtime.
752+
753+
Without separate compilation, all modules are parsed in the same pass
754+
and the MRO walk is safe; we keep the original inline-all behavior
755+
there as an optimization (no chain call needed for instance creation).
751756
"""
752757
cls = builder.mapper.type_to_ir[cdef.info]
753758
if cls.builtin_base:
754759
return set(), []
755760

756-
attrs_with_defaults = set()
761+
cls_type = dataclass_type(cdef)
762+
attrs_with_defaults: set[str] = set()
763+
default_assignments: list[tuple[AssignmentStmt, str]] = []
757764

758-
# Pull out all assignments in classes in the mro so we can initialize them
759765
# TODO: Support nested statements
760-
default_assignments: list[tuple[AssignmentStmt, str]] = []
761-
for info in reversed(cdef.info.mro):
762-
if info not in builder.mapper.type_to_ir:
766+
if builder.options.separate:
767+
infos: list[TypeInfo] = [cdef.info]
768+
else:
769+
infos = list(reversed(cdef.info.mro))
770+
771+
for info in infos:
772+
info_ir = builder.mapper.type_to_ir.get(info)
773+
if info_ir is None:
763774
continue
764775
for stmt in info.defn.defs.body:
765-
if (
766-
isinstance(stmt, AssignmentStmt)
767-
and isinstance(stmt.lvalues[0], NameExpr)
768-
and not is_class_var(stmt.lvalues[0])
769-
and not isinstance(stmt.rvalue, TempNode)
770-
):
771-
name = stmt.lvalues[0].name
772-
if name == "__slots__":
773-
continue
774-
775-
if name == "__deletable__":
776-
check_deletable_declaration(builder, cls, stmt.line)
777-
continue
778-
779-
if skip is not None and skip(name, stmt):
780-
continue
781-
782-
attr_type = cls.attr_type(name)
783-
784-
# If the attribute is initialized to None and type isn't optional,
785-
# doesn't initialize it to anything (special case for "# type:" comments).
786-
if isinstance(stmt.rvalue, RefExpr) and stmt.rvalue.fullname == "builtins.None":
787-
if (
788-
not is_optional_type(attr_type)
789-
and not is_object_rprimitive(attr_type)
790-
and not is_none_rprimitive(attr_type)
791-
):
792-
continue
793-
794-
attrs_with_defaults.add(name)
795-
default_assignments.append((stmt, info.module_name))
776+
if not isinstance(stmt, AssignmentStmt):
777+
continue
778+
name = default_attr_name(stmt, info_ir, cls_type)
779+
if name is None:
780+
continue
781+
attrs_with_defaults.add(name)
782+
default_assignments.append((stmt, info.module_name))
796783

797784
return attrs_with_defaults, default_assignments
798785

799786

800787
def generate_attr_defaults_init(
801788
builder: IRBuilder, cdef: ClassDef, default_assignments: list[tuple[AssignmentStmt, str]]
802789
) -> None:
803-
"""Generate an initialization method for default attr values (from class vars)."""
804-
if not default_assignments:
805-
return
790+
"""Generate an initialization method for default attr values (from class vars).
791+
792+
Under separate compilation, the emitted __mypyc_defaults_setup chains to
793+
the nearest ancestor that has the method (Python __init__ style), then
794+
sets only this class's own defaults; inherited defaults are produced by
795+
the chain at runtime. The ancestor lookup uses cls.mro[1:] and relies on
796+
prepare.py having registered the FuncDecl on every class that needs one
797+
before any IR build runs. IR build within a compilation group proceeds
798+
in filename order, so this class may be IR-built before its base, and a
799+
method_decls lookup that depended on the base having been IR-built first
800+
would miss. Without separate compilation, find_attr_initializers has
801+
already collected the full MRO's defaults into default_assignments, so
802+
we inline them all as before.
803+
"""
806804
cls = builder.mapper.type_to_ir[cdef.info]
807805
if cls.builtin_base:
808806
return
809807

810-
with builder.enter_method(cls, "__mypyc_defaults_setup", bool_rprimitive):
808+
parent_with_defaults: ClassIR | None = None
809+
if builder.options.separate:
810+
for ancestor in cls.mro[1:]:
811+
if MYPYC_DEFAULTS_SETUP in ancestor.method_decls:
812+
parent_with_defaults = ancestor
813+
break
814+
815+
if not default_assignments and parent_with_defaults is None:
816+
return
817+
818+
with builder.enter_method(cls, MYPYC_DEFAULTS_SETUP, bool_rprimitive):
811819
self_var = builder.self()
820+
821+
# Chain to parent's setup so inherited defaults run first; propagate
822+
# its False return so a parent default that raised still aborts
823+
# instance creation rather than being silently swallowed here.
824+
if parent_with_defaults is not None:
825+
decl = parent_with_defaults.method_decl(MYPYC_DEFAULTS_SETUP)
826+
parent_ok = builder.builder.call(decl, [self_var], [ARG_POS], [None], cdef.line)
827+
fail_block, continue_block = BasicBlock(), BasicBlock()
828+
builder.add(Branch(parent_ok, continue_block, fail_block, Branch.BOOL))
829+
builder.activate_block(fail_block)
830+
builder.add(Return(builder.false()))
831+
builder.activate_block(continue_block)
832+
812833
for stmt, origin_module in default_assignments:
813834
lvalue = stmt.lvalues[0]
814835
assert isinstance(lvalue, NameExpr), lvalue
@@ -833,26 +854,6 @@ def generate_attr_defaults_init(
833854
builder.add(Return(builder.true()))
834855

835856

836-
def check_deletable_declaration(builder: IRBuilder, cl: ClassIR, line: int) -> None:
837-
for attr in cl.deletable:
838-
if attr not in cl.attributes:
839-
if not cl.has_attr(attr):
840-
builder.error(f'Attribute "{attr}" not defined', line)
841-
continue
842-
for base in cl.mro:
843-
if attr in base.property_types:
844-
builder.error(f'Cannot make property "{attr}" deletable', line)
845-
break
846-
else:
847-
_, base = cl.attr_details(attr)
848-
builder.error(
849-
('Attribute "{}" not defined in "{}" ' + '(defined in "{}")').format(
850-
attr, cl.name, base.name
851-
),
852-
line,
853-
)
854-
855-
856857
def create_ne_from_eq(builder: IRBuilder, cdef: ClassDef) -> None:
857858
"""Create a "__ne__" method from a "__eq__" method (if only latter exists)."""
858859
cls = builder.mapper.type_to_ir[cdef.info]

0 commit comments

Comments
 (0)