Skip to content

Commit e08a044

Browse files
Merge pull request #150 from mjpieters/sync_cached_property_getter
Sync cached_property getter access
2 parents 806e85e + d2e442f commit e08a044

File tree

4 files changed

+258
-73
lines changed

4 files changed

+258
-73
lines changed

asyncstdlib/functools.py

Lines changed: 169 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from asyncio import iscoroutinefunction
12
from typing import (
23
Callable,
34
Awaitable,
@@ -7,13 +8,15 @@
78
Generator,
89
Optional,
910
Coroutine,
10-
overload,
11+
AsyncContextManager,
12+
Type,
13+
cast,
1114
)
1215

13-
from ._typing import T, AC, AnyIterable
16+
from ._typing import T, AC, AnyIterable, R
1417
from ._core import ScopedIter, awaitify as _awaitify, Sentinel
1518
from .builtins import anext
16-
from ._utility import public_module
19+
from .contextlib import nullcontext
1720

1821
from ._lrucache import (
1922
lru_cache,
@@ -32,6 +35,7 @@
3235
"LRUAsyncBoundCallable",
3336
"reduce",
3437
"cached_property",
38+
"CachedProperty",
3539
]
3640

3741

@@ -45,44 +49,153 @@ def cache(user_function: AC) -> LRUAsyncCallable[AC]:
4549
return lru_cache(maxsize=None)(user_function)
4650

4751

48-
class AwaitableValue(Generic[T]):
52+
class AwaitableValue(Generic[R]):
4953
"""Helper to provide an arbitrary value in ``await``"""
5054

5155
__slots__ = ("value",)
5256

53-
def __init__(self, value: T):
57+
def __init__(self, value: R):
5458
self.value = value
5559

5660
# noinspection PyUnreachableCode
57-
def __await__(self) -> Generator[None, None, T]:
61+
def __await__(self) -> Generator[None, None, R]:
5862
return self.value
5963
yield # type: ignore # pragma: no cover
6064

6165
def __repr__(self) -> str:
6266
return f"{self.__class__.__name__}({self.value!r})"
6367

6468

65-
class _RepeatableCoroutine(Generic[T]):
66-
"""Helper to ``await`` a coroutine also more or less than just once"""
69+
class _FutureCachedValue(Generic[R, T]):
70+
"""A placeholder object to control concurrent access to a cached awaitable value.
6771
68-
__slots__ = ("call", "args", "kwargs")
72+
When given a lock to coordinate access, only the first task to await on a
73+
cached property triggers the underlying coroutine. Once a value has been
74+
produced, all tasks are unblocked and given the same, single value.
75+
76+
"""
77+
78+
__slots__ = ("_get_attribute", "_instance", "_name", "_lock")
6979

7080
def __init__(
71-
self, __call: Callable[..., Coroutine[Any, Any, T]], *args: Any, **kwargs: Any
81+
self,
82+
get_attribute: Callable[[T], Coroutine[Any, Any, R]],
83+
instance: T,
84+
name: str,
85+
lock: AsyncContextManager[Any],
7286
):
73-
self.call = __call
74-
self.args = args
75-
self.kwargs = kwargs
87+
self._get_attribute = get_attribute
88+
self._instance = instance
89+
self._name = name
90+
self._lock = lock
91+
92+
def __await__(self) -> Generator[None, None, R]:
93+
return self._await_impl().__await__()
94+
95+
@property
96+
def _instance_value(self) -> Awaitable[R]:
97+
"""Retrieve whatever is currently cached on the instance
98+
99+
If the instance (no longer) has this attribute, it was deleted and the
100+
process is restarted by delegating to the descriptor.
76101
77-
def __await__(self) -> Generator[Any, Any, T]:
78-
return self.call(*self.args, **self.kwargs).__await__()
102+
"""
103+
try:
104+
return self._instance.__dict__[self._name]
105+
except KeyError:
106+
# something deleted the cached value or future cached value placeholder. Restart
107+
# the fetch by delegating to the cached_property descriptor.
108+
return getattr(self._instance, self._name)
109+
110+
async def _await_impl(self) -> R:
111+
if (stored := self._instance_value) is self:
112+
# attempt to get the lock
113+
async with self._lock:
114+
# check again for a cached value
115+
if (stored := self._instance_value) is self:
116+
# the instance attribute is still this placeholder, and we
117+
# hold the lock. Start the getter to store the value on the
118+
# instance and return the value.
119+
return await self._get_attribute(self._instance)
120+
121+
# another task produced a value, or the instance.__dict__ object was
122+
# deleted in the interim.
123+
return await stored
79124

80125
def __repr__(self) -> str:
81-
return f"<{self.__class__.__name__} object {self.call.__name__} at {id(self)}>"
126+
return (
127+
f"<{type(self).__name__} for '{type(self._instance).__name__}."
128+
f"{self._name}' at {id(self):#x}>"
129+
)
130+
82131

