Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
6f18ee3
Add a test for the @cached_property hack
clayote Feb 21, 2026
f4c12de
Write a Sphinx extension to document `@cached_property` on slots classes
clayote Feb 21, 2026
4dacd2c
Add news snippet
clayote Feb 21, 2026
47ffde5
Fix pyproject.toml
clayote Feb 22, 2026
221a0ac
Add a test for Sphinx's autodoc `:members:`
clayote Feb 22, 2026
0dc4bf2
Add a weird hack to make Sphinx pretend slots for cached props aren't…
clayote Feb 22, 2026
b8a0bfa
Add `__eq__` and `__ne__` to `_TupleProxy`
clayote Feb 22, 2026
6ba8d9a
Write docstrings for tests
clayote Feb 22, 2026
b5be48d
Add docstring to sphinx_cached_property.py
clayote Feb 22, 2026
d39bf70
"Implement" `_TupleProxy.__hash__`
clayote Feb 22, 2026
876be7a
Make the `tests` dependency group depend on the `docs` one
clayote Feb 22, 2026
1a5d11f
Add `test_tuple_proxy`
clayote Feb 22, 2026
4ae5ce1
Delete `_TupleProxy.__ne__`
clayote Feb 22, 2026
49d7b8b
Add equality assertion to `test_tuple_proxy`
clayote Feb 22, 2026
7fd2ac3
Put some stuff in the tuples for `test_tuple_proxy`
clayote Feb 22, 2026
7d4206a
Test hashes in `test_tuple_proxy`
clayote Feb 22, 2026
c550aa1
Document `sphinx_cached_property` in `how-does-it-work.md`
clayote Feb 24, 2026
8bb6660
Remove the :mod: role from "Sphinx" in how-does-it-work.md
clayote Feb 25, 2026
6e25315
Improve module docstring in `sphinx_cached_property.py`
clayote Feb 25, 2026
fe06e2f
Update changelog.d/1519.change.md
clayote Feb 25, 2026
fef1adf
Remove `@need_sphinx` from test_slots.py
clayote Feb 25, 2026
7d3165b
Use pytest's `tmp_path` fixture for Sphinx tests
clayote Feb 25, 2026
8222d2c
Make Sphinx tests more elegant with `read_text`
clayote Feb 25, 2026
699d1bc
Use shutil to make Sphinx tests more elegant in test_slots.py
clayote Feb 25, 2026
28da483
Remove the now-pointless import guard around Sphinx in test_slots.py
clayote Feb 25, 2026
8bb2fea
Report attrs version in `attrs.sphinx_cached_property`
clayote Mar 4, 2026
522ff70
Add docstrings to `sphinx_cached_property.py`
clayote Mar 4, 2026
7cf6ab4
Include Sphinx &c in uv.lock
clayote Mar 6, 2026
1008e7f
Fix `_TupleProxy` docstring
clayote Mar 7, 2026
4b432c3
Put the Sphinx test data in its own subdir
clayote Mar 7, 2026
a1f6d3a
Add type hints to `sphinx_cached_property.py`
clayote Mar 7, 2026
260eac3
Fix Python 3.9-incompatible union syntax
clayote Mar 8, 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 @@ -911,7 +940,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 @@ -920,8 +949,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 @@ -936,7 +968,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 @@ -957,7 +994,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
43 changes: 43 additions & 0 deletions src/attrs/sphinx_cached_property.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# 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 __future__ import annotations

from functools import cached_property
from typing import Any

from sphinx.application import Sphinx

from . import __version__


def get_cached_property_for_member_descriptor(
cls: type, name: str, default=None
) -> cached_property | Any:
"""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) -> dict[str, str | bool]:
"""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/doctest_data/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/doctest_data/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/doctest_data/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 / "doctest_data"
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.parent / "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 / "doctest_data"
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.parent / "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