From 165d4051effa072044cee57997f7d156a4bd25db Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Fri, 5 Dec 2025 21:02:47 +0000 Subject: [PATCH 01/19] make cached_property a descriptor in attrs instead of rewriting __getattr__ --- src/attr/_make.py | 99 +++++++++++++++++++---------------------------- 1 file changed, 40 insertions(+), 59 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index d24d9ba98..ddc5a7cd7 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -15,7 +15,7 @@ import weakref from collections.abc import Callable, Mapping -from functools import cached_property +from functools import cached_property, update_wrapper from typing import Any, NamedTuple, TypeVar # We need to import _compat itself in addition to the _compat members to avoid @@ -496,56 +496,40 @@ def _transform_attrs( return _Attributes(AttrsClass(attrs), base_attrs, base_attr_map) -def _make_cached_property_getattr(cached_properties, original_getattr, cls): - lines = [ - # Wrapped to get `__class__` into closure cell for super() - # (It will be replaced with the newly constructed class after construction). - "def wrapper(_cls):", - " __class__ = _cls", - " def __getattr__(self, item, cached_properties=cached_properties, original_getattr=original_getattr, _cached_setattr_get=_cached_setattr_get):", - " func = cached_properties.get(item)", - " if func is not None:", - " result = func(self)", - " _setter = _cached_setattr_get(self)", - " _setter(item, result)", - " return result", - ] - if original_getattr is not None: - lines.append( - " return original_getattr(self, item)", - ) - else: - lines.extend( - [ - " try:", - " return super().__getattribute__(item)", - " except AttributeError:", - " if not hasattr(super(), '__getattr__'):", - " raise", - " return super().__getattr__(item)", - " original_error = f\"'{self.__class__.__name__}' object has no attribute '{item}'\"", - " raise AttributeError(original_error)", - ] - ) +class _SlottedCachedProperty: + # This is a class that is used to wrap both a slot and a cached property + # externally, users should just use `functools.cached_property` but + # attrs' slotting behaviour will remove those, add the names to `__slots__` + # and after constructing the class, replace those slot descriptors with these + # special slotted cached property attributes - lines.extend( - [ - " return __getattr__", - "__getattr__ = wrapper(_cls)", - ] - ) + def __init__(self, slot, func): + self.slot = slot + self.func = func - unique_filename = _generate_unique_filename(cls, "getattr") + def __get__(self, instance, owner=None): + if instance is None: + return self - glob = { - "cached_properties": cached_properties, - "_cached_setattr_get": _OBJ_SETATTR.__get__, - "original_getattr": original_getattr, - } + try: + return self.slot.__get__(instance, owner) + except AttributeError: + pass - return _linecache_and_compile( - "\n".join(lines), unique_filename, glob, locals={"_cls": cls} - )["__getattr__"] + result = self.func(instance) + + self.slot.__set__(instance, result) + + return result + + def __repr__(self): + return f"" + + def __set__(self, obj, value): + self.slot.__set__(obj, value) + + def __delete__(self, obj): + self.slot.__delete__(obj) def _frozen_setattrs(self, name, value): @@ -912,24 +896,12 @@ def _create_slots_class(self): # To know to update them. additional_closure_functions_to_update = [] if cached_properties: - class_annotations = _get_annotations(self._cls) for name, func in cached_properties.items(): # Add cached properties to names for slotting. names += (name,) # Clear out function from class to avoid clashing. del cd[name] additional_closure_functions_to_update.append(func) - annotation = inspect.signature(func).return_annotation - if annotation is not inspect.Parameter.empty: - class_annotations[name] = annotation - - original_getattr = cd.get("__getattr__") - if original_getattr is not None: - additional_closure_functions_to_update.append(original_getattr) - - cd["__getattr__"] = _make_cached_property_getattr( - cached_properties, original_getattr, self._cls - ) # We only add the names of attributes that aren't inherited. # Setting __slots__ to inherited attributes wastes memory. @@ -956,6 +928,15 @@ def _create_slots_class(self): # Create new class based on old class and our methods. cls = type(self._cls)(self._cls.__name__, self._cls.__bases__, cd) + # Now add back the wrapped cached properties + for name, func in cached_properties.items(): + slot = getattr(cls, name) + if isinstance(slot, _SlottedCachedProperty): + slot = slot.slot + slotted_property = _SlottedCachedProperty(slot, func) + update_wrapper(slotted_property, func) + setattr(cls, name, slotted_property) + # The following is a fix for # . # If a method mentions `__class__` or uses the no-arg super(), the From b4c5a1a298e5ac1b40bcd7ecad2d1734b8aa6749 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Fri, 5 Dec 2025 21:52:26 +0000 Subject: [PATCH 02/19] Remove the infers_type test The descriptor now carries the annotations or annotate function from the wrapped function. --- tests/test_slots.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/tests/test_slots.py b/tests/test_slots.py index a74c32b03..2f0dc3d98 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -785,27 +785,6 @@ def f(self) -> int: assert A(x=1).f == 1 -@pytest.mark.xfail( - PY_3_14_PLUS, reason="3.14 does not infer the type anymore." -) -def test_slots_cached_property_infers_type(): - """ - Infers type of cached property on Python 3.13 and earlier. - - See also #1431. - """ - - @attrs.frozen(slots=True) - class A: - x: int - - @functools.cached_property - def f(self) -> int: - return self.x - - assert A.__annotations__ == {"x": int, "f": int} - - def test_slots_cached_property_with_empty_getattr_raises_attribute_error_of_requested(): """ Ensures error information is not lost. From ecb0118a53a64047d560c46c1944415c29fceb1b Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Fri, 5 Dec 2025 21:53:26 +0000 Subject: [PATCH 03/19] Add tests for #1325, #1333 and #1288 --- tests/test_slots.py | 84 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/tests/test_slots.py b/tests/test_slots.py index 2f0dc3d98..e561af76e 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -864,6 +864,90 @@ def __getattr__(self, item): assert a.z == "z" +def test_slots_cached_property_has_annotations(): + """ + The slotted cached property wrapper should have the annotations + from the wrapped object + """ + @attrs.frozen(slots=True) + class A: + x: int + + @functools.cached_property + def f(self) -> int: + return self.x + + assert A.__annotations__ == {"x": int} + assert A.f.__annotations__ == {"return": int} + + +def test_slots_cached_property_retains_doc(): + """ + Cached property's docstring is retained + + See: https://github.com/python-attrs/attrs/issues/1325 + """ + + @attr.s(slots=True) + class A: + x = attr.ib() + + @functools.cached_property + def f(self): + """ + This is a docstring. + """ + return self.x + + assert "This is a docstring." in A.f.__doc__ + + +def test_slots_cached_property_super_works(): + """ + Calling super() with a cached property should correctly call from the parent + + See: https://github.com/python-attrs/attrs/issues/1333 + """ + @attr.s(slots=True) + class Parent: + @functools.cached_property + def name(self) -> str: + return "Alice" + + @attr.s(slots=True) + class Child(Parent): + @functools.cached_property + def name(self) -> str: + return f"Bob (son of {super().name})" + + p = Parent() + c = Child() + + assert p.name == "Alice" + assert c.name == "Bob (son of Alice)" + + +def test_slots_cached_property_skips_child_getattr(): + """ + __getattr__ on child should not interfere with cached_properties + + See: https://github.com/python-attrs/attrs/issues/1288 + """ + + @attrs.define + class Bob: + @functools.cached_property + def howdy(self): + return 3 + + class Sup(Bob): + def __getattr__(self, name): + raise AttributeError(name) + + b = Sup() + assert b.howdy == 3 + + def test_slots_getattr_in_superclass__is_called_for_missing_attributes_when_cached_property_present(): """ Ensure __getattr__ implementation is maintained in subclass. From ac9e5917d298893198df62b54a3f1d5830ec2493 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Fri, 5 Dec 2025 21:57:19 +0000 Subject: [PATCH 04/19] Update documentation on slotted cached properties --- docs/how-does-it-work.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/how-does-it-work.md b/docs/how-does-it-work.md index 96dc5c303..313c800ea 100644 --- a/docs/how-does-it-work.md +++ b/docs/how-does-it-work.md @@ -110,8 +110,9 @@ Therefore, *attrs* converts `cached_property`-decorated methods when constructin Getting this working is achieved by: +* Removing the wrapped methods from the original class and storing them. * Adding names to `__slots__` for the wrapped methods. -* Adding a `__getattr__` method to set values on the wrapped methods. +* Creating new `_SlottedCachedProperty` wrappers that wrap the slot descriptors together with the original functions. For most users, this should mean that it works transparently. From 76a57b9cff7774584ea2452ff79815c0620051d9 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Fri, 5 Dec 2025 22:09:23 +0000 Subject: [PATCH 05/19] Add some direct access tests for coverage --- tests/test_slots.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_slots.py b/tests/test_slots.py index e561af76e..29f8bd048 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -948,6 +948,22 @@ def __getattr__(self, name): assert b.howdy == 3 +def test_slots_cached_property_direct(): + """ + Test getting the wrapped cached property directly and the repr + """ + from attr._make import _SlottedCachedProperty + + @attr.s(slots=True) + class Parent: + @functools.cached_property + def name(self) -> str: + return "Alice" + + assert isinstance(Parent.name, _SlottedCachedProperty) + assert repr(Parent.name).startswith(" Date: Fri, 5 Dec 2025 23:02:58 +0000 Subject: [PATCH 06/19] annotations hacks, ruff formatting --- src/attr/_make.py | 17 +++++++++++++++-- tests/test_slots.py | 10 +++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index ddc5a7cd7..1d14dc449 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -500,8 +500,8 @@ class _SlottedCachedProperty: # This is a class that is used to wrap both a slot and a cached property # externally, users should just use `functools.cached_property` but # attrs' slotting behaviour will remove those, add the names to `__slots__` - # and after constructing the class, replace those slot descriptors with these - # special slotted cached property attributes + # and after constructing the class, replace those slot descriptors with + # these special slotted cached property attributes def __init__(self, slot, func): self.slot = slot @@ -522,6 +522,19 @@ def __get__(self, instance, owner=None): return result + if sys.version_info >= (3, 14): + # As __annotate__ exists on the instance and not a class + # due to wrapping, Python 3.14 won't have __annotations__ + # Use a property to provide them in this case + @property + def __annotations__(self): + return self.func.__annotations__ + + def __call__(self): + # Trick inspect.get_annotations into thinking this is callable + exc_msg = f"{self.__class__.__name__!r} object is not callable" + raise TypeError(exc_msg) + def __repr__(self): return f"" diff --git a/tests/test_slots.py b/tests/test_slots.py index 29f8bd048..e7c97a676 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -5,6 +5,7 @@ """ import functools +import inspect import pickle import weakref @@ -15,7 +16,7 @@ import attr import attrs -from attr._compat import PY_3_14_PLUS, PYPY +from attr._compat import PY_3_10_PLUS, PYPY # Pympler doesn't work on PyPy. @@ -869,6 +870,7 @@ def test_slots_cached_property_has_annotations(): The slotted cached property wrapper should have the annotations from the wrapped object """ + @attrs.frozen(slots=True) class A: x: int @@ -880,6 +882,10 @@ def f(self) -> int: assert A.__annotations__ == {"x": int} assert A.f.__annotations__ == {"return": int} + if PY_3_10_PLUS: + # This requires making the class "callable" for inspect + assert inspect.get_annotations(A.f) == {"return": int} + def test_slots_cached_property_retains_doc(): """ @@ -908,6 +914,7 @@ def test_slots_cached_property_super_works(): See: https://github.com/python-attrs/attrs/issues/1333 """ + @attr.s(slots=True) class Parent: @functools.cached_property @@ -945,6 +952,7 @@ def __getattr__(self, name): raise AttributeError(name) b = Sup() + assert b.howdy == 3 From 368a93bbcd6b761e30f047542460a8d863513906 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Fri, 5 Dec 2025 23:22:05 +0000 Subject: [PATCH 07/19] Use the attrs compat instead of direct sys.version_info --- src/attr/_make.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 1d14dc449..bfbc85211 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -25,6 +25,7 @@ PY_3_10_PLUS, PY_3_11_PLUS, PY_3_13_PLUS, + PY_3_14_PLUS, _AnnotationExtractor, _get_annotations, get_generic_base, @@ -522,7 +523,7 @@ def __get__(self, instance, owner=None): return result - if sys.version_info >= (3, 14): + if PY_3_14_PLUS: # As __annotate__ exists on the instance and not a class # due to wrapping, Python 3.14 won't have __annotations__ # Use a property to provide them in this case From 72e58fb004f08a4b5c9654bb1b0c908d0499042d Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Fri, 5 Dec 2025 23:22:19 +0000 Subject: [PATCH 08/19] Additional tests for coverage --- tests/test_slots.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_slots.py b/tests/test_slots.py index e7c97a676..74c7a146b 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -970,6 +970,27 @@ def name(self) -> str: assert isinstance(Parent.name, _SlottedCachedProperty) assert repr(Parent.name).startswith(" str: + return "Alice" + + p = Parent() + p.name = "Bob" + assert p.name == "Bob" + del p.name + assert p.name == "Alice" def test_slots_getattr_in_superclass__is_called_for_missing_attributes_when_cached_property_present(): From 829da4c30bf8072d6f110938f37440eee38eaad7 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Fri, 5 Dec 2025 23:35:06 +0000 Subject: [PATCH 09/19] remove unnecessary annotations work not done by cached_property --- src/attr/_make.py | 19 ++----------------- tests/test_slots.py | 27 +-------------------------- 2 files changed, 3 insertions(+), 43 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index bfbc85211..cad692607 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -507,6 +507,8 @@ class _SlottedCachedProperty: def __init__(self, slot, func): self.slot = slot self.func = func + self.__doc__ = self.func.__doc__ + self.__module__ = self.func.__module__ def __get__(self, instance, owner=None): if instance is None: @@ -523,22 +525,6 @@ def __get__(self, instance, owner=None): return result - if PY_3_14_PLUS: - # As __annotate__ exists on the instance and not a class - # due to wrapping, Python 3.14 won't have __annotations__ - # Use a property to provide them in this case - @property - def __annotations__(self): - return self.func.__annotations__ - - def __call__(self): - # Trick inspect.get_annotations into thinking this is callable - exc_msg = f"{self.__class__.__name__!r} object is not callable" - raise TypeError(exc_msg) - - def __repr__(self): - return f"" - def __set__(self, obj, value): self.slot.__set__(obj, value) @@ -948,7 +934,6 @@ def _create_slots_class(self): if isinstance(slot, _SlottedCachedProperty): slot = slot.slot slotted_property = _SlottedCachedProperty(slot, func) - update_wrapper(slotted_property, func) setattr(cls, name, slotted_property) # The following is a fix for diff --git a/tests/test_slots.py b/tests/test_slots.py index 74c7a146b..c553acb8e 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -865,28 +865,6 @@ def __getattr__(self, item): assert a.z == "z" -def test_slots_cached_property_has_annotations(): - """ - The slotted cached property wrapper should have the annotations - from the wrapped object - """ - - @attrs.frozen(slots=True) - class A: - x: int - - @functools.cached_property - def f(self) -> int: - return self.x - - assert A.__annotations__ == {"x": int} - assert A.f.__annotations__ == {"return": int} - - if PY_3_10_PLUS: - # This requires making the class "callable" for inspect - assert inspect.get_annotations(A.f) == {"return": int} - - def test_slots_cached_property_retains_doc(): """ Cached property's docstring is retained @@ -958,7 +936,7 @@ def __getattr__(self, name): def test_slots_cached_property_direct(): """ - Test getting the wrapped cached property directly and the repr + Test getting the wrapped cached property directly """ from attr._make import _SlottedCachedProperty @@ -969,9 +947,6 @@ def name(self) -> str: return "Alice" assert isinstance(Parent.name, _SlottedCachedProperty) - assert repr(Parent.name).startswith(" Date: Fri, 5 Dec 2025 23:40:17 +0000 Subject: [PATCH 10/19] restore deleted test --- tests/test_slots.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/test_slots.py b/tests/test_slots.py index c553acb8e..a0f53c758 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -16,7 +16,7 @@ import attr import attrs -from attr._compat import PY_3_10_PLUS, PYPY +from attr._compat import PY_3_14_PLUS, PYPY # Pympler doesn't work on PyPy. @@ -786,6 +786,27 @@ def f(self) -> int: assert A(x=1).f == 1 +@pytest.mark.xfail( + PY_3_14_PLUS, reason="3.14 does not infer the type anymore." +) +def test_slots_cached_property_infers_type(): + """ + Infers type of cached property on Python 3.13 and earlier. + + See also #1431. + """ + + @attrs.frozen(slots=True) + class A: + x: int + + @functools.cached_property + def f(self) -> int: + return self.x + + assert A.__annotations__ == {"x": int, "f": int} + + def test_slots_cached_property_with_empty_getattr_raises_attribute_error_of_requested(): """ Ensures error information is not lost. From 847f535676de0eb9865f24801431201fe09ffed1 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Fri, 5 Dec 2025 23:40:36 +0000 Subject: [PATCH 11/19] restore deleted annotation work --- src/attr/_make.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/attr/_make.py b/src/attr/_make.py index cad692607..a529ecf1c 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -896,11 +896,16 @@ def _create_slots_class(self): # To know to update them. additional_closure_functions_to_update = [] if cached_properties: + class_annotations = _get_annotations(self._cls) for name, func in cached_properties.items(): # Add cached properties to names for slotting. names += (name,) # Clear out function from class to avoid clashing. del cd[name] + annotation = inspect.signature(func).return_annotation + if annotation is not inspect.Parameter.empty: + class_annotations[name] = annotation + additional_closure_functions_to_update.append(func) # We only add the names of attributes that aren't inherited. From 4662735b9d8c57809b89d228f23c3a0997a2d0b7 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Fri, 5 Dec 2025 23:44:42 +0000 Subject: [PATCH 12/19] ruff fixes --- src/attr/_make.py | 3 +-- tests/test_slots.py | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index a529ecf1c..4faa73705 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -15,7 +15,7 @@ import weakref from collections.abc import Callable, Mapping -from functools import cached_property, update_wrapper +from functools import cached_property from typing import Any, NamedTuple, TypeVar # We need to import _compat itself in addition to the _compat members to avoid @@ -25,7 +25,6 @@ PY_3_10_PLUS, PY_3_11_PLUS, PY_3_13_PLUS, - PY_3_14_PLUS, _AnnotationExtractor, _get_annotations, get_generic_base, diff --git a/tests/test_slots.py b/tests/test_slots.py index a0f53c758..4e5c779cb 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -5,7 +5,6 @@ """ import functools -import inspect import pickle import weakref From 09af0065a7b0d22d2249a385415956141bd981e7 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Sat, 6 Dec 2025 00:03:22 +0000 Subject: [PATCH 13/19] style - replace comment with docstring to fit in better --- src/attr/_make.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 4faa73705..69ed1057c 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -497,11 +497,15 @@ def _transform_attrs( class _SlottedCachedProperty: - # This is a class that is used to wrap both a slot and a cached property - # externally, users should just use `functools.cached_property` but - # attrs' slotting behaviour will remove those, add the names to `__slots__` - # and after constructing the class, replace those slot descriptors with - # these special slotted cached property attributes + """ + This is a class that is used to wrap both a slot and a cached property. + *It is not to be used directly.* + Users should just use `functools.cached_property`. + + attrs' slotting behaviour will remove cached_property instances, + add the names to `__slots__` and after constructing the class, + replace those slot descriptors with instances of this class + """ def __init__(self, slot, func): self.slot = slot From 02ee262c8c2e1adb928470268b6c7f817e473c87 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Sat, 6 Dec 2025 15:41:27 +0000 Subject: [PATCH 14/19] Try to test some unexpected behaviour is consistent --- tests/test_slots.py | 68 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/test_slots.py b/tests/test_slots.py index 4e5c779cb..2b9275cd1 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -988,6 +988,74 @@ def name(self) -> str: assert p.name == "Alice" +@pytest.mark.parametrize( + "slotted", + [ + pytest.param( + True, + marks=pytest.mark.xfail( + reason="field names are not checked for cached properties" + ), + ), + False, + ], +) +def test_cached_property_overriding_field(slotted): + """ + Discrepency in cached property overriding behaviour + + attrs only considers fields that are not fields to become + cached properties. + + c.name is not converted to a cached property + """ + + @attrs.define(slots=slotted) + class Parent: + name: str = "Alice" + + @attrs.define(slots=slotted) + class Child(Parent): + @functools.cached_property + def name(self): + return "Bob" + + # This isn't to imply that this is good + # just that it's consistent + p = Parent() + c = Child() + + assert p.name == "Alice" + assert c.name == "Alice" + del c.name + assert c.name == "Bob" # Errors under slots + + +@pytest.mark.parametrize("slotted", [True, False]) +def test_field_overriding_cached_property(slotted): + """ + Check that overriding a cached property with a field + works the same slotted or unslotted + """ + @attrs.define(slots=slotted) + class Parent: + @functools.cached_property + def name(self): + return "Alice" + + @attrs.define(slots=slotted) + class Child(Parent): + name: str = "Bob" + + p = Parent() + c = Child() + + assert p.name == "Alice" + assert c.name == "Bob" + del c.name + assert c.name == "Alice" + + def test_slots_getattr_in_superclass__is_called_for_missing_attributes_when_cached_property_present(): """ Ensure __getattr__ implementation is maintained in subclass. From c4c1c6e71aab9076b58384b4f2b85f097757c98d Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Sat, 6 Dec 2025 16:22:03 +0000 Subject: [PATCH 15/19] formatting --- tests/test_slots.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_slots.py b/tests/test_slots.py index 2b9275cd1..cdd1a4e9a 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -1037,6 +1037,7 @@ def test_field_overriding_cached_property(slotted): Check that overriding a cached property with a field works the same slotted or unslotted """ + @attrs.define(slots=slotted) class Parent: @functools.cached_property From ea11e0053cde8af07820ae99a2f2da28fdd061a4 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Sat, 6 Dec 2025 16:26:06 +0000 Subject: [PATCH 16/19] discrepancy --- tests/test_slots.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_slots.py b/tests/test_slots.py index cdd1a4e9a..f971db382 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -1002,7 +1002,7 @@ def name(self) -> str: ) def test_cached_property_overriding_field(slotted): """ - Discrepency in cached property overriding behaviour + Discrepancy in cached property overriding behaviour attrs only considers fields that are not fields to become cached properties. From 921174e925707881bca529316e77a7eca0c61e43 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Mon, 8 Dec 2025 13:13:10 +0000 Subject: [PATCH 17/19] Try caching the get, set and delete methods for the slot. --- src/attr/_make.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 69ed1057c..048eeee0a 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -513,26 +513,30 @@ def __init__(self, slot, func): self.__doc__ = self.func.__doc__ self.__module__ = self.func.__module__ + self._slotget = slot.__get__ + self._slotset = slot.__set__ + self._slotdelete = slot.__delete__ + def __get__(self, instance, owner=None): if instance is None: return self try: - return self.slot.__get__(instance, owner) + return self._slotget(instance, owner) except AttributeError: pass result = self.func(instance) - self.slot.__set__(instance, result) + self._slotset(instance, result) return result def __set__(self, obj, value): - self.slot.__set__(obj, value) + self._slotset(obj, value) def __delete__(self, obj): - self.slot.__delete__(obj) + self._slotdelete(obj) def _frozen_setattrs(self, name, value): From 65b5b359af493e74ea9e4c97b6101f99e7b74b03 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Mon, 8 Dec 2025 16:21:20 +0000 Subject: [PATCH 18/19] Add a benchmark for constructing classes with cached properties --- bench/test_benchmarks.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/bench/test_benchmarks.py b/bench/test_benchmarks.py index 80129a449..d4fb28556 100644 --- a/bench/test_benchmarks.py +++ b/bench/test_benchmarks.py @@ -214,3 +214,18 @@ def test_repeated_access(self): for _ in range(ROUNDS): _ = c.cached + + def test_create_cached_property_class(self): + """ + Benchmark creating a class with a cached property + """ + for _ in range(ROUNDS): + + @attrs.define + class LocalC: + x: int + y: str + z: dict[str, int] + @functools.cached_property + def cached(self): + return 42 From 5e19f827d62b4e5495c8c2ec34990cc67cc16d06 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:21:34 +0000 Subject: [PATCH 19/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- bench/test_benchmarks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bench/test_benchmarks.py b/bench/test_benchmarks.py index d4fb28556..fc462bf40 100644 --- a/bench/test_benchmarks.py +++ b/bench/test_benchmarks.py @@ -226,6 +226,7 @@ class LocalC: x: int y: str z: dict[str, int] + @functools.cached_property def cached(self): return 42