Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 82 additions & 67 deletions mypyc/irbuild/classdef.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import Final

from mypy.nodes import (
ARG_POS,
EXCLUDED_ENUM_ATTRIBUTES,
TYPE_VAR_TUPLE_KIND,
AssignmentStmt,
Expand All @@ -21,7 +22,6 @@
NameExpr,
OverloadedFuncDef,
PassStmt,
RefExpr,
StrExpr,
TempNode,
TypeInfo,
Expand All @@ -48,15 +48,7 @@
TupleSet,
Value,
)
from mypyc.ir.rtypes import (
RType,
bool_rprimitive,
dict_rprimitive,
is_none_rprimitive,
is_object_rprimitive,
is_optional_type,
object_rprimitive,
)
from mypyc.ir.rtypes import RType, bool_rprimitive, dict_rprimitive, object_rprimitive
from mypyc.irbuild.builder import IRBuilder, create_type_params
from mypyc.irbuild.function import (
gen_property_getter_ir,
Expand All @@ -66,7 +58,13 @@
load_type,
)
from mypyc.irbuild.prepare import GENERATOR_HELPER_NAME
from mypyc.irbuild.util import dataclass_type, get_func_def, is_constant, is_dataclass_decorator
from mypyc.irbuild.util import (
dataclass_type,
default_attr_name,
get_func_def,
is_constant,
is_dataclass_decorator,
)
from mypyc.primitives.dict_ops import dict_new_op, exact_dict_set_item_op
from mypyc.primitives.generic_ops import (
iter_op,
Expand Down Expand Up @@ -322,10 +320,6 @@ def __init__(self, builder: IRBuilder, cdef: ClassDef) -> None:
def class_body_obj(self) -> Value | None:
return self.type_obj

def skip_attr_default(self, name: str, stmt: AssignmentStmt) -> bool:
"""Controls whether to skip generating a default for an attribute."""
return False

def add_method(self, fdef: FuncDef) -> None:
handle_ext_method(self.builder, self.cdef, fdef)

Expand All @@ -348,9 +342,7 @@ def finalize(self, ir: ClassIR) -> None:
# Call __init_subclass__ after class attributes have been set
self.builder.call_c(py_init_subclass_op, [self.type_obj], self.cdef.line)

attrs_with_defaults, default_assignments = find_attr_initializers(
self.builder, self.cdef, self.skip_attr_default
)
attrs_with_defaults, default_assignments = find_attr_initializers(self.builder, self.cdef)
ir.attrs_with_defaults.update(attrs_with_defaults)
generate_attr_defaults_init(self.builder, self.cdef, default_assignments)
Comment on lines +345 to 347
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as a slight optimization, could we only execute this block if the processed class has __mypyc_defaults_setup in its methods?

create_ne_from_eq(self.builder, self.cdef)
Expand Down Expand Up @@ -380,9 +372,6 @@ def create_non_ext_info(self) -> NonExtClassInfo:
self.builder.add(LoadAddress(type_object_op.type, type_object_op.src, self.cdef.line)),
)

def skip_attr_default(self, name: str, stmt: AssignmentStmt) -> bool:
return stmt.type is not None

def get_type_annotation(self, stmt: AssignmentStmt) -> TypeInfo | None:
# We populate __annotations__ because dataclasses uses it to determine
# which attributes to compute on.
Expand Down Expand Up @@ -445,9 +434,6 @@ class AttrsClassBuilder(DataClassBuilder):

add_annotations_to_dict = False

def skip_attr_default(self, name: str, stmt: AssignmentStmt) -> bool:
return True

