Summary
After del Base.__getattr__, attribute access on an instance of a subclass that defines its own __getattribute__ no longer calls that method. RustPython falls back to the default attribute lookup, and the class stays that way. CPython keeps calling the subclass's __getattribute__.
So proxies, lazy objects and access hooks written with __getattribute__ silently stop working once __getattr__ has been added to and removed from a base class. One common way to get there is unittest.mock.patch.object(Base, "__getattr__", ..., create=True). When the patch ends, mock deletes the attribute again, and from then on every subclass of Base that defines __getattribute__ skips it for the rest of the process. Deleting __getattr__ from the class that defines __getattribute__ itself has the same effect.
Reproducer
class Base:
pass
class Proxy(Base):
def __getattribute__(self, name):
print("Proxy.__getattribute__", name)
return object.__getattribute__(self, name)
p = Proxy()
p.x = 1
Base.__getattr__ = lambda self, name: "fallback"
del Base.__getattr__
print(p.x)
The two lines before the last print can be replaced with with unittest.mock.patch.object(Base, "__getattr__", lambda self, name: "fallback", create=True): pass, and the output is the same.
Expected
CPython 3.15.2 (same on 3.13.18 and 3.12.16):
Proxy.__getattribute__ x
1
Actual
RustPython main (dd8c343), same on b66534d:
Cause
__getattribute__ and __getattr__ share the getattro slot, and its update code is in slot.rs#L1230-L1269. When a name is added and no __getattr__ is left in the MRO, the code looks up __getattribute__ with lookup_slot_in_mro. When a name is removed (ADD is false), the last branch (L1267-L1269) calls inherit_from_mro instead. That copies the slot of the first base class that has one and never looks at a __getattribute__ in the class's own dict. del Base.__getattr__ updates the slot of Base and of its subclasses, so Proxy ends up with object's generic getattro. The __setattr__/__delattr__ branch right below (L1271-L1304) already resolves both names again on removal.
I tried resolving __getattribute__ the same way in both cases: drop the ADD condition, look up identifier!(ctx, __getattribute__) instead of name, and remove the last else. With that change, the reproducer, the mock.patch.object variant and the same-class case all match CPython. test_descr, test_class, test_types, extra_tests/snippets/builtin_object.py and extra_tests/snippets/vm_specialization.py still pass.
Drafted with help from Claude Code; I reviewed and checked it before posting.