diff --git a/CHANGES/200.feature.rst b/CHANGES/200.feature.rst new file mode 100644 index 0000000..d1eb9c4 --- /dev/null +++ b/CHANGES/200.feature.rst @@ -0,0 +1,7 @@ +Added :class:`~propcache.api.under_cached_property_with_name`, a variant of +:class:`~propcache.api.under_cached_property` that stores cached values in +a caller-chosen attribute instead of the hard-coded ``_cache``. A +:class:`~propcache.api.under_cache_name` decorator factory is also provided +for binding a cache attribute name once and reusing it across multiple +property declarations, which makes the new descriptor practical to use on +classes that define ``__slots__``. diff --git a/docs/api.rst b/docs/api.rst index fd4692a..78d8037 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -54,3 +54,47 @@ under_cached_property instance.clear_cache() print(instance.calculated_data) # expensive operation + +under_cached_property_with_name +=============================== + +.. decorator:: under_cached_property_with_name(func, cache_name) + + Like :func:`under_cached_property`, but the cache attribute name is + configurable. The cached value is stored under + ``getattr(instance, cache_name)[func.__name__]`` instead of the + hard-coded ``_cache``. + + This is useful when working with classes that define ``__slots__`` and + need to use a different attribute name, or when an instance maintains + several independent cache buckets. + + The named attribute must already exist on the instance (typically + initialized to an empty ``dict``) before the first attribute access. + +under_cache_name +================ + +.. decorator:: under_cache_name(cache_name) + + Decorator factory that binds a ``cache_name`` once and returns a + reusable decorator. Calling the resulting object on a method produces + an :func:`under_cached_property_with_name` descriptor wired to that + cache attribute. + + Example:: + + from propcache.api import under_cache_name + + reify = under_cache_name("_my_cache") + + class MyTool: + __slots__ = ("i", "_my_cache") + + def __init__(self, i: int) -> None: + self.i = i + self._my_cache = {} + + @reify + def cached_item(self) -> int: + return self.i + 10 diff --git a/src/propcache/__init__.py b/src/propcache/__init__.py index bbfd78a..083971f 100644 --- a/src/propcache/__init__.py +++ b/src/propcache/__init__.py @@ -2,7 +2,12 @@ from typing import TYPE_CHECKING -_PUBLIC_API = ("cached_property", "under_cached_property") +_PUBLIC_API = ( + "cached_property", + "under_cache_name", + "under_cached_property", + "under_cached_property_with_name", +) __version__ = "0.5.2" __all__ = () @@ -11,7 +16,11 @@ # This module is now a facade for the API. if TYPE_CHECKING: from .api import cached_property as cached_property # noqa: F401 + from .api import under_cache_name as under_cache_name # noqa: F401 from .api import under_cached_property as under_cached_property # noqa: F401 + from .api import ( # noqa: F401 + under_cached_property_with_name as under_cached_property_with_name, + ) def _import_facade(attr: str) -> object: diff --git a/src/propcache/_helpers.py b/src/propcache/_helpers.py index 1e52895..54690fd 100644 --- a/src/propcache/_helpers.py +++ b/src/propcache/_helpers.py @@ -2,7 +2,12 @@ import sys from typing import TYPE_CHECKING -__all__ = ("cached_property", "under_cached_property") +__all__ = ( + "cached_property", + "under_cached_property", + "under_cached_property_with_name", + "under_cache_name", +) NO_EXTENSIONS = bool(os.environ.get("PROPCACHE_NO_EXTENSIONS")) # type: bool @@ -13,27 +18,51 @@ # isort: off if TYPE_CHECKING: from ._helpers_py import cached_property as cached_property_py + from ._helpers_py import under_cache_name as under_cache_name_py from ._helpers_py import under_cached_property as under_cached_property_py + from ._helpers_py import ( + under_cached_property_with_name as under_cached_property_with_name_py, + ) cached_property = cached_property_py under_cached_property = under_cached_property_py + under_cached_property_with_name = under_cached_property_with_name_py + under_cache_name = under_cache_name_py elif not NO_EXTENSIONS: # pragma: no branch try: from ._helpers_c import cached_property as cached_property_c # type: ignore[attr-defined, unused-ignore] + from ._helpers_c import under_cache_name as under_cache_name_c # type: ignore[attr-defined, unused-ignore] from ._helpers_c import under_cached_property as under_cached_property_c # type: ignore[attr-defined, unused-ignore] + from ._helpers_c import ( + under_cached_property_with_name as under_cached_property_with_name_c, + ) # type: ignore[attr-defined, unused-ignore] cached_property = cached_property_c under_cached_property = under_cached_property_c + under_cached_property_with_name = under_cached_property_with_name_c + under_cache_name = under_cache_name_c except ImportError: # pragma: no cover from ._helpers_py import cached_property as cached_property_py + from ._helpers_py import under_cache_name as under_cache_name_py from ._helpers_py import under_cached_property as under_cached_property_py + from ._helpers_py import ( + under_cached_property_with_name as under_cached_property_with_name_py, + ) cached_property = cached_property_py # type: ignore[assignment, misc] under_cached_property = under_cached_property_py + under_cached_property_with_name = under_cached_property_with_name_py + under_cache_name = under_cache_name_py else: from ._helpers_py import cached_property as cached_property_py + from ._helpers_py import under_cache_name as under_cache_name_py from ._helpers_py import under_cached_property as under_cached_property_py + from ._helpers_py import ( + under_cached_property_with_name as under_cached_property_with_name_py, + ) cached_property = cached_property_py # type: ignore[assignment, misc] under_cached_property = under_cached_property_py + under_cached_property_with_name = under_cached_property_with_name_py + under_cache_name = under_cache_name_py # isort: on diff --git a/src/propcache/_helpers_c.pyx b/src/propcache/_helpers_c.pyx index 9e9e558..c699a8a 100644 --- a/src/propcache/_helpers_c.pyx +++ b/src/propcache/_helpers_c.pyx @@ -56,6 +56,68 @@ cdef class under_cached_property: __class_getitem__ = classmethod(GenericAlias) +cdef class under_cached_property_with_name: + """Like ``under_cached_property`` but reads the cache from a named attribute. + + The default ``under_cached_property`` always reads/writes ``inst._cache``. + This variant lets the caller pick a different attribute name, which is + useful for classes that use ``__slots__`` (where ``_cache`` would clash + with a slot of the same name on subclasses), or for classes that want to + partition cached values into multiple buckets. + + The named attribute must be a mutable mapping (typically a ``dict``) + and must exist on the instance before the first attribute access. + """ + + cdef readonly object wrapped + cdef object name + cdef readonly str cache_name + + def __init__(self, object wrapped, str cache_name): + self.wrapped = wrapped + self.name = wrapped.__name__ + self.cache_name = cache_name + + @property + def __doc__(self): + return self.wrapped.__doc__ + + def __get__(self, object inst, owner): + if inst is None: + return self + cdef dict cache = getattr(inst, self.cache_name) + cdef PyObject* val = PyDict_GetItem(cache, self.name) + if val == NULL: + val = PyObject_CallOneArg(self.wrapped, inst) + PyDict_SetItem(cache, self.name, val) + Py_DECREF(val) + return val + + def __set__(self, inst, value): + raise AttributeError("cached property is read-only") + + __class_getitem__ = classmethod(GenericAlias) + + +cdef class under_cache_name: + """Decorator factory binding a cache attribute name. + + Calling an instance with a function returns an + ``under_cached_property_with_name`` configured to store its value in the + pre-bound attribute. + """ + + cdef readonly str cache_name + + def __init__(self, str cache_name): + self.cache_name = cache_name + + def __call__(self, object wrapped): + return under_cached_property_with_name(wrapped, self.cache_name) + + __class_getitem__ = classmethod(GenericAlias) + + cdef class cached_property: """Use as a class method decorator. It operates almost exactly like the Python `@property` decorator, but it puts the result of the diff --git a/src/propcache/_helpers_py.py b/src/propcache/_helpers_py.py index db5b8d6..8a023dd 100644 --- a/src/propcache/_helpers_py.py +++ b/src/propcache/_helpers_py.py @@ -7,7 +7,12 @@ from functools import cached_property from typing import Any, Generic, Protocol, TypeVar, overload -__all__ = ("under_cached_property", "cached_property") +__all__ = ( + "under_cached_property", + "under_cached_property_with_name", + "under_cache_name", + "cached_property", +) if sys.version_info >= (3, 11): @@ -62,3 +67,76 @@ def __get__( def __set__(self, inst: _CacheImpl[Any], value: _T) -> None: raise AttributeError("cached property is read-only") + + +class under_cached_property_with_name(Generic[_T]): + """Like ``under_cached_property`` but reads the cache from a named attribute. + + The default ``under_cached_property`` always reads/writes ``inst._cache``. + This variant lets the caller pick a different attribute name, which is + useful for classes that use ``__slots__`` (where ``_cache`` would clash + with a slot of the same name on subclasses), or for classes that want to + partition cached values into multiple buckets. + + The named attribute must be a mutable mapping (typically a ``dict``) + and must exist on the instance before the first attribute access. + """ + + def __init__(self, wrapped: Callable[[Any], _T], cache_name: str) -> None: + self.wrapped = wrapped + self.__doc__ = wrapped.__doc__ + self.name = wrapped.__name__ + self.cache_name = cache_name + + @overload + def __get__(self, inst: None, owner: type[object] | None = None) -> Self: ... + + @overload + def __get__(self, inst: object, owner: type[object] | None = None) -> _T: ... + + def __get__( + self, inst: object | None, owner: type[object] | None = None + ) -> _T | Self: + if inst is None: + return self + cache: dict[str, Any] = getattr(inst, self.cache_name) + try: + return cache[self.name] # type: ignore[no-any-return] + except KeyError: + val = self.wrapped(inst) + cache[self.name] = val + return val + + def __set__(self, inst: object, value: _T) -> None: + raise AttributeError("cached property is read-only") + + +class under_cache_name: + """Decorator factory binding a cache attribute name. + + Calling an instance with a function returns an + ``under_cached_property_with_name`` configured to store its value in the + pre-bound attribute. Useful for defining a project-wide ``reify`` alias + once and reusing it across many class definitions:: + + reify = under_cache_name("_my_cache") + + class MyTool: + __slots__ = ("i", "_my_cache") + + def __init__(self, i: int) -> None: + self.i = i + self._my_cache = {} + + @reify + def cached_item(self) -> int: + return self.i + 10 + """ + + def __init__(self, cache_name: str) -> None: + self.cache_name = cache_name + + def __call__( + self, wrapped: Callable[[Any], _T] + ) -> under_cached_property_with_name[_T]: + return under_cached_property_with_name(wrapped, self.cache_name) diff --git a/src/propcache/api.py b/src/propcache/api.py index 22389e6..7aa6cb9 100644 --- a/src/propcache/api.py +++ b/src/propcache/api.py @@ -1,8 +1,15 @@ """Public API of the property caching library.""" -from ._helpers import cached_property, under_cached_property +from ._helpers import ( + cached_property, + under_cache_name, + under_cached_property, + under_cached_property_with_name, +) __all__ = ( "cached_property", + "under_cache_name", "under_cached_property", + "under_cached_property_with_name", ) diff --git a/tests/test_api.py b/tests/test_api.py index 85beec3..3da684c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -7,5 +7,11 @@ def test_api() -> None: """Verify the public API is accessible.""" assert api.cached_property is not None assert api.under_cached_property is not None + assert api.under_cached_property_with_name is not None + assert api.under_cache_name is not None assert api.cached_property is _helpers.cached_property assert api.under_cached_property is _helpers.under_cached_property + assert ( + api.under_cached_property_with_name is _helpers.under_cached_property_with_name + ) + assert api.under_cache_name is _helpers.under_cache_name diff --git a/tests/test_init.py b/tests/test_init.py index 6bcffe7..95c70e1 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -10,13 +10,25 @@ def test_api_at_top_level() -> None: """Verify the public API is accessible at top-level.""" assert propcache.cached_property is not None assert propcache.under_cached_property is not None + assert propcache.under_cached_property_with_name is not None + assert propcache.under_cache_name is not None assert propcache.cached_property is _helpers.cached_property assert propcache.under_cached_property is _helpers.under_cached_property + assert ( + propcache.under_cached_property_with_name + is _helpers.under_cached_property_with_name + ) + assert propcache.under_cache_name is _helpers.under_cache_name @pytest.mark.parametrize( "prop_name", - ("cached_property", "under_cached_property"), + ( + "cached_property", + "under_cached_property", + "under_cached_property_with_name", + "under_cache_name", + ), ) def test_public_api_is_discoverable_in_dir(prop_name: str) -> None: """Verify the public API is discoverable programmatically.""" diff --git a/tests/test_under_cached_property_with_name.py b/tests/test_under_cached_property_with_name.py new file mode 100644 index 0000000..a85f4cb --- /dev/null +++ b/tests/test_under_cached_property_with_name.py @@ -0,0 +1,202 @@ +import sys +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, Protocol, TypeVar + +import pytest + +from propcache.api import under_cached_property_with_name + +if sys.version_info >= (3, 11): + from typing import assert_type + +_T_co = TypeVar("_T_co", covariant=True) + + +class _CacheNameFactory(Protocol): + def __call__( + self, wrapped: Callable[[Any], _T_co] + ) -> "under_cached_property_with_name[_T_co]": ... + + +class APIProtocol(Protocol): + @staticmethod + def under_cached_property_with_name( + func: Callable[[Any], _T_co], cache_name: str + ) -> "under_cached_property_with_name[_T_co]": ... + + @staticmethod + def under_cache_name(name: str) -> _CacheNameFactory: ... + + +def test_under_cached_property_with_name(propcache_module: APIProtocol) -> None: + reify = propcache_module.under_cache_name("_my_cache") + + class A: + def __init__(self) -> None: + self._my_cache: dict[str, Any] = {} + + @reify + def prop(self) -> int: + return 1 + + @reify + def prop2(self) -> str: + return "foo" + + a = A() + if sys.version_info >= (3, 11): + assert_type(a.prop, int) + assert_type(a.prop2, str) + assert a.prop == 1 + assert a.prop2 == "foo" + assert a._my_cache == {"prop": 1, "prop2": "foo"} + + +def test_under_cached_property_with_name_assignment( + propcache_module: APIProtocol, +) -> None: + reify = propcache_module.under_cache_name("_my_cache") + + class A: + def __init__(self) -> None: + self._my_cache: dict[str, Any] = {} + + @reify + def prop(self) -> None: + """Mock property.""" + + a = A() + with pytest.raises(AttributeError): + a.prop = 123 # type: ignore[assignment] + + +def test_under_cached_property_with_name_missing_cache( + propcache_module: APIProtocol, +) -> None: + """Accessing the property before the cache attribute exists must raise.""" + reify = propcache_module.under_cache_name("_my_cache") + + class A: + @reify + def prop(self) -> int: + return 1 + + a = A() + with pytest.raises(AttributeError): + _ = a.prop + + +def test_under_cached_property_with_name_caching( + propcache_module: APIProtocol, +) -> None: + """The wrapped function must be invoked at most once per instance.""" + calls = 0 + reify = propcache_module.under_cache_name("_my_cache") + + class A: + def __init__(self) -> None: + self._my_cache: dict[str, int] = {} + + @reify + def prop(self) -> int: + nonlocal calls + calls += 1 + return 42 + + a = A() + assert a.prop == 42 + assert a.prop == 42 + assert calls == 1 + + +def test_under_cached_property_with_name_isolated_caches( + propcache_module: APIProtocol, +) -> None: + """Properties bound to different cache attributes stay independent.""" + in_first = propcache_module.under_cache_name("_first") + in_second = propcache_module.under_cache_name("_second") + + class A: + def __init__(self) -> None: + self._first: dict[str, int] = {} + self._second: dict[str, int] = {} + + @in_first + def prop_a(self) -> int: + return 1 + + @in_second + def prop_b(self) -> int: + return 2 + + a = A() + assert a.prop_a == 1 + assert a.prop_b == 2 + assert a._first == {"prop_a": 1} + assert a._second == {"prop_b": 2} + + +def test_under_cached_property_with_name_descriptor_access( + propcache_module: APIProtocol, +) -> None: + """Accessing the descriptor on the class returns the descriptor itself.""" + reify = propcache_module.under_cache_name("_my_cache") + + class A: + @reify + def prop(self) -> int: + """Docstring.""" + return 1 + + if TYPE_CHECKING: + assert isinstance(A.prop, under_cached_property_with_name) + else: + assert isinstance(A.prop, propcache_module.under_cached_property_with_name) + assert A.prop.__doc__ == "Docstring." + assert A.prop.cache_name == "_my_cache" + + +def test_under_cached_property_with_name_direct_construction( + propcache_module: APIProtocol, +) -> None: + """The descriptor class can be used directly without the factory.""" + + def fn(self: "A") -> int: + return 7 + + descriptor = propcache_module.under_cached_property_with_name(fn, "_bucket") + + class A: + def __init__(self) -> None: + self._bucket: dict[str, int] = {} + + prop = descriptor + + a = A() + assert a.prop == 7 + assert a._bucket == {"fn": 7} + + +def test_under_cache_name_attribute(propcache_module: APIProtocol) -> None: + """The factory exposes the bound cache name as an attribute.""" + factory = propcache_module.under_cache_name("_my_cache") + assert factory.cache_name == "_my_cache" # type: ignore[attr-defined] + + +def test_under_cached_property_with_name_slots(propcache_module: APIProtocol) -> None: + """The variant works on classes that use ``__slots__``.""" + reify = propcache_module.under_cache_name("_my_cache") + + class A: + __slots__ = ("_my_cache",) + + def __init__(self) -> None: + self._my_cache: dict[str, int] = {} + + @reify + def prop(self) -> int: + return 99 + + a = A() + assert a.prop == 99 + assert a._my_cache == {"prop": 99}