diff --git a/changelog.d/1532.change.md b/changelog.d/1532.change.md new file mode 100644 index 000000000..3ee177eac --- /dev/null +++ b/changelog.d/1532.change.md @@ -0,0 +1 @@ +Slotted classes now expose their cached properties as a public `__attrs_cached_properties__` class attribute: a dict that maps the names of the `cached_property` functions defined on the class to the original functions. diff --git a/docs/extending.md b/docs/extending.md index 4aa195b3a..39e4b10d2 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -39,6 +39,36 @@ f = a(b(original_f)) ``` ::: +## Cached Properties + +Every slotted class created by *attrs* has a `__attrs_cached_properties__` class attribute. +It's a plain dict that maps the names of the `cached_property` functions defined on the class to the original functions. + +It is useful for tooling that needs to introspect cached properties without evaluating them — for example, to document them. + +```{doctest} +>>> from attrs import define +>>> from functools import cached_property +>>> @define +... class C: +... a: int +... +... @cached_property +... def b(self): +... return self.a * 2 +>>> C.__attrs_cached_properties__ #doctest: +ELLIPSIS +{'b': } +``` + +The mapping only contains the cached properties defined on the class itself — inherited ones are not included. +To find all cached properties of a class, walk its `__mro__` and merge the mappings. +Non-slotted classes don't have this attribute. + +:::{note} +The mapping is the same dict that *attrs* uses internally to implement cached properties on slotted classes. +It contains the original functions, so accessing it never evaluates them. +::: + ## Wrapping the Decorator diff --git a/src/attr/_make.py b/src/attr/_make.py index afbca4635..acca81419 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -918,6 +918,12 @@ def _create_slots_class(self): if isinstance(cached_prop, cached_property) } + # Expose the cached properties mapping as a public API. + # It maps the names of the cached properties defined on this class to + # their original functions and is used by the generated `__getattr__` + # below. Inherited cached properties are not included. + cd["__attrs_cached_properties__"] = cached_properties + # Collect methods with a `__class__` reference that are shadowed in the new class. # To know to update them. additional_closure_functions_to_update = [] diff --git a/tests/test_slots.py b/tests/test_slots.py index a74c32b03..0bc3d1eed 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -1143,6 +1143,105 @@ def f_2(self): assert obj.f_2 == 2 +def test_slots_cached_properties_exposed_publicly(): + """ + Slotted classes expose their cached properties as a mapping of names to + the original functions via `__attrs_cached_properties__`. + """ + + def f_impl(self): + return self.x + + @attr.s(slots=True) + class A: + x = attr.ib() + f = functools.cached_property(f_impl) + + assert A.__attrs_cached_properties__ == {"f": f_impl} + assert A.__attrs_cached_properties__["f"] is f_impl + + +def test_slots_cached_properties_exposure_does_not_evaluate(): + """ + Accessing `__attrs_cached_properties__` must not evaluate the cached + properties. + """ + call_count = 0 + + @attr.s(slots=True) + class A: + x = attr.ib() + + @functools.cached_property + def f(self): + nonlocal call_count + call_count += 1 + return self.x + + assert A.__attrs_cached_properties__["f"].__name__ == "f" + assert call_count == 0 + + A(1) + assert call_count == 0 + + +def test_slots_cached_properties_empty_for_slotted_without(): + """ + Slotted classes without cached properties have an empty + `__attrs_cached_properties__` mapping. + """ + + @attr.s(slots=True) + class A: + x = attr.ib() + + assert A.__attrs_cached_properties__ == {} + + +def test_slots_cached_properties_not_on_non_slotted(): + """ + Non-slotted classes don't get a `__attrs_cached_properties__` attribute. + """ + + @attr.s(slots=False) + class A: + x = attr.ib() + + @functools.cached_property + def f(self): + return self.x + + assert not hasattr(A, "__attrs_cached_properties__") + + +def test_slots_cached_properties_own_only(): + """ + `__attrs_cached_properties__` only contains the cached properties defined + on the class itself, not inherited ones. + """ + + @attr.s(slots=True) + class A: + x = attr.ib() + + @functools.cached_property + def f(self): + return self.x + + @attr.s(slots=True) + class B(A): + @functools.cached_property + def g(self): + return self.x * 2 + + f = A.__attrs_cached_properties__["f"] + g = B.__attrs_cached_properties__["g"] + + assert A.__attrs_cached_properties__ == {"f": f} + assert B.__attrs_cached_properties__ == {"g": g} + assert "f" not in B.__attrs_cached_properties__ + + @attr.s(slots=True) class A: x = attr.ib()