From 716d219cfe693e26a5d540f377f98d28d020b9ef Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 17:38:22 +1300 Subject: [PATCH 01/48] Replace `@cached_property` with a `@property` for documentation purposes This renames the slots previously named the same as the `@cached_property` they were made for. They now have the suffix `_cache`, so that we can name the `@property` the same as the `@cached_property`. --- src/attr/_make.py | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 32e42976e..913a81128 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -505,6 +505,7 @@ def _make_cached_property_getattr(cached_properties, original_getattr, 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:", + " item = item + '_cache'", " result = func(self)", " _setter = _cached_setattr_get(self)", " _setter(item, result)", @@ -548,6 +549,40 @@ def _make_cached_property_getattr(cached_properties, original_getattr, cls): )["__getattr__"] +def _make_cached_property_uncached(original_cached_property_func, cls): + """Make an ordinary :deco:`property` to replace a :deco:`cached_property` + + This is mainly done for documentation purposes. The generated + :deco:`property` won't be called, because of our custom :meth:`__getattr__`. + We still want it there, so that its docstring is accessible, both to + :function:`help` and to documentation generators, such as Sphinx. + + """ + name = original_cached_property_func.__name__ + doc = original_cached_property_func.__doc__ + doc_lines = [] + if doc is not None: + doc_lines = doc.splitlines(True) + doc_lines[0] = '"""' + doc_lines[0] + doc_lines.append("\n") + doc_lines.append('"""') + + annotation = inspect.signature(original_cached_property_func).return_annotation + if annotation is inspect.Parameter.empty: + defline = f"def {name}(self):" + else: + defline = f"def {name}(self) -> {annotation}:" + lines = [ + "@property", + defline, + *(" " + line for line in doc_lines), + f" return self.__getattr__('{name}_cache')" + ] + unique_filename = _generate_unique_filename(cls, original_cached_property_func) + glob = {"original_cached_property": original_cached_property_func} + return _linecache_and_compile("\n".join(lines), unique_filename, glob)[name] + + def _frozen_setattrs(self, name, value): """ Attached to frozen classes as __setattr__. @@ -915,13 +950,14 @@ def _create_slots_class(self): class_annotations = _get_annotations(self._cls) for name, func in cached_properties.items(): # Add cached properties to names for slotting. - names += (name,) + names += (name + '_cache',) # 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 + cd[name] = _make_cached_property_uncached(func, self._cls) original_getattr = cd.get("__getattr__") if original_getattr is not None: From a5da43596010ffbc6973be80adc3b066a418aa19 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 18:05:04 +1300 Subject: [PATCH 02/48] Fix `test_slots_cached_property_class_does_not_have__dict__` --- 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 a74c32b03..80716ed30 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -765,7 +765,7 @@ class A: def f(self): return self.x - assert set(A.__slots__) == {"x", "f", "__weakref__"} + assert set(A.__slots__) == {"x", "f_cache", "__weakref__"} assert "__dict__" not in dir(A) From a3d1c45ef5ffe28a6554b1c27692f5a4c68fa808 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 18:09:25 +1300 Subject: [PATCH 03/48] Fix `@cached_property` annotations on py3.14 --- src/attr/_make.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 913a81128..98f697e90 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -570,8 +570,12 @@ def _make_cached_property_uncached(original_cached_property_func, cls): annotation = inspect.signature(original_cached_property_func).return_annotation if annotation is inspect.Parameter.empty: defline = f"def {name}(self):" - else: + elif isinstance(annotation, str): defline = f"def {name}(self) -> {annotation}:" + else: + import annotationlib + + defline = f"def {name}(self) -> {annotationlib.type_repr(annotation)}:" lines = [ "@property", defline, From 9ba38fcdb8c5dba9e4cc784044d1996bcab01270 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 18:25:45 +1300 Subject: [PATCH 04/48] Don't depend on `annotationlib` in `_make._make_cached_property_uncached()` --- src/attr/_make.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 98f697e90..627cdbe9e 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -505,10 +505,10 @@ def _make_cached_property_getattr(cached_properties, original_getattr, 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:", - " item = item + '_cache'", + " cache = item + '_cache'", " result = func(self)", " _setter = _cached_setattr_get(self)", - " _setter(item, result)", + " _setter(cache, result)", " return result", ] if original_getattr is not None: @@ -573,9 +573,20 @@ def _make_cached_property_uncached(original_cached_property_func, cls): elif isinstance(annotation, str): defline = f"def {name}(self) -> {annotation}:" else: - import annotationlib - - defline = f"def {name}(self) -> {annotationlib.type_repr(annotation)}:" + try: + import annotationlib + + defline = f"def {name}(self) -> {annotationlib.type_repr(annotation)}:" + except ModuleNotFoundError: + if isinstance(annotation, (type, types.FunctionType, types.BuiltinFunctionType)): + if annotation.__module__ == "builtins": + defline = f"def {name}(self) -> {annotation.__qualname__}:" + else: + defline = f"def {name}(self) -> {annotation.__module__}.{annotation.__qualname__}" + elif annotation in (..., types.EllipsisType): + defline = f"def {name}(self) -> ...:" + else: + defline = f"def {name}(self) -> {annotation}" lines = [ "@property", defline, From 1eff384b2bdad4f6cfcd788ba3c8384f43b32032 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 18:32:31 +1300 Subject: [PATCH 05/48] Call into the @cached_property from its uncached @property I'm still confused about why this code gets run at all, but at least it acts the same as cached properties now --- src/attr/_make.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 627cdbe9e..9f94723f1 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -591,7 +591,7 @@ def _make_cached_property_uncached(original_cached_property_func, cls): "@property", defline, *(" " + line for line in doc_lines), - f" return self.__getattr__('{name}_cache')" + f" return self.__getattr__('{name}')" ] unique_filename = _generate_unique_filename(cls, original_cached_property_func) glob = {"original_cached_property": original_cached_property_func} From 06d16560e55449b8ab964f6c0779a87a9b19e017 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 18:46:15 +1300 Subject: [PATCH 06/48] Make the generated @property raise AttributeError Better than calling __getattr__ ourselves because of recursion --- src/attr/_make.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 9f94723f1..7b1820dcd 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -591,7 +591,7 @@ def _make_cached_property_uncached(original_cached_property_func, cls): "@property", defline, *(" " + line for line in doc_lines), - f" return self.__getattr__('{name}')" + f" raise AttributeError('Use __getattr__ instead')" ] unique_filename = _generate_unique_filename(cls, original_cached_property_func) glob = {"original_cached_property": original_cached_property_func} From 18bdd2c77411bebaa8952796a4ccde1cb6227daf Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 18:55:04 +1300 Subject: [PATCH 07/48] Correct `test_slots_sub_class_with_actual_slot` At least, I think this is correct. When I run the original code in a terminal, without the changes from this branch, that's how it behaves. Is my Python setup messed? --- 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 80716ed30..666bf48c4 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -1055,7 +1055,7 @@ class B(A): f: int = attr.ib() assert B(1, 2).f == 2 - assert B.__slots__ == () + assert B.__slots__ == ("f",) def test_slots_cached_property_is_not_called_at_construction(): From 8a8bd46abf03ec8f28bd0efdfd19254d4a5189be Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 18:58:43 +1300 Subject: [PATCH 08/48] Fix repeat cached_property calls --- src/attr/_make.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 7b1820dcd..cade1d671 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -506,10 +506,13 @@ def _make_cached_property_getattr(cached_properties, original_getattr, cls): " func = cached_properties.get(item)", " if func is not None:", " cache = item + '_cache'", - " result = func(self)", - " _setter = _cached_setattr_get(self)", - " _setter(cache, result)", - " return result", + " try:", + " return self.__getattribute__(cache)", + " except AttributeError:", + " result = func(self)", + " _setter = _cached_setattr_get(self)", + " _setter(cache, result)", + " return result", ] if original_getattr is not None: lines.append( From d126565bd5e3a7a46705bf24375ff7e1b13add85 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 06:01:11 +0000 Subject: [PATCH 09/48] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/attr/_make.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index cade1d671..193883705 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -570,7 +570,9 @@ def _make_cached_property_uncached(original_cached_property_func, cls): doc_lines.append("\n") doc_lines.append('"""') - annotation = inspect.signature(original_cached_property_func).return_annotation + annotation = inspect.signature( + original_cached_property_func + ).return_annotation if annotation is inspect.Parameter.empty: defline = f"def {name}(self):" elif isinstance(annotation, str): @@ -579,9 +581,14 @@ def _make_cached_property_uncached(original_cached_property_func, cls): try: import annotationlib - defline = f"def {name}(self) -> {annotationlib.type_repr(annotation)}:" + defline = ( + f"def {name}(self) -> {annotationlib.type_repr(annotation)}:" + ) except ModuleNotFoundError: - if isinstance(annotation, (type, types.FunctionType, types.BuiltinFunctionType)): + if isinstance( + annotation, + (type, types.FunctionType, types.BuiltinFunctionType), + ): if annotation.__module__ == "builtins": defline = f"def {name}(self) -> {annotation.__qualname__}:" else: @@ -594,11 +601,15 @@ def _make_cached_property_uncached(original_cached_property_func, cls): "@property", defline, *(" " + line for line in doc_lines), - f" raise AttributeError('Use __getattr__ instead')" + " raise AttributeError('Use __getattr__ instead')", ] - unique_filename = _generate_unique_filename(cls, original_cached_property_func) + unique_filename = _generate_unique_filename( + cls, original_cached_property_func + ) glob = {"original_cached_property": original_cached_property_func} - return _linecache_and_compile("\n".join(lines), unique_filename, glob)[name] + return _linecache_and_compile("\n".join(lines), unique_filename, glob)[ + name + ] def _frozen_setattrs(self, name, value): @@ -968,7 +979,7 @@ def _create_slots_class(self): class_annotations = _get_annotations(self._cls) for name, func in cached_properties.items(): # Add cached properties to names for slotting. - names += (name + '_cache',) + names += (name + "_cache",) # Clear out function from class to avoid clashing. del cd[name] additional_closure_functions_to_update.append(func) From 95766dd7268c6f00db8934be3b8587a138f862e1 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 20:41:26 +1300 Subject: [PATCH 10/48] Avoid try-catch block in custom __getattr__ --- src/attr/_make.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index cade1d671..1c2daa6d7 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -506,9 +506,9 @@ def _make_cached_property_getattr(cached_properties, original_getattr, cls): " func = cached_properties.get(item)", " if func is not None:", " cache = item + '_cache'", - " try:", + " if cache in self.__slots__:", " return self.__getattribute__(cache)", - " except AttributeError:", + " else:", " result = func(self)", " _setter = _cached_setattr_get(self)", " _setter(cache, result)", From 0609b6ee57448061b71a3f7d7c322b6e91e4766e Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 20:52:02 +1300 Subject: [PATCH 11/48] Go back to the try-catch, but call `__get__` directly --- src/attr/_make.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 6f56d70d3..2955b72c1 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -506,9 +506,10 @@ def _make_cached_property_getattr(cached_properties, original_getattr, cls): " func = cached_properties.get(item)", " if func is not None:", " cache = item + '_cache'", - " if cache in self.__slots__:", - " return self.__getattribute__(cache)", - " else:", + " cached = getattr_static(self, cache)", + " try:", + " return cached.__get__(self, type(self))", + " except AttributeError:", " result = func(self)", " _setter = _cached_setattr_get(self)", " _setter(cache, result)", @@ -545,6 +546,7 @@ def _make_cached_property_getattr(cached_properties, original_getattr, cls): "cached_properties": cached_properties, "_cached_setattr_get": _OBJ_SETATTR.__get__, "original_getattr": original_getattr, + "getattr_static": inspect.getattr_static, } return _linecache_and_compile( From 5a9630a62b0db4704afd0c114c1f8c5c47b17eed Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 20:57:34 +1300 Subject: [PATCH 12/48] Add `test_slots_cached_property_has_docstring` --- tests/test_slots.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_slots.py b/tests/test_slots.py index 666bf48c4..aa5e60a6d 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -751,6 +751,23 @@ def f(self): assert A(11).f == 11 +def test_slots_cached_property_has_docstring(): + """ + cached_property in slotted class carries its original docstring + """ + + @attr.s(slots=True) + class A: + x = attr.ib() + + @functools.cached_property + def f(self): + """What an informative docstring!""" + return self.x + + assert A(11).f.__doc__ == "What an informative docstring!" + + def test_slots_cached_property_class_does_not_have__dict__(): """ From 00b0608a734de4d6459586e5fabc5f65bff701fe Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 07:57:46 +0000 Subject: [PATCH 13/48] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- 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 aa5e60a6d..52cf82117 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -751,6 +751,7 @@ def f(self): assert A(11).f == 11 + def test_slots_cached_property_has_docstring(): """ cached_property in slotted class carries its original docstring @@ -768,7 +769,6 @@ def f(self): assert A(11).f.__doc__ == "What an informative docstring!" - def test_slots_cached_property_class_does_not_have__dict__(): """ slotted class with cached property has no __dict__ attribute. From c4f9313a2da95fb910b1721bf7e8915aaf547c80 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 17:53:52 +1300 Subject: [PATCH 14/48] Add news fragment (cherry picked from commit 37dff273e53b3ab595b19c99d186dc1d032a66f1) --- changelog.d/cache_slot.change.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/cache_slot.change.md diff --git a/changelog.d/cache_slot.change.md b/changelog.d/cache_slot.change.md new file mode 100644 index 000000000..12ca1b74a --- /dev/null +++ b/changelog.d/cache_slot.change.md @@ -0,0 +1 @@ +Fixed docstrings for @cached_property of slotted classes From 173a3cad86feeba6aca0051da1081c931956139a Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 21:01:13 +1300 Subject: [PATCH 15/48] Fix missing colons in `_make_cached_property_uncached` --- src/attr/_make.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 2955b72c1..cfeed8251 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -594,11 +594,11 @@ def _make_cached_property_uncached(original_cached_property_func, cls): if annotation.__module__ == "builtins": defline = f"def {name}(self) -> {annotation.__qualname__}:" else: - defline = f"def {name}(self) -> {annotation.__module__}.{annotation.__qualname__}" + defline = f"def {name}(self) -> {annotation.__module__}.{annotation.__qualname__}:" elif annotation in (..., types.EllipsisType): defline = f"def {name}(self) -> ...:" else: - defline = f"def {name}(self) -> {annotation}" + defline = f"def {name}(self) -> {annotation}:" lines = [ "@property", defline, From c50e8b888df8f7632c6d90c4d55ac4cfa2bd4c8c Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 21:03:36 +1300 Subject: [PATCH 16/48] Remove pointless whitespace from copied docstring --- src/attr/_make.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index cfeed8251..e7453c466 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -568,9 +568,15 @@ def _make_cached_property_uncached(original_cached_property_func, cls): doc_lines = [] if doc is not None: doc_lines = doc.splitlines(True) - doc_lines[0] = '"""' + doc_lines[0] - doc_lines.append("\n") - doc_lines.append('"""') + if len(doc_lines) == 1: + doc_lines = ['"""' + doc_lines[0] + '"""'] + else: + doc_lines[0] = '"""' + doc_lines[0] + for i, line in enumerate(doc_lines): + if line: + doc_lines[i] = " " + line + doc_lines.append("") + doc_lines.append(' """') annotation = inspect.signature( original_cached_property_func @@ -602,7 +608,7 @@ def _make_cached_property_uncached(original_cached_property_func, cls): lines = [ "@property", defline, - *(" " + line for line in doc_lines), + *doc_lines, " raise AttributeError('Use __getattr__ instead')", ] unique_filename = _generate_unique_filename( From 3c396262e9bf963a33cddaf0389194626eab8be1 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 21:09:46 +1300 Subject: [PATCH 17/48] Add `test_slots_cached_property_has_multiline_docstring` --- tests/test_slots.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_slots.py b/tests/test_slots.py index aa5e60a6d..96e1fe0eb 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -768,6 +768,31 @@ def f(self): assert A(11).f.__doc__ == "What an informative docstring!" +def test_slots_cached_property_has_multiline_docstring(): + """ + cached_property in slotted class carries the original, multiline docstring + """ + + @attr.s(slots=True) + class A: + x = attr.ib() + + @functools.cached_property + def f(self): + """This function is so well documented, + + I had to put newlines in + + """ + return self.x + + assert A(22).f.__doc__ == """This function is so well documented, + + I had to put newlines in + + """ + + def test_slots_cached_property_class_does_not_have__dict__(): """ From 51798f74bae30fd9e981109fea4b79b7bc4f1b8c Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 21:11:26 +1300 Subject: [PATCH 18/48] Reduce meaningless whitespace even more --- src/attr/_make.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index e7453c466..77bda0f9e 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -573,9 +573,10 @@ def _make_cached_property_uncached(original_cached_property_func, cls): else: doc_lines[0] = '"""' + doc_lines[0] for i, line in enumerate(doc_lines): - if line: - doc_lines[i] = " " + line - doc_lines.append("") + if line.strip(): + doc_lines[i] = " " + line.rstrip() + else: + doc_lines[i] = line.rstrip() doc_lines.append(' """') annotation = inspect.signature( From 2ecd23a69b35b69e4f91df038213eb2ba3e3309e Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 21:22:26 +1300 Subject: [PATCH 19/48] Fix my tests --- tests/test_slots.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/test_slots.py b/tests/test_slots.py index 96e1fe0eb..9a87256e4 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -765,7 +765,7 @@ def f(self): """What an informative docstring!""" return self.x - assert A(11).f.__doc__ == "What an informative docstring!" + assert A.f.__doc__ == "What an informative docstring!" def test_slots_cached_property_has_multiline_docstring(): @@ -786,11 +786,7 @@ def f(self): """ return self.x - assert A(22).f.__doc__ == """This function is so well documented, - - I had to put newlines in - - """ + assert A.f.__doc__ == """This function is so well documented,\n\nI had to put newlines in\n\n""" From fdf24997dd966e5269477c40beffae82e06a4260 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 21:23:03 +1300 Subject: [PATCH 20/48] Fix one-line docstring indentation --- src/attr/_make.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 77bda0f9e..235410939 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -569,7 +569,7 @@ def _make_cached_property_uncached(original_cached_property_func, cls): if doc is not None: doc_lines = doc.splitlines(True) if len(doc_lines) == 1: - doc_lines = ['"""' + doc_lines[0] + '"""'] + doc_lines = [' """' + doc_lines[0] + '"""'] else: doc_lines[0] = '"""' + doc_lines[0] for i, line in enumerate(doc_lines): From 99503ebca5043a2cce3ed82bc6a5be2ea94900a9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 08:24:57 +0000 Subject: [PATCH 21/48] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_slots.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_slots.py b/tests/test_slots.py index e4ffe0751..6fad4a494 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -787,7 +787,10 @@ def f(self): """ return self.x - assert A.f.__doc__ == """This function is so well documented,\n\nI had to put newlines in\n\n""" + assert ( + A.f.__doc__ + == """This function is so well documented,\n\nI had to put newlines in\n\n""" + ) def test_slots_cached_property_class_does_not_have__dict__(): From 4bc5a7d6c8a259ea87bab15518237b95cc298c97 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 21:27:22 +1300 Subject: [PATCH 22/48] Dedent docstring for testing --- tests/test_slots.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_slots.py b/tests/test_slots.py index e4ffe0751..5ff3bd724 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -6,6 +6,7 @@ import functools import pickle +import textwrap import weakref from unittest import mock @@ -787,7 +788,7 @@ def f(self): """ return self.x - assert A.f.__doc__ == """This function is so well documented,\n\nI had to put newlines in\n\n""" + assert textwrap.dedent(A.f.__doc__) == """This function is so well documented,\n\nI had to put newlines in\n\n""" def test_slots_cached_property_class_does_not_have__dict__(): From 0d2721a8e8927f3e655b89b3abb422a9bb589c6f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 08:28:41 +0000 Subject: [PATCH 23/48] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_slots.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_slots.py b/tests/test_slots.py index 5ff3bd724..2760d37b9 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -788,7 +788,10 @@ def f(self): """ return self.x - assert textwrap.dedent(A.f.__doc__) == """This function is so well documented,\n\nI had to put newlines in\n\n""" + assert ( + textwrap.dedent(A.f.__doc__) + == """This function is so well documented,\n\nI had to put newlines in\n\n""" + ) def test_slots_cached_property_class_does_not_have__dict__(): From e4025c2d2fae3bec689a8170aaa4b3f2c185dd98 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 21:31:24 +1300 Subject: [PATCH 24/48] Fix multiline docstring generation --- src/attr/_make.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 235410939..08e9b2f92 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -571,7 +571,7 @@ def _make_cached_property_uncached(original_cached_property_func, cls): if len(doc_lines) == 1: doc_lines = [' """' + doc_lines[0] + '"""'] else: - doc_lines[0] = '"""' + doc_lines[0] + doc_lines[0] = ' """' + doc_lines[0] for i, line in enumerate(doc_lines): if line.strip(): doc_lines[i] = " " + line.rstrip() From 6b4ba4afb052c4c8ac0990ac7b3011d876d14ed3 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 21:39:36 +1300 Subject: [PATCH 25/48] Fix some off-by-one errors copying multiline docstrings --- src/attr/_make.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 08e9b2f92..23bde787b 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -571,8 +571,8 @@ def _make_cached_property_uncached(original_cached_property_func, cls): if len(doc_lines) == 1: doc_lines = [' """' + doc_lines[0] + '"""'] else: - doc_lines[0] = ' """' + doc_lines[0] - for i, line in enumerate(doc_lines): + doc_lines[0] = ' """' + doc_lines[0].rstrip() + for i, line in enumerate(doc_lines[1:], start=1): if line.strip(): doc_lines[i] = " " + line.rstrip() else: From fe0e78ac3d9fbee4a34cb0d6e8fb1c348c2c7369 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 22:07:55 +1300 Subject: [PATCH 26/48] Paper over the differences in newline and indent of docstrings between Python versions --- src/attr/_make.py | 17 +++++++++++------ tests/test_slots.py | 7 +++---- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 23bde787b..c19e2eba7 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -10,6 +10,7 @@ import itertools import linecache import sys +import textwrap import types import unicodedata import weakref @@ -567,17 +568,21 @@ def _make_cached_property_uncached(original_cached_property_func, cls): doc = original_cached_property_func.__doc__ doc_lines = [] if doc is not None: - doc_lines = doc.splitlines(True) + doc_lines = doc.splitlines() if len(doc_lines) == 1: doc_lines = [' """' + doc_lines[0] + '"""'] else: - doc_lines[0] = ' """' + doc_lines[0].rstrip() + line0 = ' """' + doc_lines[0].strip() for i, line in enumerate(doc_lines[1:], start=1): - if line.strip(): - doc_lines[i] = " " + line.rstrip() + line = line.strip() + if line: + doc_lines[i] = " " + line else: - doc_lines[i] = line.rstrip() - doc_lines.append(' """') + doc_lines[i] = "" + if len(doc_lines) > 2 and doc_lines[-2] == doc_lines[-1] == "": + doc_lines = [line0] + doc_lines[1:-1] + [' """'] + else: + doc_lines = [line0] + doc_lines[1:] + [' """'] annotation = inspect.signature( original_cached_property_func diff --git a/tests/test_slots.py b/tests/test_slots.py index 2760d37b9..90b9284fc 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -788,10 +788,9 @@ def f(self): """ return self.x - assert ( - textwrap.dedent(A.f.__doc__) - == """This function is so well documented,\n\nI had to put newlines in\n\n""" - ) + docstring_lines = A.f.__doc__.splitlines() + docstring_lines_dedented = [line.lstrip() for line in docstring_lines] + assert docstring_lines_dedented[:4] == ["This function is so well documented,", "", "I had to put newlines in", ""] def test_slots_cached_property_class_does_not_have__dict__(): From 6550e0d0b361fc447c71368a5f363ede703bb59e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 09:08:24 +0000 Subject: [PATCH 27/48] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/attr/_make.py | 1 - tests/test_slots.py | 8 ++++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index c19e2eba7..55d0b29b3 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -10,7 +10,6 @@ import itertools import linecache import sys -import textwrap import types import unicodedata import weakref diff --git a/tests/test_slots.py b/tests/test_slots.py index 90b9284fc..f03ad7752 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -6,7 +6,6 @@ import functools import pickle -import textwrap import weakref from unittest import mock @@ -790,7 +789,12 @@ def f(self): docstring_lines = A.f.__doc__.splitlines() docstring_lines_dedented = [line.lstrip() for line in docstring_lines] - assert docstring_lines_dedented[:4] == ["This function is so well documented,", "", "I had to put newlines in", ""] + assert docstring_lines_dedented[:4] == [ + "This function is so well documented,", + "", + "I had to put newlines in", + "", + ] def test_slots_cached_property_class_does_not_have__dict__(): From 58a16bc29b4970a8ad8b12357669ca208e3ad953 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 22:12:44 +1300 Subject: [PATCH 28/48] Remove a branch that never ran --- src/attr/_make.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index c19e2eba7..a2eb8361e 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -587,10 +587,9 @@ def _make_cached_property_uncached(original_cached_property_func, cls): annotation = inspect.signature( original_cached_property_func ).return_annotation + assert not isinstance(annotation, str) if annotation is inspect.Parameter.empty: defline = f"def {name}(self):" - elif isinstance(annotation, str): - defline = f"def {name}(self) -> {annotation}:" else: try: import annotationlib From 67e5a64be082a4569a6f1f908221e767e89a67b4 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 22:14:50 +1300 Subject: [PATCH 29/48] Don't bother with annotationlib --- src/attr/_make.py | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index a2eb8361e..fb695890e 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -590,26 +590,16 @@ def _make_cached_property_uncached(original_cached_property_func, cls): assert not isinstance(annotation, str) if annotation is inspect.Parameter.empty: defline = f"def {name}(self):" + elif isinstance( + annotation, + (type, types.FunctionType, types.BuiltinFunctionType), + ): + if annotation.__module__ == "builtins": + defline = f"def {name}(self) -> {annotation.__qualname__}:" + else: + defline = f"def {name}(self) -> {annotation.__module__}.{annotation.__qualname__}:" else: - try: - import annotationlib - - defline = ( - f"def {name}(self) -> {annotationlib.type_repr(annotation)}:" - ) - except ModuleNotFoundError: - if isinstance( - annotation, - (type, types.FunctionType, types.BuiltinFunctionType), - ): - if annotation.__module__ == "builtins": - defline = f"def {name}(self) -> {annotation.__qualname__}:" - else: - defline = f"def {name}(self) -> {annotation.__module__}.{annotation.__qualname__}:" - elif annotation in (..., types.EllipsisType): - defline = f"def {name}(self) -> ...:" - else: - defline = f"def {name}(self) -> {annotation}:" + defline = f"def {name}(self) -> {annotation}:" lines = [ "@property", defline, From 18397560de42ac0b722d24afdec63d5e69964df4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 09:15:21 +0000 Subject: [PATCH 30/48] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/attr/_make.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 0804d5f67..ac6d5c09f 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -590,8 +590,8 @@ def _make_cached_property_uncached(original_cached_property_func, cls): if annotation is inspect.Parameter.empty: defline = f"def {name}(self):" elif isinstance( - annotation, - (type, types.FunctionType, types.BuiltinFunctionType), + annotation, + (type, types.FunctionType, types.BuiltinFunctionType), ): if annotation.__module__ == "builtins": defline = f"def {name}(self) -> {annotation.__qualname__}:" From 1f129b02bd389ce631270ed2c9438c1c7081488d Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 22:25:12 +1300 Subject: [PATCH 31/48] Add `test_slots_cached_property_return_annotation` I haven't worked out why those return annotations only *sometimes* come back as `str`. --- tests/test_slots.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_slots.py b/tests/test_slots.py index f03ad7752..190ff4685 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -5,6 +5,7 @@ """ import functools +import inspect import pickle import weakref @@ -752,6 +753,34 @@ def f(self): assert A(11).f == 11 +def test_slots_cached_property_return_annotation(): + """ + cached_property in slotted class preserves return annotation + """ + + @attr.s(slots=True) + class A: + @functools.cached_property + def f(self) -> int: + return 33 + + return_annotation = inspect.signature(A.f.fget).return_annotation + if isinstance(return_annotation, str): + return_annotation = eval(return_annotation) + assert return_annotation is int + + @attr.s(slots=True) + class B: + @functools.cached_property + def f(self) -> functools.partial: + return functools.partial(print, "Hello, world!") + + return_annotation = inspect.signature(B.f.fget).return_annotation + if isinstance(return_annotation, str): + return_annotation = eval(return_annotation) + assert return_annotation is functools.partial + + def test_slots_cached_property_has_docstring(): """ cached_property in slotted class carries its original docstring From 9efd9122190502afdb7101e7e495de6993a8e06f Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Thu, 19 Feb 2026 22:38:33 +1300 Subject: [PATCH 32/48] Support string return annotation --- src/attr/_make.py | 1 - tests/test_slots.py | 9 +++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index ac6d5c09f..ef32bb191 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -586,7 +586,6 @@ def _make_cached_property_uncached(original_cached_property_func, cls): annotation = inspect.signature( original_cached_property_func ).return_annotation - assert not isinstance(annotation, str) if annotation is inspect.Parameter.empty: defline = f"def {name}(self):" elif isinstance( diff --git a/tests/test_slots.py b/tests/test_slots.py index 190ff4685..5f9a6c094 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -780,6 +780,15 @@ def f(self) -> functools.partial: return_annotation = eval(return_annotation) assert return_annotation is functools.partial + @attr.s(slots=True) + class C: + @functools.cached_property + def f(self) -> "wow": + return "wow" + + return_annotation = inspect.signature(C.f.fget).return_annotation + assert return_annotation == "wow" + def test_slots_cached_property_has_docstring(): """ From e27c582997752aec850699f577a138703a163371 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Fri, 20 Feb 2026 11:02:53 +1300 Subject: [PATCH 33/48] Avoid `getattr_static` in `_make_cached_property_getattr` I guess this will break if the metaclass has `__slots__`? Seems unlikely... --- src/attr/_make.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index ef32bb191..f90243bd2 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -506,7 +506,7 @@ def _make_cached_property_getattr(cached_properties, original_getattr, cls): " func = cached_properties.get(item)", " if func is not None:", " cache = item + '_cache'", - " cached = getattr_static(self, cache)", + " cached = _cls.__dict__[cache]", " try:", " return cached.__get__(self, type(self))", " except AttributeError:", @@ -546,7 +546,6 @@ def _make_cached_property_getattr(cached_properties, original_getattr, cls): "cached_properties": cached_properties, "_cached_setattr_get": _OBJ_SETATTR.__get__, "original_getattr": original_getattr, - "getattr_static": inspect.getattr_static, } return _linecache_and_compile( From fedc668fc97cad221799ffe24d6e7c8b243ec9f2 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Fri, 20 Feb 2026 11:04:54 +1300 Subject: [PATCH 34/48] Use the descriptor's `__set__` method directly in `_make_cached_property_getattr` --- src/attr/_make.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index f90243bd2..7aeb5623a 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -511,8 +511,7 @@ def _make_cached_property_getattr(cached_properties, original_getattr, cls): " return cached.__get__(self, type(self))", " except AttributeError:", " result = func(self)", - " _setter = _cached_setattr_get(self)", - " _setter(cache, result)", + " cached.__set__(self, result)", " return result", ] if original_getattr is not None: From 9d370be15543c951bbffad7336317423c4b6a5c8 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Fri, 20 Feb 2026 11:08:53 +1300 Subject: [PATCH 35/48] Delint `_make_cached_property_uncached` --- src/attr/_make.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 7aeb5623a..35d61aa67 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -570,16 +570,16 @@ def _make_cached_property_uncached(original_cached_property_func, cls): doc_lines = [' """' + doc_lines[0] + '"""'] else: line0 = ' """' + doc_lines[0].strip() - for i, line in enumerate(doc_lines[1:], start=1): - line = line.strip() + for i, raw_line in enumerate(doc_lines[1:], start=1): + line = raw_line.strip() if line: doc_lines[i] = " " + line else: doc_lines[i] = "" if len(doc_lines) > 2 and doc_lines[-2] == doc_lines[-1] == "": - doc_lines = [line0] + doc_lines[1:-1] + [' """'] + doc_lines = [line0, *doc_lines[1:-1], ' """'] else: - doc_lines = [line0] + doc_lines[1:] + [' """'] + doc_lines = [line0, *doc_lines[1:], ' """'] annotation = inspect.signature( original_cached_property_func From 8d7fa5fa5afe7e0ff13174fb43f0da736f7992d2 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Fri, 20 Feb 2026 11:10:16 +1300 Subject: [PATCH 36/48] Use the name of a real class as the return annotation in `test_slots_cached_property_return_annotation` --- tests/test_slots.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_slots.py b/tests/test_slots.py index 5f9a6c094..6b8b3c347 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -783,11 +783,11 @@ def f(self) -> functools.partial: @attr.s(slots=True) class C: @functools.cached_property - def f(self) -> "wow": + def f(self) -> "attr.NOTHING": return "wow" return_annotation = inspect.signature(C.f.fget).return_annotation - assert return_annotation == "wow" + assert return_annotation == "attr.NOTHING" def test_slots_cached_property_has_docstring(): From 393c804121459015125fe220ebbbb378e013c80b Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Fri, 20 Feb 2026 11:16:17 +1300 Subject: [PATCH 37/48] Optimize in `_make_cached_property_getattr` --- src/attr/_make.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 35d61aa67..654bd0b1d 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -508,11 +508,12 @@ def _make_cached_property_getattr(cached_properties, original_getattr, cls): " cache = item + '_cache'", " cached = _cls.__dict__[cache]", " try:", - " return cached.__get__(self, type(self))", + " return cached.__get__(self, _cls)", " except AttributeError:", - " result = func(self)", - " cached.__set__(self, result)", - " return result", + " pass", + " result = func(self)", + " cached.__set__(self, result)", + " return result", ] if original_getattr is not None: lines.append( From 937c1c27035fb2d643fd255a9e5723e452736d7a Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Fri, 20 Feb 2026 12:01:49 +1300 Subject: [PATCH 38/48] Make the generated `@property` really act like a `@cached_property` It seemed to be getting called anyway! May as well! This made it pretty pointless to generate a `__getattr__`, so I've removed `_make_cached_property_getattr` --- src/attr/_make.py | 77 +++++++++-------------------------------------- 1 file changed, 14 insertions(+), 63 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 654bd0b1d..a5bbd3dc5 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -495,64 +495,6 @@ 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:", - " cache = item + '_cache'", - " cached = _cls.__dict__[cache]", - " try:", - " return cached.__get__(self, _cls)", - " except AttributeError:", - " pass", - " result = func(self)", - " cached.__set__(self, 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)", - ] - ) - - lines.extend( - [ - " return __getattr__", - "__getattr__ = wrapper(_cls)", - ] - ) - - unique_filename = _generate_unique_filename(cls, "getattr") - - glob = { - "cached_properties": cached_properties, - "_cached_setattr_get": _OBJ_SETATTR.__get__, - "original_getattr": original_getattr, - } - - return _linecache_and_compile( - "\n".join(lines), unique_filename, glob, locals={"_cls": cls} - )["__getattr__"] - - def _make_cached_property_uncached(original_cached_property_func, cls): """Make an ordinary :deco:`property` to replace a :deco:`cached_property` @@ -585,6 +527,7 @@ def _make_cached_property_uncached(original_cached_property_func, cls): annotation = inspect.signature( original_cached_property_func ).return_annotation + name = original_cached_property_func.__name__ if annotation is inspect.Parameter.empty: defline = f"def {name}(self):" elif isinstance( @@ -601,7 +544,19 @@ def _make_cached_property_uncached(original_cached_property_func, cls): "@property", defline, *doc_lines, - " raise AttributeError('Use __getattr__ instead')", + " for entry in type.__dict__['__mro__'].__get__(self.__class__):", + f" if '{name}_cache' in entry.__dict__:", + f" descriptor = entry.__dict__['{name}_cache']", + " break", + " else:", + " raise AttributeError('No descriptor')", + " try:", + " return descriptor.__get__(self, self.__class__)", + " except AttributeError:", + " pass", + " result = original_cached_property(self)", + " descriptor.__set__(self, result)", + " return result", ] unique_filename = _generate_unique_filename( cls, original_cached_property_func @@ -992,10 +947,6 @@ def _create_slots_class(self): 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. slot_names = [name for name in names if name not in base_names] From 77da557148a3c851c02bde3509e6d4a0f8656109 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 23:02:03 +0000 Subject: [PATCH 39/48] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/attr/_make.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/attr/_make.py b/src/attr/_make.py index a5bbd3dc5..18d974450 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -495,6 +495,7 @@ def _transform_attrs( return _Attributes(AttrsClass(attrs), base_attrs, base_attr_map) + def _make_cached_property_uncached(original_cached_property_func, cls): """Make an ordinary :deco:`property` to replace a :deco:`cached_property` From 4f8f814eba07f95deeb458e43321a8c75da7d564 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Fri, 20 Feb 2026 12:07:40 +1300 Subject: [PATCH 40/48] Remove redundant `name` in `_make_cached_property_uncached` --- src/attr/_make.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index a5bbd3dc5..a3e3655d7 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -527,7 +527,6 @@ def _make_cached_property_uncached(original_cached_property_func, cls): annotation = inspect.signature( original_cached_property_func ).return_annotation - name = original_cached_property_func.__name__ if annotation is inspect.Parameter.empty: defline = f"def {name}(self):" elif isinstance( From 0a09093dbab7a6e2332c12d1caef1cb92d6f67a2 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Fri, 20 Feb 2026 12:09:24 +1300 Subject: [PATCH 41/48] Correct docstring of `_make_cached_property_uncached` --- src/attr/_make.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index a3e3655d7..8d47dca55 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -498,10 +498,9 @@ def _transform_attrs( def _make_cached_property_uncached(original_cached_property_func, cls): """Make an ordinary :deco:`property` to replace a :deco:`cached_property` - This is mainly done for documentation purposes. The generated - :deco:`property` won't be called, because of our custom :meth:`__getattr__`. - We still want it there, so that its docstring is accessible, both to - :function:`help` and to documentation generators, such as Sphinx. + The :deco:`property` will work on slotted instances. We'll make a slot + to keep the result of :func:`original_cached_property_func`, named like it, + with the suffix ``_cached``. """ name = original_cached_property_func.__name__ From 67fb2913756ceba35cab3e0434fe1b40728b4cd5 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Fri, 20 Feb 2026 12:11:05 +1300 Subject: [PATCH 42/48] Delete leftover getattr generation code --- src/attr/_make.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 8d47dca55..ac5af12fd 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -941,10 +941,6 @@ def _create_slots_class(self): class_annotations[name] = annotation cd[name] = _make_cached_property_uncached(func, self._cls) - original_getattr = cd.get("__getattr__") - if original_getattr is not None: - additional_closure_functions_to_update.append(original_getattr) - # We only add the names of attributes that aren't inherited. # Setting __slots__ to inherited attributes wastes memory. slot_names = [name for name in names if name not in base_names] From 4829c5bf3037e286050de28ca731d484988cfa5e Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Fri, 20 Feb 2026 12:26:00 +1300 Subject: [PATCH 43/48] Avoid the MRO loop in `_make_cached_property_uncached` if we can --- src/attr/_make.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index ac5af12fd..808aea385 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -542,12 +542,15 @@ def _make_cached_property_uncached(original_cached_property_func, cls): "@property", defline, *doc_lines, - " for entry in type.__dict__['__mro__'].__get__(self.__class__):", - f" if '{name}_cache' in entry.__dict__:", - f" descriptor = entry.__dict__['{name}_cache']", - " break", - " else:", - " raise AttributeError('No descriptor')", + " clsd = self.__class__.__dict__", + f" descriptor = clsd.get('{name}_cache')", + " if descriptor is None:", + " for entry in type.__dict__['__mro__'].__get__(self.__class__)[1:]:", + f" descriptor = entry.__dict__.get('{name}_cache')", + " if descriptor is not None:", + " break", + " else:", + " raise AttributeError('No descriptor')", " try:", " return descriptor.__get__(self, self.__class__)", " except AttributeError:", From 2391242f33452347d5143714c370f7e6da478355 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Fri, 20 Feb 2026 12:45:20 +1300 Subject: [PATCH 44/48] Trivially optimize `_make_cached_property_uncached` --- src/attr/_make.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 1cc568551..fa6e29cd6 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -543,17 +543,15 @@ def _make_cached_property_uncached(original_cached_property_func, cls): "@property", defline, *doc_lines, - " clsd = self.__class__.__dict__", - f" descriptor = clsd.get('{name}_cache')", + " cls = self.__class__", + f" descriptor = cls.__dict__.get('{name}_cache')", " if descriptor is None:", - " for entry in type.__dict__['__mro__'].__get__(self.__class__)[1:]:", + " for entry in type.__dict__['__mro__'].__get__(cls)[1:]:", f" descriptor = entry.__dict__.get('{name}_cache')", " if descriptor is not None:", " break", - " else:", - " raise AttributeError('No descriptor')", " try:", - " return descriptor.__get__(self, self.__class__)", + " return descriptor.__get__(self, cls)", " except AttributeError:", " pass", " result = original_cached_property(self)", From dd7a45f0845a0976d8b6bc23d4654e2979924abb Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Fri, 20 Feb 2026 13:19:02 +1300 Subject: [PATCH 45/48] Cache the cached property descriptors in a global var --- src/attr/_make.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index fa6e29cd6..15d8e1d93 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -496,6 +496,9 @@ def _transform_attrs( return _Attributes(AttrsClass(attrs), base_attrs, base_attr_map) +_cached_property_descriptors = {} + + def _make_cached_property_uncached(original_cached_property_func, cls): """Make an ordinary :deco:`property` to replace a :deco:`cached_property` @@ -544,11 +547,12 @@ def _make_cached_property_uncached(original_cached_property_func, cls): defline, *doc_lines, " cls = self.__class__", - f" descriptor = cls.__dict__.get('{name}_cache')", + f" descriptor = cached_property_descriptors.get((cls, '{name}'))", " if descriptor is None:", - " for entry in type.__dict__['__mro__'].__get__(cls)[1:]:", + " for entry in type.__dict__['__mro__'].__get__(cls):", f" descriptor = entry.__dict__.get('{name}_cache')", " if descriptor is not None:", + f" cached_property_descriptors[cls, '{name}'] = descriptor", " break", " try:", " return descriptor.__get__(self, cls)", @@ -561,7 +565,8 @@ def _make_cached_property_uncached(original_cached_property_func, cls): unique_filename = _generate_unique_filename( cls, original_cached_property_func ) - glob = {"original_cached_property": original_cached_property_func} + glob = {"original_cached_property": original_cached_property_func, + "cached_property_descriptors": _cached_property_descriptors} return _linecache_and_compile("\n".join(lines), unique_filename, glob)[ name ] From ef378ac06e5c121e55a29ba32e8ab1964f63389c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 00:19:14 +0000 Subject: [PATCH 46/48] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/attr/_make.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 15d8e1d93..2da04271f 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -565,8 +565,10 @@ def _make_cached_property_uncached(original_cached_property_func, cls): unique_filename = _generate_unique_filename( cls, original_cached_property_func ) - glob = {"original_cached_property": original_cached_property_func, - "cached_property_descriptors": _cached_property_descriptors} + glob = { + "original_cached_property": original_cached_property_func, + "cached_property_descriptors": _cached_property_descriptors, + } return _linecache_and_compile("\n".join(lines), unique_filename, glob)[ name ] From 9075541606fbc47f72705ce632e5134479f9f114 Mon Sep 17 00:00:00 2001 From: Zachary Spector Date: Fri, 20 Feb 2026 13:31:55 +1300 Subject: [PATCH 47/48] Cache the cached property *results* in a global var --- src/attr/_make.py | 22 +++++----------------- tests/test_slots.py | 2 +- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 15d8e1d93..3bdc885f2 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -496,7 +496,7 @@ def _transform_attrs( return _Attributes(AttrsClass(attrs), base_attrs, base_attr_map) -_cached_property_descriptors = {} +_cached_property_results = {} def _make_cached_property_uncached(original_cached_property_func, cls): @@ -547,26 +547,16 @@ def _make_cached_property_uncached(original_cached_property_func, cls): defline, *doc_lines, " cls = self.__class__", - f" descriptor = cached_property_descriptors.get((cls, '{name}'))", - " if descriptor is None:", - " for entry in type.__dict__['__mro__'].__get__(cls):", - f" descriptor = entry.__dict__.get('{name}_cache')", - " if descriptor is not None:", - f" cached_property_descriptors[cls, '{name}'] = descriptor", - " break", - " try:", - " return descriptor.__get__(self, cls)", - " except AttributeError:", - " pass", - " result = original_cached_property(self)", - " descriptor.__set__(self, result)", + f" result = cached_property_results.get((cls, '{name}', id(self)), NOTHING)", + " if result is NOTHING:", + f" result = cached_property_results[cls, '{name}', id(self)] = original_cached_property(self)", " return result", ] unique_filename = _generate_unique_filename( cls, original_cached_property_func ) glob = {"original_cached_property": original_cached_property_func, - "cached_property_descriptors": _cached_property_descriptors} + "cached_property_results": _cached_property_results, "NOTHING": NOTHING} return _linecache_and_compile("\n".join(lines), unique_filename, glob)[ name ] @@ -938,8 +928,6 @@ def _create_slots_class(self): 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 + "_cache",) # Clear out function from class to avoid clashing. del cd[name] additional_closure_functions_to_update.append(func) diff --git a/tests/test_slots.py b/tests/test_slots.py index 6b8b3c347..f61f4c3f4 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -848,7 +848,7 @@ class A: def f(self): return self.x - assert set(A.__slots__) == {"x", "f_cache", "__weakref__"} + assert set(A.__slots__) == {"x", "__weakref__"} assert "__dict__" not in dir(A) From 9729fe48b2a305da56c518c930daa2ca02bf5f52 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 00:32:19 +0000 Subject: [PATCH 48/48] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/attr/_make.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 3bdc885f2..eb8f20471 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -555,8 +555,11 @@ def _make_cached_property_uncached(original_cached_property_func, cls): unique_filename = _generate_unique_filename( cls, original_cached_property_func ) - glob = {"original_cached_property": original_cached_property_func, - "cached_property_results": _cached_property_results, "NOTHING": NOTHING} + glob = { + "original_cached_property": original_cached_property_func, + "cached_property_results": _cached_property_results, + "NOTHING": NOTHING, + } return _linecache_and_compile("\n".join(lines), unique_filename, glob)[ name ]