132+
class CachedProperty(Generic[T, R]):
133+
def __init__(
134+
self,
135+
getter: Callable[[T], Awaitable[R]],
136+
asynccontextmanager_type: Type[AsyncContextManager[Any]] = nullcontext,
137+
):
138+
self.func = getter
139+
self.attrname = None
140+
self.__doc__ = getter.__doc__
141+
self._asynccontextmanager_type = asynccontextmanager_type
142+
143+
def __set_name__(self, owner: Any, name: str) -> None:
144+
if self.attrname is None:
145+
self.attrname = name
146+
elif name != self.attrname:
147+
raise TypeError(
148+
"Cannot assign the same cached_property to two different names "
149+
f"({self.attrname!r} and {name!r})."
150+
)
151+
152+
def __get__(
153+
self, instance: Optional[T], owner: Optional[Type[Any]]
154+
) -> Union["CachedProperty[T, R]", Awaitable[R]]:
155+
if instance is None:
156+
return self
157+
158+
name = self.attrname
159+
if name is None:
160+
raise TypeError(
161+
"Cannot use cached_property instance without calling __set_name__ on it."
162+
)
163+
164+
# check for write access first; not all objects have __dict__ (e.g. class defines slots)
165+
try:
166+
cache = instance.__dict__
167+
except AttributeError:
168+
msg = (
169+
f"No '__dict__' attribute on {type(instance).__name__!r} "
170+
f"instance to cache {name!r} property."
171+
)
172+
raise TypeError(msg) from None
173+
174+
# store a placeholder for other tasks to access the future cached value
175+
# on this instance. It takes care of coordinating between different
176+
# tasks awaiting on the placeholder until the cached value has been
177+
# produced.
178+
wrapper = _FutureCachedValue(
179+
self._get_attribute, instance, name, self._asynccontextmanager_type()
180+
)
181+
cache[name] = wrapper
182+
return wrapper
183+
184+
async def _get_attribute(self, instance: T) -> R:
185+
value = await self.func(instance)
186+
name = self.attrname
187+
assert name is not None # enforced in __get__
188+
instance.__dict__[name] = AwaitableValue(value)
189+
return value
83190

84-
@public_module(__name__, "cached_property")
85-
class CachedProperty(Generic[T]):
191+
192+
def cached_property(
193+
type_or_getter: Union[Type[AsyncContextManager[Any]], Callable[[T], Awaitable[R]]],
194+
/,
195+
) -> Union[
196+
Callable[[Callable[[T], Awaitable[R]]], CachedProperty[T, R]],
197+
CachedProperty[T, R],
198+
]:
86199
"""
87200
Transform a method into an attribute whose value is cached
88201
@@ -108,7 +221,7 @@ def __init__(self, url):
108221
async def data(self):
109222
return await asynclib.get(self.url)
110223
111-
resource = Resource(1, 3)
224+
resource = Resource("http://example.com")
112225
print(await resource.data) # needs some time...
113226
print(await resource.data) # finishes instantly
114227
del resource.data
@@ -117,51 +230,53 @@ async def data(self):
117230
Unlike a :py:class:`property`, this type does not support
118231
:py:meth:`~property.setter` or :py:meth:`~property.deleter`.
119232
233+
If the attribute is accessed by multiple tasks before a cached value has
234+
been produced, the getter can be run more than once. The final cached value
235+
is determined by the last getter coroutine to return. To enforce that the
236+
getter is executed at most once, provide a ``lock`` type - e.g. the
237+
:py:class:`asyncio.Lock` class in an :py:mod:`asyncio` application - and
238+
access is automatically synchronised.
239+
240+
.. code-block:: python3
241+
242+
from asyncio import Lock, gather
243+
244+
class Resource:
245+
def __init__(self, url):
246+
self.url = url
247+
248+
@a.cached_property(Lock)
249+
async def data(self):
250+
return await asynclib.get(self.url)
251+
252+
resource = Resource("http://example.com")
253+
print(*(await gather(resource.data, resource.data)))
254+
120255
.. note::
121256
122257
Instances on which a value is to be cached must have a
123258
``__dict__`` attribute that is a mutable mapping.
124259
"""
260+
if isinstance(type_or_getter, type) and issubclass(
261+
type_or_getter, AsyncContextManager
262+
):
125263

