Skip to content

Commit 8bb16ee

Browse files
committed
Define __getattribute__() on slotted classes with cached properties
Method `__getattribute__()` is documented as the "way to to actually get total control over attribute access" [1] so we change the implementation of slotted classes with cached properties by defining a `__getattribute__()` method instead of `__getattr__()` previously. [1]: https://docs.python.org/3/reference/datamodel.html#customizing-attribute-access 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. In tests, we replace most custom `__getattr__()` definitions by equivalent `__getattribute__()` ones, except in regression tests where `__getattr__()` is explicitly involved. Also, in test_slots_with_multiple_cached_property_subclasses_works(), we replace the `if hasattr(super(), "__getattr__"):` by a `try:`/`except AttributeError:` as using `hasattr(..., "__getattribute__")` would be meaningless since `__getattribute__()` is always defined. Fix #1288
1 parent b393d79 commit 8bb16ee

File tree

4 files changed

+114
-47
lines changed

4 files changed

+114
-47
lines changed

changelog.d/1291.change.md

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Rework handling of `functools.cached_property` in slotted classes by defining a `__getattribute__()` method instead of `__getattr__()` previously.
2+
While this is closer to the guidance for [customizing attribute access](https://docs.python.org/3/reference/datamodel.html#customizing-attribute-access), this fixes inheritance resolution of cached properties in slotted classes when subclasses are not attr-decorated but define a custom `__getattr__()` method.

docs/how-does-it-work.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ Therefore, *attrs* converts `cached_property`-decorated methods when constructin
111111
Getting this working is achieved by:
112112

113113
* Adding names to `__slots__` for the wrapped methods.
114-
* Adding a `__getattr__` method to set values on the wrapped methods.
114+
* Adding a `__getattribute__` method to set values on the wrapped methods.
115115

116116
For most users, this should mean that it works transparently.
117117

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_getattribute=original_getattribute, _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_getattribute": 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.

tests/test_slots.py

+76-18
Original file line numberDiff line numberDiff line change
@@ -786,7 +786,7 @@ def f(self) -> int:
786786

787787

788788
@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+")
789-
def test_slots_cached_property_with_empty_getattr_raises_attribute_error_of_requested():
789+
def test_slots_cached_property_with_empty_getattribute_raises_attribute_error_of_requested():
790790
"""
791791
Ensures error information is not lost.
792792
"""
@@ -809,8 +809,8 @@ def f(self):
809809
@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+")
810810
def test_slots_cached_property_raising_attributeerror():
811811
"""
812-
Ensures AttributeError raised by a property is preserved by __getattr__()
813-
implementation.
812+
Ensures AttributeError raised by a property is preserved by
813+
__getattribute__() implementation.
814814
815815
Regression test for issue https://github.com/python-attrs/attrs/issues/1230
816816
"""
@@ -846,9 +846,9 @@ def q(self):
846846

847847

848848
@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+")
849-
def test_slots_cached_property_with_getattr_calls_getattr_for_missing_attributes():
849+
def test_slots_cached_property_with_getattribute_calls_getattr_for_missing_attributes():
850850
"""
851-
Ensure __getattr__ implementation is maintained for non cached_properties.
851+
Ensure __getattribute__ implementation is maintained for non cached_properties.
852852
"""
853853

854854
@attr.s(slots=True)
@@ -859,7 +859,7 @@ class A:
859859
def f(self):
860860
return self.x
861861

862-
def __getattr__(self, item):
862+
def __getattribute__(self, item):
863863
return item
864864

865865
a = A(1)
@@ -868,16 +868,16 @@ def __getattr__(self, item):
868868

869869

870870
@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+")
871-
def test_slots_getattr_in_superclass__is_called_for_missing_attributes_when_cached_property_present():
871+
def test_slots_getattribute_in_superclass__is_called_for_missing_attributes_when_cached_property_present():
872872
"""
873-
Ensure __getattr__ implementation is maintained in subclass.
873+
Ensure __getattribute__ implementation is maintained in subclass.
874874
"""
875875

876876
@attr.s(slots=True)
877877
class A:
878878
x = attr.ib()
879879

880-
def __getattr__(self, item):
880+
def __getattribute__(self, item):
881881
return item
882882

883883
@attr.s(slots=True)
@@ -892,9 +892,66 @@ def f(self):
892892

893893

894894
@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+")
895-
def test_slots_getattr_in_subclass_gets_superclass_cached_property():
895+
def test_slots_getattr_in_subclass_without_cached_property():
896896
"""
897-
Ensure super() in __getattr__ is not broken through cached_property re-write.
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+
951+
@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+")
952+
def test_slots_getattribute_in_subclass_gets_superclass_cached_property():
953+
"""
954+
Ensure super() in __getattribute__ is not broken through cached_property re-write.
898955
"""
899956

900957
@attr.s(slots=True)
@@ -905,7 +962,7 @@ class A:
905962
def f(self):
906963
return self.x
907964

908-
def __getattr__(self, item):
965+
def __getattribute__(self, item):
909966
return item
910967

911968
@attr.s(slots=True)
@@ -914,8 +971,8 @@ class B(A):
914971
def g(self):
915972
return self.x
916973

917-
def __getattr__(self, item):
918-
return super().__getattr__(item)
974+
def __getattribute__(self, item):
975+
return super().__getattribute__(item)
919976

920977
b = B(1)
921978
assert b.f == 1
@@ -966,10 +1023,11 @@ class B:
9661023
def g(self):
9671024
return self.x * 2
9681025

969-
def __getattr__(self, item):
970-
if hasattr(super(), "__getattr__"):
971-
return super().__getattr__(item)
972-
return item
1026+
def __getattribute__(self, item):
1027+
try:
1028+
return super().__getattribute__(item)
1029+
except AttributeError:
1030+
return item
9731031

9741032
@attr.s(slots=True)
9751033
class AB(A, B):

0 commit comments

Comments
 (0)