1+ from asyncio import iscoroutinefunction
12from typing import (
23 Callable ,
34 Awaitable ,
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
1417from ._core import ScopedIter , awaitify as _awaitify , Sentinel
1518from .builtins import anext
16- from ._utility import public_module
19+ from .contextlib import nullcontext
1720
1821from ._lrucache import (
1922 lru_cache ,
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>" )
0 commit comments