def get_type_annotation(self, stmt: AssignmentStmt) -> TypeInfo | None:
if isinstance(stmt.rvalue, CallExpr):
# find the type arg in `attr.ib(type=str)`
Expand Down Expand Up @@ -741,74 +727,103 @@ def add_non_ext_class_attr(


def find_attr_initializers(
builder: IRBuilder, cdef: ClassDef, skip: Callable[[str, AssignmentStmt], bool] | None = None
builder: IRBuilder, cdef: ClassDef
) -> tuple[set[str], list[tuple[AssignmentStmt, str]]]:
"""Find initializers of attributes in a class body.

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.
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).
"""
cls = builder.mapper.type_to_ir[cdef.info]
if cls.builtin_base:
return set(), []

attrs_with_defaults = set()
cls_type = dataclass_type(cdef)
attrs_with_defaults: set[str] = set()
default_assignments: list[tuple[AssignmentStmt, str]] = []

# 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 info not in builder.mapper.type_to_ir:
if builder.options.separate:
infos: list[TypeInfo] = [cdef.info]
else:
infos = list(reversed(cdef.info.mro))

for info in infos:
info_ir = builder.mapper.type_to_ir.get(info)
if info_ir is None:
continue
for stmt in info.defn.defs.body:
if (
isinstance(stmt, AssignmentStmt)
and isinstance(stmt.lvalues[0], NameExpr)
and not is_class_var(stmt.lvalues[0])
and not isinstance(stmt.rvalue, TempNode)
):
name = stmt.lvalues[0].name
if name == "__slots__":
continue

if name == "__deletable__":
check_deletable_declaration(builder, cls, stmt.line)
continue

if skip is not None and skip(name, stmt):
continue

attr_type = cls.attr_type(name)

# If the attribute is initialized to None and type isn't optional,
# doesn't initialize it to anything (special case for "# type:" comments).
if isinstance(stmt.rvalue, RefExpr) and stmt.rvalue.fullname == "builtins.None":
if (
not is_optional_type(attr_type)
and not is_object_rprimitive(attr_type)
and not is_none_rprimitive(attr_type)
):
continue

attrs_with_defaults.add(name)
default_assignments.append((stmt, info.module_name))
if not isinstance(stmt, AssignmentStmt):
continue
if isinstance(stmt.lvalues[0], NameExpr) and stmt.lvalues[0].name == "__deletable__":
check_deletable_declaration(builder, cls, stmt.line)
continue
Comment on lines +769 to +771
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could this also be moved to prepare.py? check_deletable_declaration reports errors based on IR attributes that should be available in prepare.py already from what i can tell. it would be better to have the compiler exit early if the input program is not valid.

name = default_attr_name(stmt, info_ir, cls_type)
if name is None:
continue
attrs_with_defaults.add(name)
default_assignments.append((stmt, info.module_name))

return attrs_with_defaults, default_assignments


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. The ancestor lookup uses cls.mro[1:] and relies on
prepare.py having registered the FuncDecl on every class that needs one
before any IR build runs. IR build within a compilation group proceeds
in filename order, so this class may be IR-built before its base, and a
method_decls lookup that depended on the base having been IR-built first
would miss. 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
Comment on lines +805 to +807
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this makes the compilation results dependent on the order in which files are compiled, because __mypyc_defaults_setup will only be present in method_decls after it has been added when this function is called for the base class. so if the subclass is compiled before the base class then it won't find it. assuming the base and subclasses are defined in different files like in the test.

in the test this is not hit because the three different files are each in their own compilation groups, and the dependency analysis finds that they are dependent on each other so the compilation order is always the same: other_b.py (base class) -> other_a.py (subclass) -> native.py (instantiation).

but if we put other_b.py and other_a.py in the same compilation group with this comment:

[case testIncrementalCrossModuleInheritedAttrDefaultsWithOverride]
# separate: [(["native.py"], "grp1"), (["other_a.py", "other_b.py"], "grp2")]
...

then the compilation order changes because the dependency analysis treats all files in a given group as dependent on each other, and the compilation order within a group is determined by filenames. so other_a.py (subclass) will be compiled before other_b.py (base class) because its name comes first when sorting.

to make the compilation independent on the order, we have a preparation phase in prepare.py that runs before the IR build and sets up attributes that might be needed when compiling across different files.

so i think we should move the analysis of whether __mypyc_defaults_setup needs to be generated to prepare.py, and add the method declaration there if so. then here we only generate the body and we can safely make this check.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, this is very helpful, appreciate the detailed explanation and it makes sense. Let me give your suggestion a whirl. I'll make sure to include a regression test covering this path as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, I'll be OOO for a few days but I plan to take this to the finish line on Mon/Tue.

Copy link
Copy Markdown
Contributor Author

@georgesittas georgesittas Jun 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, @p-sawicki 👋

I confirmed the issue you described and have added a test for it. I've also implemented your suggestion re: moving the decision to prepare.py. The pattern mirrors how __mypyc_generator_helper__ is pre-registered.

I also moved the per-statement filter and the builders' skip_attr_default rules into two helpers in irbuild.util, to have a single source of truth for both the prepare-phase registration and the IR-build collection. That allowed dropping the skip_attr_default overrides on the builder classes.

Let me know what you think when you get a chance to review.


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
Expand Down
38 changes: 38 additions & 0 deletions mypyc/irbuild/prepare.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from mypy.nodes import (
ARG_STAR,
ARG_STAR2,
AssignmentStmt,
CallExpr,
ClassDef,
Decorator,
Expand Down Expand Up @@ -55,6 +56,7 @@
from mypyc.ir.rtypes import (
RInstance,
RType,
bool_rprimitive,
dict_rprimitive,
none_rprimitive,
object_pointer_rprimitive,
Expand All @@ -63,6 +65,8 @@
)
from mypyc.irbuild.mapper import Mapper
from mypyc.irbuild.util import (
dataclass_type,
default_attr_name,
get_func_def,
get_mypyc_attrs,
is_dataclass,
Expand Down Expand Up @@ -131,6 +135,16 @@ def build_type_map(
if class_ir.is_ext_class:
prepare_implicit_property_accessors(cdef.info, class_ir, module.fullname, mapper)

# Register __mypyc_defaults_setup FuncDecls on classes that have their own
# class-level default attribute assignments. Done here, before any IR build
# runs, so that the cross-class lookup in generate_attr_defaults_init is
# order-independent: IR build within a compilation group proceeds in
# filename order, so a subclass may be IR-built before its base.
for module, cdef in classes:
class_ir = mapper.type_to_ir[cdef.info]
if class_ir.is_ext_class and _has_own_default_attrs(cdef, class_ir):
_register_defaults_setup_decl(class_ir, module.fullname)

# Collect all the functions also. We collect from the symbol table
# so that we can easily pick out the right copy of a function that
# is conditionally defined. This doesn't include nested functions!
Expand Down Expand Up @@ -408,6 +422,30 @@ def validate_acyclic_class_bases(
)


def _has_own_default_attrs(cdef: ClassDef, ir: ClassIR) -> bool:
"""Whether this class's own body has any default attribute assignment
that would be emitted into __mypyc_defaults_setup.

Used during prepare to decide whether to register a
__mypyc_defaults_setup FuncDecl ahead of IR build.
"""
if ir.builtin_base or ir.is_trait:
return False
cls_type = dataclass_type(cdef)
return any(
default_attr_name(stmt, ir, cls_type) is not None
for stmt in cdef.info.defn.defs.body
if isinstance(stmt, AssignmentStmt)
)


def _register_defaults_setup_decl(ir: ClassIR, module_name: str) -> None:
sig = FuncSignature([RuntimeArg(SELF_NAME, RInstance(ir))], bool_rprimitive)
ir.method_decls["__mypyc_defaults_setup"] = FuncDecl(
"__mypyc_defaults_setup", ir.name, module_name, sig
Comment on lines +444 to +445
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since __mypyc_defaults_setup is now referenced in more files, could you add a constant for it in mypyc/common.py and use it in place of the string literals?

)


def prepare_class_def(
path: str,
module_name: str,
Expand Down
49 changes: 49 additions & 0 deletions mypyc/irbuild/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
ARG_POS,
GDEF,
ArgKind,
AssignmentStmt,
BytesExpr,
CallExpr,
ClassDef,
Expand All @@ -24,13 +25,17 @@
OverloadedFuncDef,
RefExpr,
StrExpr,
TempNode,
TupleExpr,
UnaryExpr,
Var,
is_class_var,
)
from mypy.semanal import refers_to_fullname
from mypy.types import FINAL_DECORATOR_NAMES
from mypyc.errors import Errors
from mypyc.ir.class_ir import ClassIR
from mypyc.ir.rtypes import is_none_rprimitive, is_object_rprimitive, is_optional_type

MYPYC_ATTRS: Final[frozenset[MypycAttr]] = frozenset(
["native_class", "allow_interpreted_subclasses", "serializable", "free_list_len", "acyclic"]
Expand Down Expand Up @@ -102,6 +107,50 @@ def dataclass_type(cdef: ClassDef) -> str | None:
return None


def defaults_skip(stmt: AssignmentStmt, cls_type: str | None) -> bool:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like this will stay as an internal method called only in default_attr_name. prefix the name with an underscore?

"""Whether a class-level default assignment is skipped when emitting
__mypyc_defaults_setup, based on class type.

- attr (auto_attribs=False): skip all (handled by attr.ib machinery).
- dataclasses / attr-auto: skip annotated assignments.
- regular extension class: skip nothing.
"""
if cls_type == "attr":
return True
if cls_type in ("dataclasses", "attr-auto"):
return stmt.type is not None
return False


def default_attr_name(stmt: AssignmentStmt, ir: ClassIR, cls_type: str | None) -> str | None:
"""Return the attribute name if `stmt` is a class-level default assignment
that __mypyc_defaults_setup should emit; otherwise None.

Single source of truth for the predicate used by both
mypyc.irbuild.classdef.find_attr_initializers (IR build) and
mypyc.irbuild.prepare._has_own_default_attrs (prepare-phase decl registration).
"""
lvalue = stmt.lvalues[0]
if not isinstance(lvalue, NameExpr) or is_class_var(lvalue):
return None
if isinstance(stmt.rvalue, TempNode):
return None
name = lvalue.name
if name in ("__slots__", "__deletable__") or name not in ir.attributes:
return None
if defaults_skip(stmt, cls_type):
return None
if isinstance(stmt.rvalue, RefExpr) and stmt.rvalue.fullname == "builtins.None":
attr_type = ir.attributes[name]
if (
not is_optional_type(attr_type)
and not is_object_rprimitive(attr_type)
and not is_none_rprimitive(attr_type)
):
return None
return name


def get_mypyc_attr_literal(e: Expression) -> Any:
"""Convert an expression from a mypyc_attr decorator to a value.

Expand Down
Loading
Loading