Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions CHANGES/200.feature.rst
Original file line number Diff line number Diff line change
@@ -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__``.
44 changes: 44 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 10 additions & 1 deletion src/propcache/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = ()
Expand 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:
Expand Down
31 changes: 30 additions & 1 deletion src/propcache/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
62 changes: 62 additions & 0 deletions src/propcache/_helpers_c.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <object>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
Expand Down
80 changes: 79 additions & 1 deletion src/propcache/_helpers_py.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -62,3 +67,76 @@

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: ...

Check notice

Code scanning / CodeQL

Statement has no effect Note

This statement has no effect.

@overload
def __get__(self, inst: object, owner: type[object] | None = None) -> _T: ...

Check notice

Code scanning / CodeQL

Statement has no effect Note

This statement has no effect.

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)
9 changes: 8 additions & 1 deletion src/propcache/api.py
Original file line number Diff line number Diff line change
@@ -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",
)
6 changes: 6 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 13 additions & 1 deletion tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading
Loading