126-
def __init__(self, getter: Callable[[Any], Awaitable[T]]):
127-
self.__wrapped__ = getter
128-
self._name = getter.__name__
129-
self.__doc__ = getter.__doc__
130-
131-
def __set_name__(self, owner: Any, name: str) -> None:
132-
# Check whether we can store anything on the instance
133-
# Note that this is a failsafe, and might fail ugly.
134-
# People who are clever enough to avoid this heuristic
135-
# should also be clever enough to know the why and what.
136-
if not any("__dict__" in dir(cls) for cls in owner.__mro__):
137-
raise TypeError(
138-
"'cached_property' requires '__dict__' "
139-
f"on {owner.__name__!r} to store {name}"
264+
def decorator(
265+
coroutine: Callable[[T], Awaitable[R]],
266+
) -> CachedProperty[T, R]:
267+
return CachedProperty(
268+
coroutine,
269+
asynccontextmanager_type=cast(
270+
Type[AsyncContextManager[Any]], type_or_getter
271+
),
140272
)
141-
self._name = name
142-
143-
@overload
144-
def __get__(self, instance: None, owner: type) -> "CachedProperty[T]": ...
145273

146-
@overload
147-
def __get__(self, instance: object, owner: Optional[type]) -> Awaitable[T]: ...
148-
149-
def __get__(
150-
self, instance: Optional[object], owner: Optional[type]
151-
) -> Union["CachedProperty[T]", Awaitable[T]]:
152-
if instance is None:
153-
return self
154-
# __get__ may be called multiple times before it is first awaited to completion
155-
# provide a placeholder that acts just like the final value does
156-
return _RepeatableCoroutine(self._get_attribute, instance)
157-
158-
async def _get_attribute(self, instance: object) -> T:
159-
value = await self.__wrapped__(instance)
160-
instance.__dict__[self._name] = AwaitableValue(value)
161-
return value
274+
return decorator
162275

276+
if not iscoroutinefunction(type_or_getter):
277+
raise ValueError("cached_property can only be used with a coroutine function")
163278

164-
cached_property = CachedProperty
279+
return CachedProperty(type_or_getter)
165280

166281

167282
__REDUCE_SENTINEL = Sentinel("<no default>")

asyncstdlib/functools.pyi

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
from typing import Any, Awaitable, Callable, Generic, overload
1+
from typing import Any, AsyncContextManager, Awaitable, Callable, Generic, overload
22

3-
from ._typing import T, T1, T2, AC, AnyIterable
3+
from ._typing import T, T1, T2, AC, AnyIterable, R
44

55
from ._lrucache import (
66
LRUAsyncCallable as LRUAsyncCallable,
@@ -10,14 +10,28 @@ from ._lrucache import (
1010

1111
def cache(user_function: AC) -> LRUAsyncCallable[AC]: ...
1212

13-
class cached_property(Generic[T]):
14-
def __init__(self, getter: Callable[[Any], Awaitable[T]]) -> None: ...
13+
class CachedProperty(Generic[T, R]):
14+
def __init__(
15+
self,
16+
getter: Callable[[T], Awaitable[R]],
17+
lock_type: type[AsyncContextManager[Any]] = ...,
18+
) -> None: ...
1519
def __set_name__(self, owner: Any, name: str) -> None: ...
1620
@overload
17-
def __get__(self, instance: None, owner: type) -> "cached_property[T]": ...
21+
def __get__(self, instance: None, owner: type[Any]) -> "CachedProperty[T, R]": ...
1822
@overload
19-
def __get__(self, instance: object, owner: type | None) -> Awaitable[T]: ...
23+
def __get__(self, instance: T, owner: type | None) -> Awaitable[R]: ...
24+
# __set__ is not defined at runtime, but you are allowed to replace the cached value
25+
def __set__(self, instance: T, value: R) -> None: ... # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues]
26+
# __del__ is not defined at runtime, but you are allowed to delete the cached value
27+
def __del__(self, instance: T) -> None: ...
2028

29+
@overload
30+
def cached_property(getter: Callable[[T], Awaitable[R]], /) -> CachedProperty[T, R]: ...
31+
@overload
32+
def cached_property(
33+
asynccontextmanager_type: type[AsyncContextManager[Any]], /
34+
) -> Callable[[Callable[[T], Awaitable[R]]], CachedProperty[T, R]]: ...
2135
@overload
2236
async def reduce(
2337
function: Callable[[T1, T2], T1], iterable: AnyIterable[T2], initial: T1

docs/source/api/functools.rst

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,18 @@ Attribute Caches
3535

3636
This type of cache tracks ``await``\ ing an attribute.
3737

38-
.. autofunction:: cached_property(getter: (Self) → await T)
38+
.. py:function:: cached_property(getter: (Self) → await T, /)
3939
:decorator:
4040

41+
.. autofunction:: cached_property(asynccontextmanager_type: Type[AsyncContextManager], /)((Self) → await T)
42+
:decorator:
43+
:noindex:
44+
4145
.. versionadded:: 1.1.0
46+
.. versionadded:: 3.13.0
47+
48+
The ``asynccontextmanager_type`` decorator parameter.
49+
4250

4351
Callable Caches
4452
---------------

0 commit comments

Comments
 (0)