-
-
Notifications
You must be signed in to change notification settings - Fork 3.2k
[mypyc] Preserve inherited attribute defaults under separate=True #21547
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
dcc9641
c6cae72
517beb1
762a755
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ | |
| from typing import Final | ||
|
|
||
| from mypy.nodes import ( | ||
| ARG_POS, | ||
| EXCLUDED_ENUM_ATTRIBUTES, | ||
| TYPE_VAR_TUPLE_KIND, | ||
| AssignmentStmt, | ||
|
|
@@ -21,7 +22,6 @@ | |
| NameExpr, | ||
| OverloadedFuncDef, | ||
| PassStmt, | ||
| RefExpr, | ||
| StrExpr, | ||
| TempNode, | ||
| TypeInfo, | ||
|
|
@@ -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, | ||
|
|
@@ -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, | ||
|
|
@@ -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) | ||
|
|
||
|
|
@@ -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) | ||
| create_ne_from_eq(self.builder, self.cdef) | ||
|
|
@@ -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. | ||
|
|
@@ -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)` | ||
|
|
@@ -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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could this also be moved to |
||
| 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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: but if we put 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 to make the compilation independent on the order, we have a preparation phase in so i think we should move the analysis of whether
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 I also moved the per-statement filter and the builders' 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,7 @@ | |
| from mypy.nodes import ( | ||
| ARG_STAR, | ||
| ARG_STAR2, | ||
| AssignmentStmt, | ||
| CallExpr, | ||
| ClassDef, | ||
| Decorator, | ||
|
|
@@ -55,6 +56,7 @@ | |
| from mypyc.ir.rtypes import ( | ||
| RInstance, | ||
| RType, | ||
| bool_rprimitive, | ||
| dict_rprimitive, | ||
| none_rprimitive, | ||
| object_pointer_rprimitive, | ||
|
|
@@ -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, | ||
|
|
@@ -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! | ||
|
|
@@ -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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. since |
||
| ) | ||
|
|
||
|
|
||
| def prepare_class_def( | ||
| path: str, | ||
| module_name: str, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,6 +12,7 @@ | |
| ARG_POS, | ||
| GDEF, | ||
| ArgKind, | ||
| AssignmentStmt, | ||
| BytesExpr, | ||
| CallExpr, | ||
| ClassDef, | ||
|
|
@@ -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"] | ||
|
|
@@ -102,6 +107,50 @@ def dataclass_type(cdef: ClassDef) -> str | None: | |
| return None | ||
|
|
||
|
|
||
| def defaults_skip(stmt: AssignmentStmt, cls_type: str | None) -> bool: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. seems like this will stay as an internal method called only in |
||
| """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. | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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_setupin its methods?