Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
716d219
Replace `@cached_property` with a `@property` for documentation purposes
clayote Feb 19, 2026
a5da435
Fix `test_slots_cached_property_class_does_not_have__dict__`
clayote Feb 19, 2026
a3d1c45
Fix `@cached_property` annotations on py3.14
clayote Feb 19, 2026
9ba38fc
Don't depend on `annotationlib` in `_make._make_cached_property_uncac…
clayote Feb 19, 2026
1eff384
Call into the @cached_property from its uncached @property
clayote Feb 19, 2026
06d1656
Make the generated @property raise AttributeError
clayote Feb 19, 2026
18bdd2c
Correct `test_slots_sub_class_with_actual_slot`
clayote Feb 19, 2026
8a8bd46
Fix repeat cached_property calls
clayote Feb 19, 2026
d126565
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 19, 2026
95766dd
Avoid try-catch block in custom __getattr__
clayote Feb 19, 2026
f2d45b0
Merge remote-tracking branch 'origin/cache_property' into cache_property
clayote Feb 19, 2026
0609b6e
Go back to the try-catch, but call `__get__` directly
clayote Feb 19, 2026
5a9630a
Add `test_slots_cached_property_has_docstring`
clayote Feb 19, 2026
00b0608
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 19, 2026
c4f9313
Add news fragment
clayote Feb 19, 2026
173a3ca
Fix missing colons in `_make_cached_property_uncached`
clayote Feb 19, 2026
c50e8b8
Remove pointless whitespace from copied docstring
clayote Feb 19, 2026
3c39626
Add `test_slots_cached_property_has_multiline_docstring`
clayote Feb 19, 2026
51798f7
Reduce meaningless whitespace even more
clayote Feb 19, 2026
2ecd23a
Fix my tests
clayote Feb 19, 2026
fdf2499
Fix one-line docstring indentation
clayote Feb 19, 2026
6a5daa7
Merge remote-tracking branch 'origin/cache_property' into cache_property
clayote Feb 19, 2026
99503eb
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 19, 2026
4bc5a7d
Dedent docstring for testing
clayote Feb 19, 2026
1b5064f
Merge remote-tracking branch 'origin/cache_property' into cache_property
clayote Feb 19, 2026
0d2721a
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 19, 2026
e4025c2
Fix multiline docstring generation
clayote Feb 19, 2026
ef78167
Merge remote-tracking branch 'origin/cache_property' into cache_property
clayote Feb 19, 2026
6b4ba4a
Fix some off-by-one errors copying multiline docstrings
clayote Feb 19, 2026
fe0e78a
Paper over the differences in newline and indent of docstrings betwee…
clayote Feb 19, 2026
6550e0d
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 19, 2026
58a16bc
Remove a branch that never ran
clayote Feb 19, 2026
67e5a64
Don't bother with annotationlib
clayote Feb 19, 2026
7670522
Merge remote-tracking branch 'origin/cache_property' into cache_property
clayote Feb 19, 2026
1839756
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 19, 2026
1f129b0
Add `test_slots_cached_property_return_annotation`
clayote Feb 19, 2026
aca32a5
Merge remote-tracking branch 'origin/cache_property' into cache_property
clayote Feb 19, 2026
9efd912
Support string return annotation
clayote Feb 19, 2026
e27c582
Avoid `getattr_static` in `_make_cached_property_getattr`
clayote Feb 19, 2026
fedc668
Use the descriptor's `__set__` method directly in `_make_cached_prope…
clayote Feb 19, 2026
9d370be
Delint `_make_cached_property_uncached`
clayote Feb 19, 2026
8d7fa5f
Use the name of a real class as the return annotation in `test_slots_…
clayote Feb 19, 2026
393c804
Optimize in `_make_cached_property_getattr`
clayote Feb 19, 2026
937c1c2
Make the generated `@property` really act like a `@cached_property`
clayote Feb 19, 2026
77da557
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 19, 2026
4f8f814
Remove redundant `name` in `_make_cached_property_uncached`
clayote Feb 19, 2026
0a09093
Correct docstring of `_make_cached_property_uncached`
clayote Feb 19, 2026
67fb291
Delete leftover getattr generation code
clayote Feb 19, 2026
4829c5b
Avoid the MRO loop in `_make_cached_property_uncached` if we can
clayote Feb 19, 2026
ab9de61
Merge remote-tracking branch 'origin/cache_property' into cache_property
clayote Feb 19, 2026
2391242
Trivially optimize `_make_cached_property_uncached`
clayote Feb 19, 2026
dd7a45f
Cache the cached property descriptors in a global var
clayote Feb 20, 2026
ef378ac
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 20, 2026
9075541
Cache the cached property *results* in a global var
clayote Feb 20, 2026
35e023f
Merge remote-tracking branch 'origin/cache_property' into cache_property
clayote Feb 20, 2026
9729fe4
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/cache_slot.change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed docstrings for @cached_property of slotted classes
118 changes: 63 additions & 55 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,56 +496,73 @@ 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)",
]
)
_cached_property_results = {}

lines.extend(
[
" return __getattr__",
"__getattr__ = wrapper(_cls)",
]
)

unique_filename = _generate_unique_filename(cls, "getattr")
def _make_cached_property_uncached(original_cached_property_func, cls):
"""Make an ordinary :deco:`property` to replace a :deco:`cached_property`

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__
doc = original_cached_property_func.__doc__
doc_lines = []
if doc is not None:
doc_lines = doc.splitlines()
if len(doc_lines) == 1:
doc_lines = [' """' + doc_lines[0] + '"""']
else:
line0 = ' """' + doc_lines[0].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], ' """']
else:
doc_lines = [line0, *doc_lines[1:], ' """']

annotation = inspect.signature(
original_cached_property_func
).return_annotation
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:
defline = f"def {name}(self) -> {annotation}:"
lines = [
"@property",
defline,
*doc_lines,
" cls = self.__class__",
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 = {
"cached_properties": cached_properties,
"_cached_setattr_get": _OBJ_SETATTR.__get__,
"original_getattr": original_getattr,
"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, locals={"_cls": cls}
)["__getattr__"]
return _linecache_and_compile("\n".join(lines), unique_filename, glob)[
name
]


def _frozen_setattrs(self, name, value):
Expand Down Expand Up @@ -914,22 +931,13 @@ 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,)
# 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
)
cd[name] = _make_cached_property_uncached(func, self._cls)

# We only add the names of attributes that aren't inherited.
# Setting __slots__ to inherited attributes wastes memory.
Expand Down
87 changes: 85 additions & 2 deletions tests/test_slots.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

import functools
import inspect
import pickle
import weakref

Expand Down Expand Up @@ -752,6 +753,88 @@ 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

@attr.s(slots=True)
class C:
@functools.cached_property
def f(self) -> "attr.NOTHING":
return "wow"

return_annotation = inspect.signature(C.f.fget).return_annotation
assert return_annotation == "attr.NOTHING"


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.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

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__():
"""
slotted class with cached property has no __dict__ attribute.
Expand All @@ -765,7 +848,7 @@ class A:
def f(self):
return self.x

assert set(A.__slots__) == {"x", "f", "__weakref__"}
assert set(A.__slots__) == {"x", "__weakref__"}
assert "__dict__" not in dir(A)


Expand Down Expand Up @@ -1055,7 +1138,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():
Expand Down
Loading