Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
a9a950a
Add a test for the @cached_property hack
clayote Feb 21, 2026
7631c09
Write a Sphinx extension to document `@cached_property` on slots classes
clayote Feb 21, 2026
86c1797
Add news snippet
clayote Feb 21, 2026
ead3014
Fix pyproject.toml
clayote Feb 22, 2026
a5c818c
Add a test for Sphinx's autodoc `:members:`
clayote Feb 22, 2026
8a03755
Add a weird hack to make Sphinx pretend slots for cached props aren't…
clayote Feb 22, 2026
d4fc5e8
Add `__eq__` and `__ne__` to `_TupleProxy`
clayote Feb 22, 2026
70986ae
Write docstrings for tests
clayote Feb 22, 2026
c1071fb
Add docstring to sphinx_cached_property.py
clayote Feb 22, 2026
b18b244
uv lock??
clayote Feb 22, 2026
23ab91a
"Implement" `_TupleProxy.__hash__`
clayote Feb 22, 2026
5d5f3dd
Make the `tests` dependency group depend on the `docs` one
clayote Feb 22, 2026
be238d2
uv lock again...
clayote Feb 22, 2026
7c17102
Add `test_tuple_proxy`
clayote Feb 22, 2026
edc5d65
format
clayote Feb 22, 2026
af65c08
Delete `_TupleProxy.__ne__`
clayote Feb 22, 2026
25af636
Add equality assertion to `test_tuple_proxy`
clayote Feb 22, 2026
46e2047
Put some stuff in the tuples for `test_tuple_proxy`
clayote Feb 22, 2026
3f1a6b1
Test hashes in `test_tuple_proxy`
clayote Feb 22, 2026
a31031b
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 22, 2026
9ee0dd9
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 23, 2026
a52d6bb
Document `sphinx_cached_property` in `how-does-it-work.md`
clayote Feb 24, 2026
7a8fa7f
Remove the :mod: role from "Sphinx" in how-does-it-work.md
clayote Feb 25, 2026
9c47711
Improve module docstring in `sphinx_cached_property.py`
clayote Feb 25, 2026
8e2ebec
Update changelog.d/1519.change.md
clayote Feb 25, 2026
56e51de
Put the docstring to `_TupleProxy` on the actual class
clayote Feb 25, 2026
b4a09fe
Remove `@need_sphinx` from test_slots.py
clayote Feb 25, 2026
568502e
Use pytest's `tmp_path` fixture for Sphinx tests
clayote Feb 25, 2026
16e1e31
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 25, 2026
0208f11
Use pytest's `tmp_path` fixture for Sphinx tests
clayote Feb 25, 2026
e4a7064
Make Sphinx tests more elegant with `read_text`
clayote Feb 25, 2026
39ae15b
Use shutil to make Sphinx tests more elegant in test_slots.py
clayote Feb 25, 2026
9d54cff
Remove the now-pointless import guard around Sphinx in test_slots.py
clayote Feb 25, 2026
2924597
Merge remote-tracking branch 'origin/sphinx-attr-getter-ext' into sph…
clayote Feb 25, 2026
2281e4b
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 25, 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
9 changes: 9 additions & 0 deletions changelog.d/1519.change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Added a Sphinx extension to show cached properties on slots classes.

To use it, append `'attrs.sphinx_cached_property'` to the `extensions` in your Sphinx configuration module:

```python
# docs/conf.py

extensions += ['attrs.sphinx_cached_property']
```
1 change: 1 addition & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"sphinx.ext.todo",
"notfound.extension",
"sphinxcontrib.towncrier",
"attrs.sphinx_cached_property",
]

