Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5977f32
Add a test for the @cached_property hack
clayote Feb 21, 2026
290a4c1
Write a Sphinx extension to document `@cached_property` on slots classes
clayote Feb 21, 2026
65b69bb
Add news snippet
clayote Feb 21, 2026
fc5fa55
Fix pyproject.toml
clayote Feb 22, 2026
9e38b57
Add a test for Sphinx's autodoc `:members:`
clayote Feb 22, 2026
b300d17
Add a weird hack to make Sphinx pretend slots for cached props aren't…
clayote Feb 22, 2026
cb6303a
Add `__eq__` and `__ne__` to `_TupleProxy`
clayote Feb 22, 2026
cd82290
Write docstrings for tests
clayote Feb 22, 2026
01a7175
Add docstring to sphinx_cached_property.py
clayote Feb 22, 2026
179d4f3
"Implement" `_TupleProxy.__hash__`
clayote Feb 22, 2026
36d55e8
Make the `tests` dependency group depend on the `docs` one
clayote Feb 22, 2026
c9f1ee8
Add `test_tuple_proxy`
clayote Feb 22, 2026
de84719
Delete `_TupleProxy.__ne__`
clayote Feb 22, 2026
50620ce
Add equality assertion to `test_tuple_proxy`
clayote Feb 22, 2026
640396f
Put some stuff in the tuples for `test_tuple_proxy`
clayote Feb 22, 2026
09a1e34
Test hashes in `test_tuple_proxy`
clayote Feb 22, 2026
d2fd6f5
Document `sphinx_cached_property` in `how-does-it-work.md`
clayote Feb 24, 2026
2fe7543
Remove the :mod: role from "Sphinx" in how-does-it-work.md
clayote Feb 25, 2026
1f5f244
Improve module docstring in `sphinx_cached_property.py`
clayote Feb 25, 2026
987e7cc
Update changelog.d/1519.change.md
clayote Feb 25, 2026
4ff3583
Remove `@need_sphinx` from test_slots.py
clayote Feb 25, 2026
6ed25f3
Use pytest's `tmp_path` fixture for Sphinx tests
clayote Feb 25, 2026
64b28ba
Make Sphinx tests more elegant with `read_text`
clayote Feb 25, 2026
e8e60d6
Use shutil to make Sphinx tests more elegant in test_slots.py
clayote Feb 25, 2026
9a02092
Remove the now-pointless import guard around Sphinx in test_slots.py
clayote Feb 25, 2026
ec89dd2
Report attrs version in `attrs.sphinx_cached_property`
clayote Mar 4, 2026
fdccaa5
Add docstrings to `sphinx_cached_property.py`
clayote Mar 4, 2026
64e8291
Include Sphinx &c in uv.lock
clayote Mar 6, 2026
6a690d5
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 6, 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
46 changes: 41 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,34 @@ def __reduce__(self, _none_constructor=type(None), _args=()): # noqa: B008
return _none_constructor, _args


class _TupleProxy(Sequence):
__slots__ = ("_tup",)
"""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.

"""

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 +931,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 +940,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 +959,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 +985,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
38 changes: 38 additions & 0 deletions src/attrs/sphinx_cached_property.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# 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

from . import __version__


def get_cached_property_for_member_descriptor(
cls: type, name: str, default=None
):
"""If the attribute is for a cached property, return the ``cached_property``

Otherwise, delegate to normal ``getattr``

"""
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):
"""Install the special attribute getter for cached properties of slotted classes"""
app.add_autodoc_attrgetter(
object, get_cached_property_for_member_descriptor
)
return {
"version": __version__,
"parallel_read_safe": True,
"parallel_write_safe": True,
}
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:
77 changes: 77 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,75 @@ 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)
with (
outdir.joinpath("index.txt").open("r") as written,
here.joinpath("index.txt").open("r") as good,
):
assert written.read() == good.read()


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