Skip to content

Commit 0fa3184

Browse files
committed
Define __getattribute__() instead of __getattr__() on slotted classes with cached properties
The __getattribute__() is documented as the "way to to actually get total control over attribute access", which is really what we want. Just changing that preserves the current behaviour, according to the test suite, but also makes sub-classing work better, e.g. when the subclass is not an attr-class and also defines a custom __getattr__() as evidenced in added test. Fix python-attrs#1288
1 parent 5618e6f commit 0fa3184

File tree

3 files changed

+93
-28
lines changed

3 files changed

+93
-28
lines changed

Diff for: changelog.d/1291.change.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix inheritance resolution of cached properties in slotted class when subclasses do not define any `@cached_property` themselves but do define a custom `__getattr__()` method.

Diff for: src/attr/_make.py

+35-28
Original file line numberDiff line numberDiff line change
@@ -598,55 +598,60 @@ def _transform_attrs(
598598
return _Attributes((AttrsClass(attrs), base_attrs, base_attr_map))
599599

600600

601-
def _make_cached_property_getattr(cached_properties, original_getattr, cls):
601+
def _make_cached_property_getattribute(
602+
cached_properties, original_getattribute, cls
603+
):
602604
lines = [
603605
# Wrapped to get `__class__` into closure cell for super()
604606
# (It will be replaced with the newly constructed class after construction).
605607
"def wrapper(_cls):",
606608
" __class__ = _cls",
607-
" def __getattr__(self, item, cached_properties=cached_properties, original_getattr=original_getattr, _cached_setattr_get=_cached_setattr_get):",
608-
" func = cached_properties.get(item)",
609-
" if func is not None:",
610-
" result = func(self)",
611-
" _setter = _cached_setattr_get(self)",
612-
" _setter(item, result)",
613-
" return result",
609+
" def __getattribute__(self, item, cached_properties=cached_properties, original_getattr=original_getattr, _cached_setattr_get=_cached_setattr_get):",
610+
" try:",
611+
" return object.__getattribute__(self, item)",
612+
" except AttributeError:",
613+
" func = cached_properties.get(item)",
614+
" if func is not None:",
615+
" result = func(self)",
616+
" _setter = _cached_setattr_get(self)",
617+
" _setter(item, result)",
618+
" return result",
614619
]
615-
if original_getattr is not None:
620+
if original_getattribute is not None:
616621
lines.append(
617-
" return original_getattr(self, item)",
622+
" return original_getattribute(self, item)",
618623
)
619624
else:
620625
lines.extend(
621626
[
622-
" try:",
623-
" return super().__getattribute__(item)",
624-
" except AttributeError:",
625-
" if not hasattr(super(), '__getattr__'):",
626-
" raise",
627-
" return super().__getattr__(item)",
628-
" original_error = f\"'{self.__class__.__name__}' object has no attribute '{item}'\"",
629-
" raise AttributeError(original_error)",
627+
" try:",
628+
" return super().__getattribute__(item)",
629+
" except AttributeError:",
630+
" if not hasattr(super(), '__getattribute__'):",
631+
" raise",
632+
" return super().__getattribute__(item)",
633+
" original_error = f\"'{self.__class__.__name__}' object has no attribute '{item}'\"",
634+
" raise AttributeError(original_error)",
630635
]
631636
)
632637

633638
lines.extend(
634639
[
635-
" return __getattr__",
636-
"__getattr__ = wrapper(_cls)",
640+
" return __getattribute__",
641+
"__getattribute__ = wrapper(_cls)",
637642
]
638643
)
639644

640-
unique_filename = _generate_unique_filename(cls, "getattr")
645+
unique_filename = _generate_unique_filename(cls, "getattribute")
641646

642647
glob = {
643648
"cached_properties": cached_properties,
644649
"_cached_setattr_get": _OBJ_SETATTR.__get__,
645-
"original_getattr": original_getattr,
650+
"original_getattr": original_getattribute,
646651
}
647652

648653
return _make_method(
649-
"__getattr__",
654+
"__getattribute__",
650655
"\n".join(lines),
651656
unique_filename,
652657
glob,
@@ -948,12 +953,14 @@ def _create_slots_class(self):
948953
if annotation is not inspect.Parameter.empty:
949954
class_annotations[name] = annotation
950955

951-
original_getattr = cd.get("__getattr__")
952-
if original_getattr is not None:
953-
additional_closure_functions_to_update.append(original_getattr)
956+
original_getattribute = cd.get("__getattribute__")
957+
if original_getattribute is not None:
958+
additional_closure_functions_to_update.append(
959+
original_getattribute
960+
)
954961

955-
cd["__getattr__"] = _make_cached_property_getattr(
956-
cached_properties, original_getattr, self._cls
962+
cd["__getattribute__"] = _make_cached_property_getattribute(
963+
cached_properties, original_getattribute, self._cls
957964
)
958965

959966
# We only add the names of attributes that aren't inherited.

Diff for: tests/test_slots.py

+57
Original file line numberDiff line numberDiff line change
@@ -891,6 +891,63 @@ def f(self):
891891
assert b.z == "z"
892892

893893

894+
@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+")
895+
def test_slots_getattr_in_subclass_without_cached_property():
896+
"""
897+
Ensure that when a subclass of a slotted class with cached properties
898+
defines a __getattr__ but has no cached property itself, parent's cached
899+
properties are reachable.
900+
901+
Regression test for issue https://github.com/python-attrs/attrs/issues/1288
902+
"""
903+
904+
# Reference behaviour, without attr.
905+
class P:
906+
__slots__ = ()
907+
908+
@functools.cached_property
909+
def f(self) -> int:
910+
return 0
911+
912+
class C(P):
913+
def __getattr__(self, item: str) -> str:
914+
return item
915+
916+
assert not C.__slots__
917+
c = C()
918+
assert c.x == "x"
919+
assert c.__getattribute__("f") == 0
920+
assert c.f == 0
921+
922+
# Same with a base attr class.
923+
@attr.s(slots=True)
924+
class A:
925+
@functools.cached_property
926+
def f(self) -> int:
927+
return 0
928+
929+
# But subclass is not an attr-class.
930+
class B(A):
931+
def __getattr__(self, item: str) -> str:
932+
return item
933+
934+
b = B()
935+
assert b.z == "z"
936+
assert b.__getattribute__("f") == 0
937+
assert b.f == 0
938+
939+
# And also if subclass is an attr-class.
940+
@attr.s(slots=True)
941+
class D(A):
942+
def __getattr__(self, item: str) -> str:
943+
return item
944+
945+
d = D()
946+
assert d.z == "z"
947+
assert d.__getattribute__("f") == 0
948+
assert d.f == 0
949+
950+
894951
@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+")
895952
def test_slots_getattr_in_subclass_gets_superclass_cached_property():
896953
"""

0 commit comments

Comments
 (0)