diff --git a/mypyc/irbuild/function.py b/mypyc/irbuild/function.py index 51bdc76495f2..c9f999597d30 100644 --- a/mypyc/irbuild/function.py +++ b/mypyc/irbuild/function.py @@ -83,7 +83,7 @@ dict_new_op, exact_dict_set_item_op, ) -from mypyc.primitives.generic_ops import generic_getattr, py_setattr_op +from mypyc.primitives.generic_ops import generic_getattr, generic_setattr, py_setattr_op from mypyc.primitives.misc_ops import register_function from mypyc.primitives.registry import builtin_names from mypyc.sametype import is_same_method_signature, is_same_type @@ -423,8 +423,10 @@ def generate_setattr_wrapper(builder: IRBuilder, cdef: ClassDef, setattr: FuncDe Returns 0 on success and -1 on failure. Restrictions are similar to the __getattr__ wrapper above. - This one is simpler because to match interpreted python semantics it's enough to always - call the user-provided function, including for names matching regular attributes. + The wrapper calls the user-defined __setattr__ when the value to set is not NULL. + When it's NULL, this means that the call to tp_setattro comes from a del statement, + so it calls __delattr__ instead. If __delattr__ is not overridden in the native class, + this will call the base implementation in object which doesn't work without __dict__. """ name = setattr.name + "__wrapper" ir = builder.mapper.type_to_ir[cdef.info] @@ -440,6 +442,27 @@ def generate_setattr_wrapper(builder: IRBuilder, cdef: ClassDef, setattr: FuncDe attr_arg = builder.add_argument("attr", object_rprimitive) value_arg = builder.add_argument("value", object_rprimitive) + call_delattr, call_setattr = BasicBlock(), BasicBlock() + null = Integer(0, object_rprimitive, line) + is_delattr = builder.add(ComparisonOp(value_arg, null, ComparisonOp.EQ, line)) + builder.add_bool_branch(is_delattr, call_delattr, call_setattr) + + builder.activate_block(call_delattr) + delattr_symbol = cdef.info.get("__delattr__") + delattr = delattr_symbol.node if delattr_symbol else None + delattr_override = delattr is not None and not delattr.fullname.startswith("builtins.") + if delattr_override: + builder.gen_method_call(builder.self(), "__delattr__", [attr_arg], None, line) + else: + # Call internal function that cpython normally calls when deleting an attribute. + # Cannot call object.__delattr__ here because it calls PyObject_SetAttr internally + # which in turn calls our wrapper and recurses infinitely. + # Note that since native classes don't have __dict__, this will raise AttributeError + # for dynamic attributes. + builder.call_c(generic_setattr, [builder.self(), attr_arg, null], line) + builder.add(Return(Integer(0, c_int_rprimitive), line)) + + builder.activate_block(call_setattr) builder.gen_method_call(builder.self(), setattr.name, [attr_arg, value_arg], None, line) builder.add(Return(Integer(0, c_int_rprimitive), line)) @@ -514,6 +537,14 @@ def handle_ext_method(builder: IRBuilder, cdef: ClassDef, fdef: FuncDef) -> None generate_getattr_wrapper(builder, cdef, fdef) elif fdef.name == "__setattr__": generate_setattr_wrapper(builder, cdef, fdef) + elif fdef.name == "__delattr__": + setattr = cdef.info.get("__setattr__") + if not setattr or not setattr.node or setattr.node.fullname.startswith("builtins."): + builder.error( + '"__delattr__" supported only in classes that also override "__setattr__", ' + + "or inherit from a native class that overrides it.", + fdef.line, + ) def handle_non_ext_method( diff --git a/mypyc/test-data/fixtures/ir.py b/mypyc/test-data/fixtures/ir.py index 22a6a5986cbd..4d0aaba12cab 100644 --- a/mypyc/test-data/fixtures/ir.py +++ b/mypyc/test-data/fixtures/ir.py @@ -46,6 +46,7 @@ def __eq__(self, x: object) -> bool: pass def __ne__(self, x: object) -> bool: pass def __str__(self) -> str: pass def __setattr__(self, k: str, v: object) -> None: pass + def __delattr__(self, k: str) -> None: pass class type: def __init__(self, o: object) -> None: ... diff --git a/mypyc/test-data/irbuild-classes.test b/mypyc/test-data/irbuild-classes.test index a98b3a7d3dcf..a2d3b23ccfd9 100644 --- a/mypyc/test-data/irbuild-classes.test +++ b/mypyc/test-data/irbuild-classes.test @@ -2222,18 +2222,41 @@ class AllowsInterpreted: def __setattr__(self, attr: str, val: object) -> None: # E: "__setattr__" not supported in class "AllowsInterpreted" because it allows interpreted subclasses pass + def __delattr__(self, attr: str) -> None: + pass + class InheritsInterpreted(dict): def __setattr__(self, attr: str, val: object) -> None: # E: "__setattr__" not supported in class "InheritsInterpreted" because it inherits from a non-native class pass + def __delattr__(self, attr: str) -> None: + pass + @mypyc_attr(native_class=False) class NonNative: - pass + def __setattr__(self, attr: str, val: object) -> None: + pass class InheritsNonNative(NonNative): def __setattr__(self, attr: str, val: object) -> None: # E: "__setattr__" not supported in class "InheritsNonNative" because it inherits from a non-native class pass + def __delattr__(self, attr: str) -> None: + pass + +[case testUnsupportedDelAttr] +class SetAttr: + def __setattr__(self, attr: str, val: object) -> None: + pass + +class NoSetAttr: + def __delattr__(self, attr: str) -> None: # E: "__delattr__" supported only in classes that also override "__setattr__", or inherit from a native class that overrides it. + pass + +class InheritedSetAttr(SetAttr): + def __delattr__(self, attr: str) -> None: + pass + [case testSetAttr] from typing import ClassVar class SetAttr: @@ -2329,11 +2352,21 @@ L6: def SetAttr.__setattr____wrapper(__mypyc_self__, attr, value): __mypyc_self__ :: __main__.SetAttr attr, value :: object - r0 :: str - r1 :: None + r0 :: bit + r1 :: i32 + r2 :: bit + r3 :: str + r4 :: None L0: - r0 = cast(str, attr) - r1 = __mypyc_self__.__setattr__(r0, value) + r0 = value == 0 + if r0 goto L1 else goto L2 :: bool +L1: + r1 = CPyObject_GenericSetAttr(__mypyc_self__, attr, 0) + r2 = r1 >= 0 :: signed + return 0 +L2: + r3 = cast(str, attr) + r4 = __mypyc_self__.__setattr__(r3, value) return 0 def test(attr, val): attr :: str @@ -2372,6 +2405,124 @@ L0: r14 = r13 >= 0 :: signed return 1 +[case testSetAttrAndDelAttr] +from typing import ClassVar +class SetAttr: + _attributes: dict[str, object] + regular_attr: int + class_var: ClassVar[str] = "x" + + def __init__(self, regular_attr: int, extra_attrs: dict[str, object], new_attr: str, new_val: object) -> None: + super().__setattr__("_attributes", extra_attrs) + object.__setattr__(self, "regular_attr", regular_attr) + + super().__setattr__(new_attr, new_val) + object.__setattr__(self, new_attr, new_val) + + def __setattr__(self, key: str, val: object) -> None: + if key == "regular_attr": + super().__setattr__("regular_attr", val) + elif key == "class_var": + raise AttributeError() + else: + self._attributes[key] = val + + def __delattr__(self, key: str) -> None: + del self._attributes[key] + +[typing fixtures/typing-full.pyi] +[out] +def SetAttr.__init__(self, regular_attr, extra_attrs, new_attr, new_val): + self :: __main__.SetAttr + regular_attr :: int + extra_attrs :: dict + new_attr :: str + new_val :: object + r0 :: i32 + r1 :: bit + r2 :: i32 + r3 :: bit +L0: + self._attributes = extra_attrs + self.regular_attr = regular_attr + r0 = CPyObject_GenericSetAttr(self, new_attr, new_val) + r1 = r0 >= 0 :: signed + r2 = CPyObject_GenericSetAttr(self, new_attr, new_val) + r3 = r2 >= 0 :: signed + return 1 +def SetAttr.__setattr__(self, key, val): + self :: __main__.SetAttr + key :: str + val :: object + r0 :: str + r1 :: bool + r2 :: int + r3 :: bool + r4 :: str + r5 :: bool + r6 :: object + r7 :: str + r8, r9 :: object + r10 :: dict + r11 :: i32 + r12 :: bit +L0: + r0 = 'regular_attr' + r1 = CPyStr_Equal(key, r0) + if r1 goto L1 else goto L2 :: bool +L1: + r2 = unbox(int, val) + self.regular_attr = r2; r3 = is_error + goto L6 +L2: + r4 = 'class_var' + r5 = CPyStr_Equal(key, r4) + if r5 goto L3 else goto L4 :: bool +L3: + r6 = builtins :: module + r7 = 'AttributeError' + r8 = CPyObject_GetAttr(r6, r7) + r9 = PyObject_Vectorcall(r8, 0, 0, 0) + CPy_Raise(r9) + unreachable +L4: + r10 = self._attributes + r11 = CPyDict_SetItem(r10, key, val) + r12 = r11 >= 0 :: signed +L5: +L6: + return 1 +def SetAttr.__setattr____wrapper(__mypyc_self__, attr, value): + __mypyc_self__ :: __main__.SetAttr + attr, value :: object + r0 :: bit + r1 :: str + r2 :: None + r3 :: str + r4 :: None +L0: + r0 = value == 0 + if r0 goto L1 else goto L2 :: bool +L1: + r1 = cast(str, attr) + r2 = __mypyc_self__.__delattr__(r1) + return 0 +L2: + r3 = cast(str, attr) + r4 = __mypyc_self__.__setattr__(r3, value) + return 0 +def SetAttr.__delattr__(self, key): + self :: __main__.SetAttr + key :: str + r0 :: dict + r1 :: i32 + r2 :: bit +L0: + r0 = self._attributes + r1 = PyObject_DelItem(r0, key) + r2 = r1 >= 0 :: signed + return 1 + [case testUntransformedSetAttr_64bit] from mypy_extensions import mypyc_attr diff --git a/mypyc/test-data/run-classes.test b/mypyc/test-data/run-classes.test index d10f7b19067c..ab1dcb926c34 100644 --- a/mypyc/test-data/run-classes.test +++ b/mypyc/test-data/run-classes.test @@ -4566,6 +4566,9 @@ class SetAttrOverridden(SetAttr): else: super().__setattr__(key, val) + def __delattr__(self, key: str) -> None: + del self._attributes[key] + @mypyc_attr(native_class=False) class SetAttrNonNative: _attributes: dict[str, object] @@ -4645,6 +4648,10 @@ def test_setattr() -> None: with assertRaises(AttributeError): i.const = 45 + # Doesn't work because there's no __delattr__. + with assertRaises(AttributeError): + del i.four + def test_setattr_inherited() -> None: i = SetAttrInherited(99, {"one": 1}) assert i.class_var == "x" @@ -4678,6 +4685,10 @@ def test_setattr_inherited() -> None: with assertRaises(AttributeError): i.const = 45 + # Doesn't work because there's no __delattr__. + with assertRaises(AttributeError): + del i.four + def test_setattr_overridden() -> None: i = SetAttrOverridden(99, 1, {"one": 1}) assert i.class_var == "x" @@ -4723,6 +4734,15 @@ def test_setattr_overridden() -> None: with assertRaises(AttributeError): i.const = 45 + del i.four + assert "four" not in i._attributes + + delattr(i, "three") + assert "three" not in i._attributes + + i.__delattr__("two") + assert "two" not in i._attributes + base_ref: SetAttr = i setattr(base_ref, "sub_attr", 5) assert base_ref.sub_attr == 5 @@ -4733,6 +4753,12 @@ def test_setattr_overridden() -> None: with assertRaises(AttributeError): setattr(base_ref, "subclass_var", "c") + base_ref.new_attr = "new_attr" + assert base_ref.new_attr == "new_attr" + + del base_ref.new_attr + assert "new_attr" not in base_ref._attributes + def test_setattr_nonnative() -> None: i = SetAttrNonNative(99, {"one": 1}) assert i.class_var == "x" @@ -4766,6 +4792,10 @@ def test_setattr_nonnative() -> None: with assertRaises(AttributeError): i.const = 45 + # Doesn't work because there's no __delattr__. + with assertRaises(AttributeError): + del i.four + def test_no_setattr() -> None: i = NoSetAttr(99) i.super_setattr("attr", 100) @@ -4806,6 +4836,15 @@ def test_no_setattr_nonnative() -> None: object.__setattr__(i, "three", 102) assert i.three == 102 + del i.three + assert i.three == None + + delattr(i, "two") + assert i.two == None + + object.__delattr__(i, "one") + assert i.one == None + [typing fixtures/typing-full.pyi] [case testDunderSetAttrInterpreted] @@ -4853,6 +4892,9 @@ class SetAttrOverridden(SetAttr): else: super().__setattr__(key, val) + def __delattr__(self, key: str) -> None: + del self._attributes[key] + @mypyc_attr(native_class=False) class SetAttrNonNative: _attributes: dict[str, object] @@ -4936,6 +4978,10 @@ def test_setattr() -> None: with assertRaises(AttributeError): i.const = 45 + # Doesn't work because there's no __delattr__. + with assertRaises(AttributeError): + del i.four + def test_setattr_inherited() -> None: i = SetAttrInherited(99, {"one": 1}) assert i.class_var == "x" @@ -4969,6 +5015,10 @@ def test_setattr_inherited() -> None: with assertRaises(AttributeError): i.const = 45 + # Doesn't work because there's no __delattr__. + with assertRaises(AttributeError): + del i.four + def test_setattr_overridden() -> None: i = SetAttrOverridden(99, 1, {"one": 1}) assert i.class_var == "x" @@ -5014,6 +5064,15 @@ def test_setattr_overridden() -> None: with assertRaises(AttributeError): i.const = 45 + del i.four + assert "four" not in i._attributes + + delattr(i, "three") + assert "three" not in i._attributes + + i.__delattr__("two") + assert "two" not in i._attributes + base_ref: SetAttr = i setattr(base_ref, "sub_attr", 5) assert base_ref.sub_attr == 5 @@ -5024,6 +5083,12 @@ def test_setattr_overridden() -> None: with assertRaises(AttributeError): setattr(base_ref, "subclass_var", "c") + base_ref.new_attr = "new_attr" + assert base_ref.new_attr == "new_attr" + + del base_ref.new_attr + assert "new_attr" not in base_ref._attributes + def test_setattr_nonnative() -> None: i = SetAttrNonNative(99, {"one": 1}) assert i.class_var == "x" @@ -5057,6 +5122,10 @@ def test_setattr_nonnative() -> None: with assertRaises(AttributeError): i.const = 45 + # Doesn't work because there's no __delattr__. + with assertRaises(AttributeError): + del i.four + def test_no_setattr() -> None: i = NoSetAttr(99) i.super_setattr("attr", 100) @@ -5097,6 +5166,15 @@ def test_no_setattr_nonnative() -> None: object.__setattr__(i, "three", 102) assert i.three == 102 + del i.three + assert i.three == None + + delattr(i, "two") + assert i.two == None + + object.__delattr__(i, "one") + assert i.one == None + test_setattr() test_setattr_inherited() test_setattr_overridden() @@ -5105,3 +5183,67 @@ test_no_setattr() test_no_setattr_nonnative() [typing fixtures/typing-full.pyi] + +[case testDelAttrWithDeletableAttr] +from testutil import assertRaises + +class DelAttr: + __deletable__ = ["del_counter"] + + _attributes: dict[str, object] + del_counter: int = 0 + + def __init__(self) -> None: + object.__setattr__(self, "_attributes", {}) + + def __setattr__(self, key: str, val: object) -> None: + if key == "del_counter": + object.__setattr__(self, "del_counter", val) + else: + self._attributes[key] = val + + def __delattr__(self, key: str) -> None: + if key == "del_counter": + self.del_counter += 1 + else: + del self._attributes[key] + +def test_deletable_attr() -> None: + i = DelAttr() + assert i.del_counter == 0 + del i.del_counter + assert i.del_counter == 1 + +[case testDelAttrWithDeletableAttrInterpreted] +class DelAttr: + __deletable__ = ["del_counter"] + + _attributes: dict[str, object] + del_counter: int = 0 + + def __init__(self) -> None: + object.__setattr__(self, "_attributes", {}) + + def __setattr__(self, key: str, val: object) -> None: + if key == "del_counter": + object.__setattr__(self, "del_counter", val) + else: + self._attributes[key] = val + + def __delattr__(self, key: str) -> None: + if key == "del_counter": + self.del_counter += 1 + else: + del self._attributes[key] + +[file driver.py] +from native import DelAttr +from testutil import assertRaises + +def test_deletable_attr() -> None: + i = DelAttr() + assert i.del_counter == 0 + del i.del_counter + assert i.del_counter == 1 + +test_deletable_attr()