myst_enable_extensions = [
Expand Down
3 changes: 3 additions & 0 deletions docs/how-does-it-work.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ Getting this working is achieved by:
* Adding a `__getattr__` method to set values on the wrapped methods.

For most users, this should mean that it works transparently.
However, the docstring for the wrapped function is inaccessible.
If you need it for your documentation, you can use the bundled Sphinx extension.
Add `"attrs.sphinx_cached_property"` to the `extensions` list in your Sphinx `conf.py`.

:::{note}
The implementation does not guarantee that the wrapped method is called only once in multi-threaded usage.
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ tests = [
"pympler",
"pytest",
"pytest-xdist[psutil]",
{ include-group = "docs" }
]
cov = [{ include-group = "tests" }, "coverage[toml]"]
pyright = ["pyright", { include-group = "tests" }]
Expand Down
47 changes: 42 additions & 5 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import unicodedata
import weakref

from collections.abc import Callable, Mapping
from collections.abc import Callable, Mapping, Sequence
from functools import cached_property
from typing import Any, NamedTuple, TypeVar

Expand Down Expand Up @@ -103,6 +103,35 @@ def __reduce__(self, _none_constructor=type(None), _args=()): # noqa: B008
return _none_constructor, _args


class _TupleProxy(Sequence):
"""A wrapper for a tuple that makes it not type-check as a tuple

This is a hack to make Sphinx document all cached properties on slots
classes as if they were regular properties.

"""

__slots__ = ("_tup",)

def __init__(self, tup: tuple):
self._tup = tup

def __iter__(self):
return iter(self._tup)

def __len__(self):
return len(self._tup)

def __getitem__(self, item):
return self._tup[item]

def __eq__(self, other):
return self._tup == other

def __hash__(self):
return hash(self._tup)


def attrib(
default=NOTHING,
validator=None,
Expand Down Expand Up @@ -903,7 +932,7 @@ def _create_slots_class(self):
names += ("__weakref__",)

cached_properties = {
name: cached_prop.func
name: cached_prop
for name, cached_prop in cd.items()
if isinstance(cached_prop, cached_property)
}
Expand All @@ -912,8 +941,11 @@ def _create_slots_class(self):
# To know to update them.
additional_closure_functions_to_update = []
if cached_properties:
# Store cached property functions for the autodoc extension to read
cd["__attrs_cached_properties__"] = cached_properties
class_annotations = _get_annotations(self._cls)
for name, func in cached_properties.items():
for name, prop in cached_properties.items():
func = prop.func
# Add cached properties to names for slotting.
names += (name,)
# Clear out function from class to avoid clashing.
Expand All @@ -928,7 +960,12 @@ def _create_slots_class(self):
additional_closure_functions_to_update.append(original_getattr)

cd["__getattr__"] = _make_cached_property_getattr(
cached_properties, original_getattr, self._cls
{
name: prop.func
for (name, prop) in cached_properties.items()
},
original_getattr,
self._cls,
)

# We only add the names of attributes that aren't inherited.
Expand All @@ -949,7 +986,7 @@ def _create_slots_class(self):
if self._cache_hash:
slot_names.append(_HASH_CACHE_FIELD)

cd["__slots__"] = tuple(slot_names)
cd["__slots__"] = _TupleProxy(tuple(slot_names))

cd["__qualname__"] = self._cls.__qualname__

Expand Down
25 changes: 25 additions & 0 deletions src/attrs/sphinx_cached_property.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# SPDX-License-Identifier: MIT
"""A Sphinx extension to document cached properties on slots classes

Add ``"attrs.sphinx_cached_property"`` to the ``extensions`` list in Sphinx's
conf.py to use this. Otherwise, cached properties of ``@define(slots=True)``
classes will be inaccessible.

"""

from sphinx.application import Sphinx


def get_cached_property_for_member_descriptor(
cls: type, name: str, default=None
):
props = getattr(cls, "__attrs_cached_properties__", None)
if props is None or name not in props:
return getattr(cls, name, default)
return props[name]


def setup(app: Sphinx):
app.add_autodoc_attrgetter(
object, get_cached_property_for_member_descriptor
)
3 changes: 3 additions & 0 deletions tests/explicit-autoproperty-cached.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.. autoclass:: tests.test_slots.SphinxDocTest

.. autoproperty:: documented
7 changes: 7 additions & 0 deletions tests/index.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class tests.test_slots.SphinxDocTest

Test that slotted cached_property shows up in Sphinx docs

property documented

A very well documented function
2 changes: 2 additions & 0 deletions tests/members-cached-property.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.. autoclass:: tests.test_slots.SphinxDocTest
:members:
76 changes: 76 additions & 0 deletions tests/test_slots.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,24 @@

import functools
import pickle
import shutil
import weakref

from itertools import zip_longest
from pathlib import Path
from unittest import mock

import hypothesis.strategies as st
import pytest

from hypothesis import given
from sphinx.application import Sphinx

import attr
import attrs

from attr._compat import PY_3_14_PLUS, PYPY
from attr._make import _TupleProxy


# Pympler doesn't work on PyPy.
Expand Down Expand Up @@ -736,6 +744,74 @@ def f(self):
assert B(17).f == 289


@given(st.tuples(st.integers() | st.text() | st.floats()))
def test_tuple_proxy(t):
"""
The `_TupleProxy` class acts just like a normal tuple, but isn't one

It's not a tuple for the purposes of :func:`isinstance` and that's about it
"""
prox = _TupleProxy(t)
assert len(t) == len(prox)
for a, b in zip_longest(t, prox):
assert a is b
for i in range(len(prox)):
assert t[i] is prox[i]
assert t == prox
assert hash(t) == hash(prox)
assert not isinstance(prox, tuple)


@attr.s(slots=True)
class SphinxDocTest:
"""Test that slotted cached_property shows up in Sphinx docs"""

@functools.cached_property
def documented(self):
"""A very well documented function"""
return True


def test_sphinx_autodocuments_cached_property(tmp_path):
"""
Sphinx can generate autodocs for cached properties in slots classes
"""
here = Path(__file__).parent
rst = here.joinpath("explicit-autoproperty-cached.rst")
index = tmp_path.joinpath("index.rst")
shutil.copy(rst, index)
outdir = tmp_path.joinpath("docs")
outdir.mkdir()
app = Sphinx(
tmp_path, here.parent.joinpath("docs"), outdir, tmp_path, "text"
)
app.build(force_all=True)
assert (
outdir.joinpath("index.txt").read_text()
== here.joinpath("index.txt").read_text()
)


def test_sphinx_automembers_cached_property(tmp_path):
"""
Sphinx can find cached properties in the :members: of slots classes
"""
here = Path(__file__).parent
rst = here.joinpath("members-cached-property.rst")
index = tmp_path.joinpath("index.rst")
shutil.copy(rst, index)
outdir = tmp_path.joinpath("docs")
outdir.mkdir()
app = Sphinx(
tmp_path, here.parent.joinpath("docs"), outdir, tmp_path, "text"
)
app.build(force_all=True)
assert (
outdir.joinpath("index.txt").read_text()
== here.joinpath("index.txt").read_text()
)


def test_slots_cached_property_allows_call():
"""
cached_property in slotted class allows call.
Expand Down
Loading
Loading