python-wrap 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_wrap/__init__.py +272 -0
- python_wrap/_core.py +212 -0
- python_wrap/aio.py +620 -0
- python_wrap/batching.py +556 -0
- python_wrap/bulkhead.py +431 -0
- python_wrap/caching.py +1613 -0
- python_wrap/circuit.py +553 -0
- python_wrap/concurrency.py +1886 -0
- python_wrap/config.py +217 -0
- python_wrap/deadline.py +168 -0
- python_wrap/decorators.py +7 -0
- python_wrap/devtools.py +385 -0
- python_wrap/errors.py +86 -0
- python_wrap/events.py +161 -0
- python_wrap/hedging.py +192 -0
- python_wrap/logs.py +767 -0
- python_wrap/metrics.py +242 -0
- python_wrap/periodic.py +491 -0
- python_wrap/policy.py +263 -0
- python_wrap/py.typed +0 -0
- python_wrap/retry.py +1324 -0
- python_wrap/state.py +500 -0
- python_wrap/testing.py +662 -0
- python_wrap/timing.py +535 -0
- python_wrap/validation.py +662 -0
- python_wrap-0.2.1.dist-info/METADATA +485 -0
- python_wrap-0.2.1.dist-info/RECORD +29 -0
- python_wrap-0.2.1.dist-info/WHEEL +4 -0
- python_wrap-0.2.1.dist-info/licenses/LICENSE +21 -0
python_wrap/caching.py
ADDED
|
@@ -0,0 +1,1613 @@
|
|
|
1
|
+
"""Caching decorators: `memoize`, `cache`, `once` and `cached_property`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import concurrent.futures
|
|
7
|
+
import contextlib
|
|
8
|
+
import contextvars
|
|
9
|
+
import datetime
|
|
10
|
+
import functools
|
|
11
|
+
import inspect
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import weakref
|
|
15
|
+
from collections import OrderedDict
|
|
16
|
+
from collections.abc import Awaitable, Callable, Coroutine, Hashable
|
|
17
|
+
from typing import (
|
|
18
|
+
Any,
|
|
19
|
+
Concatenate,
|
|
20
|
+
Generic,
|
|
21
|
+
NamedTuple,
|
|
22
|
+
ParamSpec,
|
|
23
|
+
Protocol,
|
|
24
|
+
TypeVar,
|
|
25
|
+
overload,
|
|
26
|
+
runtime_checkable,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from python_wrap import _core
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"CacheInfo",
|
|
33
|
+
"CacheStore",
|
|
34
|
+
"CachedProperty",
|
|
35
|
+
"Memoized",
|
|
36
|
+
"Once",
|
|
37
|
+
"cache",
|
|
38
|
+
"cached_property",
|
|
39
|
+
"invalidate",
|
|
40
|
+
"memoize",
|
|
41
|
+
"once",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
P = ParamSpec("P")
|
|
45
|
+
R = TypeVar("R")
|
|
46
|
+
R_co = TypeVar("R_co", covariant=True)
|
|
47
|
+
T = TypeVar("T")
|
|
48
|
+
P2 = ParamSpec("P2")
|
|
49
|
+
|
|
50
|
+
_MISSING: Any = object()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CacheInfo(NamedTuple):
|
|
54
|
+
"""Statistics returned by ``wrapped.cache_info()``."""
|
|
55
|
+
|
|
56
|
+
hits: int
|
|
57
|
+
misses: int
|
|
58
|
+
maxsize: int | None
|
|
59
|
+
currsize: int
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Memoized(Protocol[P, R_co]):
|
|
63
|
+
"""A function wrapped by `memoize` (callable plus cache controls).
|
|
64
|
+
|
|
65
|
+
Use it to annotate decorated functions, e.g. ``Memoized[[int], str]``. The
|
|
66
|
+
``__get__`` overloads let type checkers bind ``self`` on instance access
|
|
67
|
+
(``obj.method(x)``) and ``cls`` under a ``@classmethod`` stacked above
|
|
68
|
+
(``Cls.method(x)``).
|
|
69
|
+
|
|
70
|
+
Typing limitations (runtime behaviour is unaffected):
|
|
71
|
+
|
|
72
|
+
* Read from the class (``Cls.method``), the wrapper accepts the call both with and
|
|
73
|
+
without its first argument, because mypy cannot tell a classmethod from an
|
|
74
|
+
instance or static method there. So ``Cls.method(x)`` on an instance method is
|
|
75
|
+
not flagged by mypy (pyright flags it), and ``Cls.cm(Cls, x)`` is accepted by both.
|
|
76
|
+
* Under a ``@staticmethod`` stacked above, ``Cls.method(x)`` type-checks but
|
|
77
|
+
``obj.method(x)`` is reported as an error by both mypy and pyright.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ...
|
|
81
|
+
|
|
82
|
+
@overload
|
|
83
|
+
def __get__(
|
|
84
|
+
self: Memoized[Concatenate[type[T], P2], R_co], instance: None, owner: type[T]
|
|
85
|
+
) -> _MemoizedOnClass[P, P2, R_co]: ...
|
|
86
|
+
@overload
|
|
87
|
+
def __get__(self, instance: None, owner: type | None = None) -> Memoized[P, R_co]: ...
|
|
88
|
+
@overload
|
|
89
|
+
def __get__(
|
|
90
|
+
self: Memoized[Concatenate[Any, P2], R_co], instance: object, owner: type | None = None
|
|
91
|
+
) -> Memoized[P2, R_co]: ...
|
|
92
|
+
|
|
93
|
+
def cache_clear(self) -> None:
|
|
94
|
+
"""Drop every cached entry and reset the statistics."""
|
|
95
|
+
|
|
96
|
+
def cache_info(self) -> CacheInfo:
|
|
97
|
+
"""Return hits, misses, maxsize and the current number of live entries."""
|
|
98
|
+
|
|
99
|
+
# The three lookups below take the full argument list of the decorated function.
|
|
100
|
+
# On a method that includes the instance (``obj.method.cache_get(obj, x)``):
|
|
101
|
+
# ``obj.method`` is a plain bound method, so its attributes are not bound.
|
|
102
|
+
|
|
103
|
+
def cache_delete(self, *args: Any, **kwargs: Any) -> bool:
|
|
104
|
+
"""Drop the entry for these arguments. Returns True if one existed."""
|
|
105
|
+
|
|
106
|
+
def cache_contains(self, *args: Any, **kwargs: Any) -> bool:
|
|
107
|
+
"""True if a live entry exists for these arguments (nothing is computed or counted)."""
|
|
108
|
+
|
|
109
|
+
def cache_get(self, *args: Any, **kwargs: Any) -> Any:
|
|
110
|
+
"""Return the cached result for these arguments without computing it.
|
|
111
|
+
|
|
112
|
+
Raises ``KeyError`` when there is no live entry. A cached exception is raised.
|
|
113
|
+
For ``async def`` functions this is the awaited result, not a coroutine.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def cache_parameters(self) -> dict[str, Any]:
|
|
117
|
+
"""The options: ``maxsize``, ``ttl``, ``typed``, ``cache_exceptions``, ``exception_ttl``."""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class _MemoizedOnClass(Memoized[P, R_co], Protocol[P, P2, R_co]):
|
|
121
|
+
"""`Memoized` read from a class: accepts the call with and without the first argument.
|
|
122
|
+
|
|
123
|
+
Returned for class access (``Cls.attr``) when the first parameter could be ``cls``.
|
|
124
|
+
pyright only picks it under ``@classmethod``; mypy cannot tell a classmethod from an
|
|
125
|
+
instance or static method there, so both call forms are accepted.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
@overload
|
|
129
|
+
def __call__(self, *args: P2.args, **kwargs: P2.kwargs) -> R_co: ...
|
|
130
|
+
@overload
|
|
131
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ...
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class Once(Protocol[P, R_co]):
|
|
135
|
+
"""A function wrapped by `once` (callable plus ``called`` and ``reset()``).
|
|
136
|
+
|
|
137
|
+
Binds ``self`` and ``cls`` like `Memoized`, with the same typing limitations.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
called: bool
|
|
141
|
+
|
|
142
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ...
|
|
143
|
+
|
|
144
|
+
@overload
|
|
145
|
+
def __get__(
|
|
146
|
+
self: Once[Concatenate[type[T], P2], R_co], instance: None, owner: type[T]
|
|
147
|
+
) -> _OnceOnClass[P, P2, R_co]: ...
|
|
148
|
+
@overload
|
|
149
|
+
def __get__(self, instance: None, owner: type | None = None) -> Once[P, R_co]: ...
|
|
150
|
+
@overload
|
|
151
|
+
def __get__(
|
|
152
|
+
self: Once[Concatenate[Any, P2], R_co], instance: object, owner: type | None = None
|
|
153
|
+
) -> Once[P2, R_co]: ...
|
|
154
|
+
|
|
155
|
+
def reset(self) -> None:
|
|
156
|
+
"""Forget the stored result so the next call runs the function again."""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class _OnceOnClass(Once[P, R_co], Protocol[P, P2, R_co]):
|
|
160
|
+
"""`Once` read from a class: accepts the call with and without the first argument.
|
|
161
|
+
|
|
162
|
+
Returned for class access (``Cls.attr``) when the first parameter could be ``cls``.
|
|
163
|
+
pyright only picks it under ``@classmethod``; mypy cannot tell a classmethod from an
|
|
164
|
+
instance or static method there, so both call forms are accepted.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
@overload
|
|
168
|
+
def __call__(self, *args: P2.args, **kwargs: P2.kwargs) -> R_co: ...
|
|
169
|
+
@overload
|
|
170
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ...
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# --------------------------------------------------------------------------- keys
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class _Tag:
|
|
177
|
+
"""Marker that keeps ``[1]`` and ``(1,)`` (or ``{1}``) from sharing a key."""
|
|
178
|
+
|
|
179
|
+
__slots__ = ("name",)
|
|
180
|
+
|
|
181
|
+
def __init__(self, name: str) -> None:
|
|
182
|
+
self.name = name
|
|
183
|
+
|
|
184
|
+
def __repr__(self) -> str:
|
|
185
|
+
return f"<{self.name}>"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
_LIST, _DICT, _SET = _Tag("list"), _Tag("dict"), _Tag("set")
|
|
189
|
+
_KWARGS = _Tag("kwargs") # separates keyword arguments in keys built without a signature
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _freeze(value: Any) -> Hashable:
|
|
193
|
+
"""Make ``value`` hashable: builtin containers are converted recursively."""
|
|
194
|
+
try:
|
|
195
|
+
hash(value)
|
|
196
|
+
except TypeError:
|
|
197
|
+
pass
|
|
198
|
+
else:
|
|
199
|
+
return value # type: ignore[no-any-return]
|
|
200
|
+
if isinstance(value, list):
|
|
201
|
+
return (_LIST, tuple(_freeze(v) for v in value))
|
|
202
|
+
if isinstance(value, tuple):
|
|
203
|
+
return tuple(_freeze(v) for v in value)
|
|
204
|
+
if isinstance(value, dict):
|
|
205
|
+
return (_DICT, frozenset((_freeze(k), _freeze(v)) for k, v in value.items()))
|
|
206
|
+
if isinstance(value, (set, frozenset)):
|
|
207
|
+
return (_SET, frozenset(_freeze(v) for v in value))
|
|
208
|
+
raise _Unhashable(type(value).__name__)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class _Unhashable(TypeError):
|
|
212
|
+
"""Raised by `_freeze`; carries the offending type name."""
|
|
213
|
+
|
|
214
|
+
def __init__(self, type_name: str) -> None:
|
|
215
|
+
super().__init__(type_name)
|
|
216
|
+
self.type_name = type_name
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _freeze_arg(name: str, value: Any) -> Hashable:
|
|
220
|
+
try:
|
|
221
|
+
return _freeze(value)
|
|
222
|
+
except _Unhashable as exc:
|
|
223
|
+
raise TypeError(
|
|
224
|
+
f"memoize: argument {name!r} (of type {exc.type_name!r}) is not hashable; "
|
|
225
|
+
"pass key=<callable> to build the cache key yourself (for methods, a key "
|
|
226
|
+
"that ignores or replaces `self`), or use functools.cached_property"
|
|
227
|
+
) from None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _make_key_builder(
|
|
231
|
+
func: Callable[..., Any],
|
|
232
|
+
typed: bool,
|
|
233
|
+
key: Callable[..., Hashable] | None,
|
|
234
|
+
skip_first: bool = False,
|
|
235
|
+
) -> Callable[[tuple[Any, ...], dict[str, Any]], Hashable]:
|
|
236
|
+
"""Build cache keys; ``skip_first`` leaves the first argument (``self``) out."""
|
|
237
|
+
if key is not None:
|
|
238
|
+
user_key = key
|
|
239
|
+
|
|
240
|
+
def custom(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Hashable:
|
|
241
|
+
return user_key(*args, **kwargs)
|
|
242
|
+
|
|
243
|
+
return custom
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
sig: inspect.Signature | None = inspect.signature(func)
|
|
247
|
+
except (TypeError, ValueError):
|
|
248
|
+
sig = None
|
|
249
|
+
|
|
250
|
+
skip = 1 if skip_first else 0
|
|
251
|
+
|
|
252
|
+
def build(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Hashable:
|
|
253
|
+
if sig is not None:
|
|
254
|
+
bound = sig.bind(*args, **kwargs)
|
|
255
|
+
bound.apply_defaults()
|
|
256
|
+
named = list(bound.arguments.items())[skip:]
|
|
257
|
+
frozen = tuple(_freeze_arg(n, v) for n, v in named)
|
|
258
|
+
else:
|
|
259
|
+
named = [(f"#{i}", v) for i, v in enumerate(args)][skip:]
|
|
260
|
+
frozen = tuple(_freeze_arg(n, v) for n, v in named)
|
|
261
|
+
if kwargs:
|
|
262
|
+
# Keep the names (and a marker), or f(1, b=2) would equal f(1, 2).
|
|
263
|
+
items = sorted(kwargs.items())
|
|
264
|
+
named += items
|
|
265
|
+
frozen += (_KWARGS, *((n, _freeze_arg(n, v)) for n, v in items))
|
|
266
|
+
values = [v for _, v in named]
|
|
267
|
+
if typed:
|
|
268
|
+
return (frozen, tuple(type(v) for v in values))
|
|
269
|
+
return frozen
|
|
270
|
+
|
|
271
|
+
return build
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ------------------------------------------------------------------------ memoize
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _retrieve(task: asyncio.Future[Any]) -> None:
|
|
278
|
+
"""Mark a shared task's exception as retrieved (all callers may have been cancelled)."""
|
|
279
|
+
if not task.cancelled():
|
|
280
|
+
task.exception()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
async def _await_shared(task: asyncio.Future[Any]) -> Any:
|
|
284
|
+
"""Await a shared task without letting one caller's cancellation cancel it.
|
|
285
|
+
|
|
286
|
+
Unlike ``asyncio.shield`` this never logs "exception in shielded future" when every
|
|
287
|
+
caller was cancelled before a failing task finished.
|
|
288
|
+
"""
|
|
289
|
+
outer: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
|
|
290
|
+
|
|
291
|
+
def forward(t: asyncio.Future[Any]) -> None:
|
|
292
|
+
if outer.done():
|
|
293
|
+
if not t.cancelled():
|
|
294
|
+
t.exception()
|
|
295
|
+
elif t.cancelled():
|
|
296
|
+
outer.cancel()
|
|
297
|
+
elif (exc := t.exception()) is not None:
|
|
298
|
+
outer.set_exception(exc)
|
|
299
|
+
else:
|
|
300
|
+
outer.set_result(t.result())
|
|
301
|
+
|
|
302
|
+
task.add_done_callback(forward)
|
|
303
|
+
return await outer
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _shared_future() -> asyncio.Future[Any]:
|
|
307
|
+
"""A future of the running loop that in-flight callers share (created under a lock)."""
|
|
308
|
+
fut: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
|
|
309
|
+
fut.add_done_callback(_retrieve)
|
|
310
|
+
return fut
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _copy_outcome(source: asyncio.Future[Any], dest: asyncio.Future[Any]) -> None:
|
|
314
|
+
if dest.done():
|
|
315
|
+
_retrieve(source)
|
|
316
|
+
elif source.cancelled():
|
|
317
|
+
dest.cancel()
|
|
318
|
+
elif (exc := source.exception()) is not None:
|
|
319
|
+
dest.set_exception(exc)
|
|
320
|
+
else:
|
|
321
|
+
dest.set_result(source.result())
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _start(
|
|
325
|
+
coro: Coroutine[Any, Any, Any],
|
|
326
|
+
shared: asyncio.Future[Any],
|
|
327
|
+
on_done: Callable[[asyncio.Future[Any] | None], None],
|
|
328
|
+
) -> asyncio.Future[Any]:
|
|
329
|
+
"""Run ``coro`` as a task and copy its outcome into ``shared``.
|
|
330
|
+
|
|
331
|
+
Call it WITHOUT holding a lock: under an eager task factory (Python 3.12+) the
|
|
332
|
+
coroutine may run to completion inside ``ensure_future``. The caller registers
|
|
333
|
+
``shared`` in its in-flight bookkeeping (under its lock) before calling this, so the
|
|
334
|
+
bookkeeping is correct whether the task starts lazily or eagerly. ``on_done`` gets
|
|
335
|
+
the finished task (or None if no task could be created) and must drop that entry.
|
|
336
|
+
"""
|
|
337
|
+
try:
|
|
338
|
+
task = asyncio.ensure_future(coro)
|
|
339
|
+
except BaseException as exc:
|
|
340
|
+
coro.close()
|
|
341
|
+
on_done(None)
|
|
342
|
+
if not shared.done():
|
|
343
|
+
shared.set_exception(exc)
|
|
344
|
+
raise
|
|
345
|
+
|
|
346
|
+
def finish(t: asyncio.Future[Any]) -> None:
|
|
347
|
+
try:
|
|
348
|
+
on_done(t)
|
|
349
|
+
finally:
|
|
350
|
+
_copy_outcome(t, shared)
|
|
351
|
+
|
|
352
|
+
task.add_done_callback(finish)
|
|
353
|
+
return task
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _ttl_seconds(ttl: float | datetime.timedelta | None) -> float | None:
|
|
357
|
+
if ttl is None:
|
|
358
|
+
return None
|
|
359
|
+
seconds = ttl.total_seconds() if isinstance(ttl, datetime.timedelta) else float(ttl)
|
|
360
|
+
if seconds <= 0:
|
|
361
|
+
raise ValueError("ttl must be positive")
|
|
362
|
+
return seconds
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _exception_types(
|
|
366
|
+
value: type[BaseException] | tuple[type[BaseException], ...],
|
|
367
|
+
) -> tuple[type[BaseException], ...]:
|
|
368
|
+
types = value if isinstance(value, tuple) else (value,)
|
|
369
|
+
for t in types:
|
|
370
|
+
if not (isinstance(t, type) and issubclass(t, BaseException)):
|
|
371
|
+
raise TypeError("cache_exceptions must be exception classes")
|
|
372
|
+
return types
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _fast_arity(func: Callable[..., Any], typed: bool, key: Callable[..., Hashable] | None) -> int:
|
|
376
|
+
"""Number of parameters if ``func`` takes only plain positional ones, else -1.
|
|
377
|
+
|
|
378
|
+
For such functions a call that passes every argument positionally can use the
|
|
379
|
+
``args`` tuple itself as the key (when hashable), skipping signature binding.
|
|
380
|
+
"""
|
|
381
|
+
if typed or key is not None:
|
|
382
|
+
return -1
|
|
383
|
+
try:
|
|
384
|
+
params = inspect.signature(func).parameters.values()
|
|
385
|
+
except (TypeError, ValueError):
|
|
386
|
+
return -1
|
|
387
|
+
plain = (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
388
|
+
if any(p.kind not in plain for p in params):
|
|
389
|
+
return -1
|
|
390
|
+
return len(params)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# fetch() returns a stored entry, ``None`` (absent) or this falsy marker (the entry had
|
|
394
|
+
# expired and was just removed). An entry is ``(value, expires, is_exception, fresh_until)``:
|
|
395
|
+
# ``expires`` is when it is dropped (None: never), ``fresh_until`` when it turns stale
|
|
396
|
+
# (equal to ``expires`` unless ``stale_ttl`` is set).
|
|
397
|
+
_EXPIRED: Any = ()
|
|
398
|
+
|
|
399
|
+
# Deadlines in entries given to a custom `CacheStore` are Unix-epoch seconds, so processes
|
|
400
|
+
# sharing one store agree on them. They are still read through ``_core.monotonic()`` (plus
|
|
401
|
+
# this constant offset) so ``fake_clock`` drives them too.
|
|
402
|
+
_WALL_OFFSET = time.time() - time.monotonic()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@runtime_checkable
|
|
406
|
+
class CacheStore(Protocol):
|
|
407
|
+
"""Where `memoize` keeps its entries when given ``store=...``.
|
|
408
|
+
|
|
409
|
+
Implement these five methods on any object (no base class needed):
|
|
410
|
+
|
|
411
|
+
* ``get(key)`` returns the entry stored under ``key``, or ``None`` if there is none.
|
|
412
|
+
* ``set(key, entry, ttl)`` stores ``entry``. ``ttl`` is the number of seconds after
|
|
413
|
+
which the entry is useless and may be discarded (``None``: keep it until deleted).
|
|
414
|
+
* ``delete(key)`` removes the entry and returns True if there was one.
|
|
415
|
+
* ``clear()`` removes every entry.
|
|
416
|
+
* ``__len__()`` is the number of stored entries (``cache_info().currsize``).
|
|
417
|
+
|
|
418
|
+
Contract:
|
|
419
|
+
|
|
420
|
+
* Entries are opaque: return exactly what was stored (a store that serialises, such
|
|
421
|
+
as a Redis adapter, must round-trip them, e.g. with `pickle`). They are never None.
|
|
422
|
+
They hold their own deadlines, as Unix timestamps, and `memoize` checks them, so a
|
|
423
|
+
store may forget entries at any time (that is just a miss) and need not expire them.
|
|
424
|
+
* Keys are hashable: normally tuples of the (normalised) arguments, or whatever
|
|
425
|
+
``key=`` returns. They are **not** strings, so a store that needs strings or bytes
|
|
426
|
+
must serialise them itself (``repr`` or ``pickle`` plus a per-function prefix).
|
|
427
|
+
* The store must be thread-safe: `memoize` does not hold its lock while calling it,
|
|
428
|
+
so a slow store (a network round trip) never blocks other cached functions. Plain
|
|
429
|
+
``dict`` operations are atomic in CPython, so a dict-backed store qualifies.
|
|
430
|
+
* Methods are synchronous; for ``async def`` functions they run on the event loop,
|
|
431
|
+
so keep them fast.
|
|
432
|
+
* Exceptions raised by the store propagate to the caller.
|
|
433
|
+
|
|
434
|
+
Example:
|
|
435
|
+
>>> class DictStore:
|
|
436
|
+
... def __init__(self): self.data = {}
|
|
437
|
+
... def get(self, key): return self.data.get(key)
|
|
438
|
+
... def set(self, key, entry, ttl): self.data[key] = entry
|
|
439
|
+
... def delete(self, key): return self.data.pop(key, None) is not None
|
|
440
|
+
... def clear(self): self.data.clear()
|
|
441
|
+
... def __len__(self): return len(self.data)
|
|
442
|
+
>>> @memoize(store=DictStore())
|
|
443
|
+
... def double(n):
|
|
444
|
+
... return n * 2
|
|
445
|
+
>>> double(2)
|
|
446
|
+
4
|
|
447
|
+
"""
|
|
448
|
+
|
|
449
|
+
def get(self, key: Hashable, /) -> Any:
|
|
450
|
+
"""Return the entry stored under ``key``, or None."""
|
|
451
|
+
|
|
452
|
+
def set(self, key: Hashable, entry: Any, ttl: float | None, /) -> None:
|
|
453
|
+
"""Store ``entry`` under ``key``; it may be discarded after ``ttl`` seconds."""
|
|
454
|
+
|
|
455
|
+
def delete(self, key: Hashable, /) -> bool:
|
|
456
|
+
"""Remove the entry under ``key``; True if there was one."""
|
|
457
|
+
|
|
458
|
+
def clear(self) -> None:
|
|
459
|
+
"""Remove every entry."""
|
|
460
|
+
|
|
461
|
+
def __len__(self) -> int:
|
|
462
|
+
"""The number of stored entries."""
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class _LocalStore:
|
|
466
|
+
"""The built-in store: an ordered dict with an optional LRU limit and TTL sweeps.
|
|
467
|
+
|
|
468
|
+
Not thread-safe by itself: the decorator holds its lock around every call.
|
|
469
|
+
"""
|
|
470
|
+
|
|
471
|
+
__slots__ = ("data", "expiring", "inserts", "live", "maxsize")
|
|
472
|
+
|
|
473
|
+
def __init__(self, maxsize: int | None, expiring: bool) -> None:
|
|
474
|
+
self.data: OrderedDict[Hashable, tuple[Any, float | None, bool, float | None]] = (
|
|
475
|
+
OrderedDict()
|
|
476
|
+
)
|
|
477
|
+
self.maxsize = maxsize
|
|
478
|
+
self.expiring = expiring
|
|
479
|
+
self.inserts = 0
|
|
480
|
+
self.live = 0
|
|
481
|
+
|
|
482
|
+
def get(self, k: Hashable) -> Any:
|
|
483
|
+
return self.data.get(k)
|
|
484
|
+
|
|
485
|
+
def delete(self, k: Hashable) -> bool:
|
|
486
|
+
return self.data.pop(k, _MISSING) is not _MISSING
|
|
487
|
+
|
|
488
|
+
def clear(self) -> None:
|
|
489
|
+
self.data.clear()
|
|
490
|
+
self.inserts = self.live = 0
|
|
491
|
+
|
|
492
|
+
def __len__(self) -> int:
|
|
493
|
+
return len(self.data)
|
|
494
|
+
|
|
495
|
+
def sweep(self) -> int:
|
|
496
|
+
"""Drop every expired entry; returns how many."""
|
|
497
|
+
now = _core.monotonic()
|
|
498
|
+
data = self.data
|
|
499
|
+
dead = [k for k, e in data.items() if e[1] is not None and now >= e[1]]
|
|
500
|
+
for k in dead:
|
|
501
|
+
del data[k]
|
|
502
|
+
return len(dead)
|
|
503
|
+
|
|
504
|
+
def put(self, k: Hashable, entry: tuple[Any, float | None, bool, float | None]) -> int:
|
|
505
|
+
"""Store an entry; returns ``ttl_evicted * 2**32 + lru_evicted`` (cheap to pass)."""
|
|
506
|
+
data = self.data
|
|
507
|
+
data[k] = entry
|
|
508
|
+
ttl_evicted = lru_evicted = 0
|
|
509
|
+
if self.maxsize is not None:
|
|
510
|
+
data.move_to_end(k)
|
|
511
|
+
if self.expiring:
|
|
512
|
+
# Amortised O(1): a full sweep only after at least
|
|
513
|
+
# max(64, entries left by the previous sweep) inserts.
|
|
514
|
+
self.inserts += 1
|
|
515
|
+
if self.inserts >= max(64, self.live):
|
|
516
|
+
ttl_evicted = self.sweep()
|
|
517
|
+
self.inserts, self.live = 0, len(data)
|
|
518
|
+
if self.maxsize is not None:
|
|
519
|
+
while len(data) > self.maxsize:
|
|
520
|
+
data.popitem(last=False)
|
|
521
|
+
lru_evicted += 1
|
|
522
|
+
return (ttl_evicted << 32) | lru_evicted
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
@overload
|
|
526
|
+
def memoize(func: Callable[P, R], /) -> Memoized[P, R]: ...
|
|
527
|
+
@overload
|
|
528
|
+
def memoize(
|
|
529
|
+
func: None = None,
|
|
530
|
+
/,
|
|
531
|
+
*,
|
|
532
|
+
maxsize: int | None = None,
|
|
533
|
+
ttl: float | datetime.timedelta | Callable[[Any], float | None] | None = None,
|
|
534
|
+
stale_ttl: float | datetime.timedelta | None = None,
|
|
535
|
+
typed: bool = False,
|
|
536
|
+
key: Callable[..., Hashable] | None = None,
|
|
537
|
+
cache_exceptions: type[BaseException] | tuple[type[BaseException], ...] = (),
|
|
538
|
+
exception_ttl: float | datetime.timedelta | None = None,
|
|
539
|
+
store: CacheStore | None = None,
|
|
540
|
+
per_instance: bool = False,
|
|
541
|
+
logger: _core.LoggerLike = None,
|
|
542
|
+
) -> Callable[[Callable[P, R]], Memoized[P, R]]: ...
|
|
543
|
+
def memoize(
|
|
544
|
+
func: Callable[..., Any] | None = None,
|
|
545
|
+
/,
|
|
546
|
+
*,
|
|
547
|
+
maxsize: int | None = None,
|
|
548
|
+
ttl: float | datetime.timedelta | Callable[[Any], float | None] | None = None,
|
|
549
|
+
stale_ttl: float | datetime.timedelta | None = None,
|
|
550
|
+
typed: bool = False,
|
|
551
|
+
key: Callable[..., Hashable] | None = None,
|
|
552
|
+
cache_exceptions: type[BaseException] | tuple[type[BaseException], ...] = (),
|
|
553
|
+
exception_ttl: float | datetime.timedelta | None = None,
|
|
554
|
+
store: CacheStore | None = None,
|
|
555
|
+
per_instance: bool = False,
|
|
556
|
+
logger: _core.LoggerLike = None,
|
|
557
|
+
) -> Any:
|
|
558
|
+
"""Cache a function's results per argument values.
|
|
559
|
+
|
|
560
|
+
Works bare (``@memoize``) or with options. Arguments are bound to the function
|
|
561
|
+
signature, so ``f(1)``, ``f(x=1)`` and ``f(1, y=<default>)`` share one entry.
|
|
562
|
+
Arguments must be hashable; lists, dicts and sets (also nested) are converted
|
|
563
|
+
automatically. Anything else unhashable raises ``TypeError`` -- pass ``key=``.
|
|
564
|
+
|
|
565
|
+
Exceptions are not cached unless listed in ``cache_exceptions``; a matching exception
|
|
566
|
+
is stored and re-raised on every hit as the very same instance (so its traceback
|
|
567
|
+
keeps growing; raise a fresh exception from a ``except`` clause if that matters).
|
|
568
|
+
The cache is guarded by a lock. For sync functions two threads that miss on the same
|
|
569
|
+
key at the same time may both compute the value (the function itself is never called
|
|
570
|
+
under the lock); the last result stored wins. For ``async def`` functions the awaited
|
|
571
|
+
result is cached and concurrent awaits of the same missing key share a single
|
|
572
|
+
in-flight task per event loop, so it runs once.
|
|
573
|
+
|
|
574
|
+
Stale-while-revalidate: with ``stale_ttl`` an entry older than ``ttl`` is *stale* but
|
|
575
|
+
still served, immediately, for ``stale_ttl`` more seconds; the call that finds it
|
|
576
|
+
stale starts one background refresh (``async def``: a task on the running event
|
|
577
|
+
loop, shared with concurrent misses; sync: a daemon thread running in a copy of the
|
|
578
|
+
caller's `contextvars` context, at most one per key). A failed refresh keeps the
|
|
579
|
+
stale value, is logged at WARNING and lets the next call try again. Cached
|
|
580
|
+
exceptions are never served stale.
|
|
581
|
+
|
|
582
|
+
Methods: ``self`` is part of the key, so results are per instance, and the cache
|
|
583
|
+
holds a strong reference to every instance it has seen, keeping it alive. A class
|
|
584
|
+
whose instances are unhashable (for example an ``eq=True`` dataclass) fails with a
|
|
585
|
+
``TypeError`` naming ``self``. ``per_instance=True`` solves both: each instance gets
|
|
586
|
+
its own cache, found by identity through a weak reference, so instances stay
|
|
587
|
+
collectable (their entries go with them) and need not be hashable. They must be
|
|
588
|
+
weak-referenceable (a ``__slots__`` class must list ``'__weakref__'``), and a cached
|
|
589
|
+
result that references its instance keeps it alive.
|
|
590
|
+
|
|
591
|
+
The wrapper has ``cache_clear()``, ``cache_info()`` (a `CacheInfo`),
|
|
592
|
+
``cache_delete(*args, **kwargs)``, ``cache_contains(*args, **kwargs)``,
|
|
593
|
+
``cache_get(*args, **kwargs)`` and ``cache_parameters()``. ``cache_contains`` and
|
|
594
|
+
``cache_get`` look an entry up without computing, counting a hit or miss, or
|
|
595
|
+
refreshing its LRU position. All three take the decorated function's full argument
|
|
596
|
+
list, so on a method pass the instance too: ``obj.method.cache_get(obj, x)``.
|
|
597
|
+
|
|
598
|
+
While ``python_wrap.testing.disable_caching()`` is active the cache is neither read
|
|
599
|
+
nor written. Listeners registered with `python_wrap.events` receive ``cache.hit``,
|
|
600
|
+
``cache.miss``, ``cache.evicted`` (field ``reason``: ``"lru"`` or ``"ttl"``),
|
|
601
|
+
``cache.stale`` (a stale value was served and a refresh started) and
|
|
602
|
+
``cache.refresh_failed`` (field ``exception``).
|
|
603
|
+
|
|
604
|
+
Args:
|
|
605
|
+
func: The function (when used bare).
|
|
606
|
+
maxsize: Maximum entries; least recently used are evicted. None is unbounded.
|
|
607
|
+
With ``per_instance`` the limit applies to each instance.
|
|
608
|
+
ttl: Seconds (or a ``timedelta``) an entry stays valid. None never expires. May
|
|
609
|
+
also be a callable ``ttl(result) -> float | None`` evaluated when a result
|
|
610
|
+
is stored (None: never expires; zero or negative: not stored).
|
|
611
|
+
stale_ttl: Seconds (or a ``timedelta``) after ``ttl`` during which the stale
|
|
612
|
+
value is still served while a background refresh runs. Requires ``ttl``.
|
|
613
|
+
typed: If True, ``f(1)`` and ``f(1.0)`` are cached separately.
|
|
614
|
+
key: Custom ``key(*args, **kwargs)`` returning a hashable cache key. It receives
|
|
615
|
+
every argument (``self`` too; with ``per_instance`` it should leave it out).
|
|
616
|
+
cache_exceptions: Exception class (or tuple of classes) whose instances are
|
|
617
|
+
cached and re-raised like results. ``asyncio.CancelledError`` never is.
|
|
618
|
+
exception_ttl: Seconds a cached exception stays valid. Defaults to ``ttl``; it
|
|
619
|
+
must be given when ``ttl`` is a callable and ``cache_exceptions`` is set.
|
|
620
|
+
store: A `CacheStore` to keep entries in instead of the built-in LRU dict.
|
|
621
|
+
per_instance: For methods: one cache per instance, held through a weak
|
|
622
|
+
reference, keyed by the arguments after ``self``.
|
|
623
|
+
logger: Logger (or name) for failed background refreshes.
|
|
624
|
+
|
|
625
|
+
Returns:
|
|
626
|
+
The wrapped function, or a decorator when called with options.
|
|
627
|
+
|
|
628
|
+
Raises:
|
|
629
|
+
ValueError: If ``maxsize`` is below 1, a ttl is not positive, or ``stale_ttl``
|
|
630
|
+
is given without ``ttl``.
|
|
631
|
+
TypeError: For a non-exception in ``cache_exceptions``, an object that is not a
|
|
632
|
+
`CacheStore`, ``store`` combined with ``maxsize`` or ``per_instance``, or
|
|
633
|
+
``per_instance`` on a function without parameters. At call time, for an
|
|
634
|
+
unhashable argument (see above) or, with ``per_instance``, an instance that
|
|
635
|
+
cannot be weakly referenced.
|
|
636
|
+
|
|
637
|
+
Example:
|
|
638
|
+
>>> @memoize(maxsize=128, ttl=60)
|
|
639
|
+
... def square(n):
|
|
640
|
+
... return n * n
|
|
641
|
+
>>> square(4), square(n=4)
|
|
642
|
+
(16, 16)
|
|
643
|
+
>>> square.cache_info().hits
|
|
644
|
+
1
|
|
645
|
+
"""
|
|
646
|
+
if maxsize is not None and maxsize < 1:
|
|
647
|
+
raise ValueError("maxsize must be a positive integer or None")
|
|
648
|
+
if store is not None:
|
|
649
|
+
if maxsize is not None:
|
|
650
|
+
raise TypeError("maxsize only applies to the built-in store; not with store=")
|
|
651
|
+
if per_instance:
|
|
652
|
+
raise TypeError("per_instance=True uses the built-in store; not with store=")
|
|
653
|
+
if not isinstance(store, CacheStore):
|
|
654
|
+
raise TypeError("store must implement CacheStore: get, set, delete, clear and __len__")
|
|
655
|
+
ttl_fn: Callable[[Any], float | None] | None = None
|
|
656
|
+
ttl_s: float | None = None
|
|
657
|
+
if callable(ttl):
|
|
658
|
+
ttl_fn = ttl
|
|
659
|
+
else:
|
|
660
|
+
ttl_s = _ttl_seconds(ttl)
|
|
661
|
+
if stale_ttl is not None and ttl is None:
|
|
662
|
+
raise ValueError("stale_ttl requires ttl")
|
|
663
|
+
stale_s = _ttl_seconds(stale_ttl)
|
|
664
|
+
exc_types = _exception_types(cache_exceptions)
|
|
665
|
+
exc_ttl_s = _ttl_seconds(exception_ttl) if exception_ttl is not None else ttl_s
|
|
666
|
+
if exc_types and ttl_fn is not None and exception_ttl is None:
|
|
667
|
+
raise ValueError("exception_ttl is required when ttl is a callable")
|
|
668
|
+
lru = maxsize is not None
|
|
669
|
+
expiring = ttl_s is not None or ttl_fn is not None or exc_ttl_s is not None
|
|
670
|
+
builtin = store is None
|
|
671
|
+
ov = _core.overrides
|
|
672
|
+
|
|
673
|
+
if builtin:
|
|
674
|
+
|
|
675
|
+
def now() -> float:
|
|
676
|
+
return _core.monotonic()
|
|
677
|
+
else:
|
|
678
|
+
|
|
679
|
+
def now() -> float:
|
|
680
|
+
return _core.monotonic() + _WALL_OFFSET
|
|
681
|
+
|
|
682
|
+
def decorate(f: Callable[..., Any]) -> Any:
|
|
683
|
+
build_key = _make_key_builder(f, typed, key, skip_first=per_instance)
|
|
684
|
+
fast_n = _fast_arity(f, typed, key)
|
|
685
|
+
if per_instance and fast_n == 0:
|
|
686
|
+
raise TypeError("per_instance=True needs a method (a function taking self)")
|
|
687
|
+
name = _core.qualname(f)
|
|
688
|
+
main: Any = _LocalStore(maxsize, expiring) if builtin else store
|
|
689
|
+
# per_instance: id(instance) -> (weak reference, that instance's store)
|
|
690
|
+
instances: dict[int, tuple[weakref.ref[Any], _LocalStore]] = {}
|
|
691
|
+
# In-flight tasks are per event loop: a task cannot be awaited from another loop.
|
|
692
|
+
inflight: weakref.WeakKeyDictionary[
|
|
693
|
+
asyncio.AbstractEventLoop, dict[Hashable, asyncio.Future[Any]]
|
|
694
|
+
] = weakref.WeakKeyDictionary()
|
|
695
|
+
refreshing: set[Hashable] = set() # sync keys being refreshed in a thread
|
|
696
|
+
background: set[asyncio.Future[Any]] = set() # strong refs to refresh tasks
|
|
697
|
+
lock = threading.Lock()
|
|
698
|
+
guard: Any = lock if builtin else contextlib.nullcontext()
|
|
699
|
+
hits = misses = gen = 0
|
|
700
|
+
|
|
701
|
+
def key_of(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Hashable:
|
|
702
|
+
if len(args) == fast_n and not kwargs:
|
|
703
|
+
k = args[1:] if per_instance else args
|
|
704
|
+
try:
|
|
705
|
+
hash(k)
|
|
706
|
+
except TypeError:
|
|
707
|
+
pass
|
|
708
|
+
else:
|
|
709
|
+
return k
|
|
710
|
+
return build_key(args, kwargs)
|
|
711
|
+
|
|
712
|
+
def bucket_of(args: tuple[Any, ...], create: bool = True) -> Any:
|
|
713
|
+
if not per_instance:
|
|
714
|
+
return main
|
|
715
|
+
if not args:
|
|
716
|
+
if not create:
|
|
717
|
+
return None
|
|
718
|
+
raise TypeError(
|
|
719
|
+
f"memoize(per_instance=True): call {name} with the instance as its "
|
|
720
|
+
"first positional argument"
|
|
721
|
+
)
|
|
722
|
+
obj = args[0]
|
|
723
|
+
i = id(obj)
|
|
724
|
+
slot = instances.get(i)
|
|
725
|
+
if slot is not None and slot[0]() is obj:
|
|
726
|
+
return slot[1]
|
|
727
|
+
if not create:
|
|
728
|
+
return None
|
|
729
|
+
|
|
730
|
+
def forget(ref: weakref.ref[Any], i: int = i) -> None:
|
|
731
|
+
# Runs while the instance is being destroyed, maybe in a thread that holds
|
|
732
|
+
# ``lock``: so no locking here (dict operations are atomic). The id cannot
|
|
733
|
+
# be reused before this callback has run.
|
|
734
|
+
current = instances.get(i)
|
|
735
|
+
if current is not None and current[0] is ref:
|
|
736
|
+
instances.pop(i, None)
|
|
737
|
+
|
|
738
|
+
try:
|
|
739
|
+
ref = weakref.ref(obj, forget)
|
|
740
|
+
except TypeError:
|
|
741
|
+
raise TypeError(
|
|
742
|
+
f"memoize(per_instance=True) needs weak-referenceable instances, but "
|
|
743
|
+
f"{type(obj).__name__!r} is not (a __slots__ class must also list "
|
|
744
|
+
"'__weakref__')"
|
|
745
|
+
) from None
|
|
746
|
+
with lock:
|
|
747
|
+
slot = instances.get(i)
|
|
748
|
+
if slot is not None and slot[0]() is obj:
|
|
749
|
+
return slot[1]
|
|
750
|
+
bucket = _LocalStore(maxsize, expiring)
|
|
751
|
+
instances[i] = (ref, bucket)
|
|
752
|
+
return bucket
|
|
753
|
+
|
|
754
|
+
def live_buckets() -> list[_LocalStore]:
|
|
755
|
+
# Caller holds the lock. A collected instance may drop its slot meanwhile.
|
|
756
|
+
while True:
|
|
757
|
+
try:
|
|
758
|
+
slots = list(instances.values())
|
|
759
|
+
except RuntimeError: # pragma: no cover - changed size during iteration
|
|
760
|
+
continue
|
|
761
|
+
return [b for ref, b in slots if ref() is not None]
|
|
762
|
+
|
|
763
|
+
def fetch(b: Any, k: Hashable) -> Any:
|
|
764
|
+
# Caller holds ``guard``.
|
|
765
|
+
entry = b.get(k)
|
|
766
|
+
if entry is None:
|
|
767
|
+
return None
|
|
768
|
+
expires = entry[1]
|
|
769
|
+
if expires is not None and now() >= expires:
|
|
770
|
+
b.delete(k)
|
|
771
|
+
return _EXPIRED
|
|
772
|
+
if lru:
|
|
773
|
+
b.data.move_to_end(k)
|
|
774
|
+
return entry
|
|
775
|
+
|
|
776
|
+
def is_stale(entry: Any) -> bool:
|
|
777
|
+
return entry[1] is not None and not entry[2] and now() >= entry[3]
|
|
778
|
+
|
|
779
|
+
def report_expired() -> None:
|
|
780
|
+
_core.emit("cache.evicted", name, reason="ttl")
|
|
781
|
+
|
|
782
|
+
def save(b: Any, k: Hashable, value: Any, g: int, is_exc: bool = False) -> None:
|
|
783
|
+
if is_exc:
|
|
784
|
+
seconds = exc_ttl_s
|
|
785
|
+
elif ttl_fn is not None:
|
|
786
|
+
seconds = ttl_fn(value)
|
|
787
|
+
if seconds is not None and seconds <= 0:
|
|
788
|
+
return # not worth storing
|
|
789
|
+
else:
|
|
790
|
+
seconds = ttl_s
|
|
791
|
+
keep = seconds
|
|
792
|
+
if seconds is not None and stale_s is not None and not is_exc:
|
|
793
|
+
keep = seconds + stale_s
|
|
794
|
+
evicted = 0
|
|
795
|
+
with guard:
|
|
796
|
+
if g != gen:
|
|
797
|
+
return # cache_clear() ran while computing
|
|
798
|
+
t = now()
|
|
799
|
+
fresh = None if seconds is None else t + seconds
|
|
800
|
+
expires = None if keep is None else t + keep
|
|
801
|
+
entry = (value, expires, is_exc, fresh)
|
|
802
|
+
if builtin:
|
|
803
|
+
evicted = b.put(k, entry)
|
|
804
|
+
else:
|
|
805
|
+
b.set(k, entry, keep)
|
|
806
|
+
if evicted and _core.listeners:
|
|
807
|
+
for _ in range(evicted >> 32):
|
|
808
|
+
report_expired()
|
|
809
|
+
for _ in range(evicted & 0xFFFFFFFF):
|
|
810
|
+
_core.emit("cache.evicted", name, reason="lru")
|
|
811
|
+
|
|
812
|
+
def refresh_failed(exc: BaseException) -> None:
|
|
813
|
+
_core.get_logger(logger).warning(
|
|
814
|
+
"memoize: background refresh of %s failed; keeping the stale value",
|
|
815
|
+
name,
|
|
816
|
+
exc_info=exc,
|
|
817
|
+
)
|
|
818
|
+
_core.emit("cache.refresh_failed", name, exception=exc)
|
|
819
|
+
|
|
820
|
+
def refresh_in_thread(
|
|
821
|
+
b: Any, k: Hashable, args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
822
|
+
) -> None:
|
|
823
|
+
rk = (id(b), k) if per_instance else k
|
|
824
|
+
with lock:
|
|
825
|
+
if rk in refreshing:
|
|
826
|
+
return
|
|
827
|
+
refreshing.add(rk)
|
|
828
|
+
g = gen
|
|
829
|
+
|
|
830
|
+
def run() -> None:
|
|
831
|
+
try:
|
|
832
|
+
save(b, k, f(*args, **kwargs), g)
|
|
833
|
+
except Exception as exc:
|
|
834
|
+
refresh_failed(exc)
|
|
835
|
+
finally:
|
|
836
|
+
with lock:
|
|
837
|
+
refreshing.discard(rk)
|
|
838
|
+
|
|
839
|
+
thread = threading.Thread(
|
|
840
|
+
target=contextvars.copy_context().run,
|
|
841
|
+
args=(run,),
|
|
842
|
+
name=f"python_wrap-refresh-{name}",
|
|
843
|
+
daemon=True,
|
|
844
|
+
)
|
|
845
|
+
try:
|
|
846
|
+
thread.start()
|
|
847
|
+
except Exception as exc: # e.g. "can't start new thread": keep serving stale
|
|
848
|
+
with lock:
|
|
849
|
+
refreshing.discard(rk)
|
|
850
|
+
refresh_failed(exc)
|
|
851
|
+
return
|
|
852
|
+
if _core.listeners:
|
|
853
|
+
_core.emit("cache.stale", name)
|
|
854
|
+
|
|
855
|
+
def refresh_in_task(
|
|
856
|
+
b: Any, k: Hashable, args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
857
|
+
) -> None:
|
|
858
|
+
rk = (id(b), k) if per_instance else k
|
|
859
|
+
with lock:
|
|
860
|
+
loop = asyncio.get_running_loop()
|
|
861
|
+
loop_inflight = inflight.setdefault(loop, {})
|
|
862
|
+
if rk in loop_inflight:
|
|
863
|
+
return # a refresh (or a miss) for this key is already running
|
|
864
|
+
g = gen
|
|
865
|
+
shared_fut = loop_inflight[rk] = _shared_future()
|
|
866
|
+
|
|
867
|
+
async def run() -> Any:
|
|
868
|
+
try:
|
|
869
|
+
result = await f(*args, **kwargs)
|
|
870
|
+
save(b, k, result, g)
|
|
871
|
+
except Exception as exc:
|
|
872
|
+
refresh_failed(exc)
|
|
873
|
+
raise
|
|
874
|
+
return result
|
|
875
|
+
|
|
876
|
+
def done(_t: asyncio.Future[Any] | None) -> None:
|
|
877
|
+
with lock:
|
|
878
|
+
if loop_inflight.get(rk) is shared_fut:
|
|
879
|
+
del loop_inflight[rk]
|
|
880
|
+
|
|
881
|
+
task = _start(run(), shared_fut, done)
|
|
882
|
+
background.add(task)
|
|
883
|
+
task.add_done_callback(background.discard)
|
|
884
|
+
if _core.listeners:
|
|
885
|
+
_core.emit("cache.stale", name)
|
|
886
|
+
|
|
887
|
+
def cache_clear() -> None:
|
|
888
|
+
nonlocal hits, misses, gen
|
|
889
|
+
with lock:
|
|
890
|
+
if builtin:
|
|
891
|
+
main.clear()
|
|
892
|
+
instances.clear()
|
|
893
|
+
inflight.clear()
|
|
894
|
+
hits = misses = 0
|
|
895
|
+
gen += 1
|
|
896
|
+
if not builtin:
|
|
897
|
+
main.clear()
|
|
898
|
+
|
|
899
|
+
def cache_info() -> CacheInfo:
|
|
900
|
+
expired = 0
|
|
901
|
+
with lock:
|
|
902
|
+
if per_instance:
|
|
903
|
+
buckets = live_buckets()
|
|
904
|
+
for b in buckets:
|
|
905
|
+
expired += b.sweep()
|
|
906
|
+
size = sum(len(b) for b in buckets)
|
|
907
|
+
elif builtin:
|
|
908
|
+
expired = main.sweep()
|
|
909
|
+
size = len(main)
|
|
910
|
+
info = (hits, misses)
|
|
911
|
+
if not builtin:
|
|
912
|
+
size = len(main)
|
|
913
|
+
if _core.listeners:
|
|
914
|
+
for _ in range(expired):
|
|
915
|
+
report_expired()
|
|
916
|
+
return CacheInfo(info[0], info[1], maxsize, size)
|
|
917
|
+
|
|
918
|
+
def peek(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
|
|
919
|
+
b = bucket_of(args, create=False)
|
|
920
|
+
if b is None:
|
|
921
|
+
return None
|
|
922
|
+
k = key_of(args, kwargs)
|
|
923
|
+
with guard:
|
|
924
|
+
entry = b.get(k)
|
|
925
|
+
if entry is None or (entry[1] is not None and now() >= entry[1]):
|
|
926
|
+
return None
|
|
927
|
+
return entry
|
|
928
|
+
|
|
929
|
+
def cache_delete(*args: Any, **kwargs: Any) -> bool:
|
|
930
|
+
b = bucket_of(args, create=False)
|
|
931
|
+
if b is None:
|
|
932
|
+
return False
|
|
933
|
+
k = key_of(args, kwargs)
|
|
934
|
+
with guard:
|
|
935
|
+
return bool(b.delete(k))
|
|
936
|
+
|
|
937
|
+
def cache_contains(*args: Any, **kwargs: Any) -> bool:
|
|
938
|
+
return peek(args, kwargs) is not None
|
|
939
|
+
|
|
940
|
+
def cache_get(*args: Any, **kwargs: Any) -> Any:
|
|
941
|
+
entry = peek(args, kwargs)
|
|
942
|
+
if entry is None:
|
|
943
|
+
raise KeyError("no cached entry for these arguments")
|
|
944
|
+
if entry[2]:
|
|
945
|
+
raise entry[0]
|
|
946
|
+
return entry[0]
|
|
947
|
+
|
|
948
|
+
def cache_parameters() -> dict[str, Any]:
|
|
949
|
+
return {
|
|
950
|
+
"maxsize": maxsize,
|
|
951
|
+
"ttl": ttl_fn if ttl_fn is not None else ttl_s,
|
|
952
|
+
"stale_ttl": stale_s,
|
|
953
|
+
"typed": typed,
|
|
954
|
+
"cache_exceptions": exc_types,
|
|
955
|
+
"exception_ttl": exc_ttl_s,
|
|
956
|
+
"store": store,
|
|
957
|
+
"per_instance": per_instance,
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
data: Any = main.data if builtin else None
|
|
961
|
+
data_get: Any = data.get if builtin else None
|
|
962
|
+
|
|
963
|
+
def fetch_main(k: Hashable) -> Any:
|
|
964
|
+
# `fetch` for the default store, inlined for speed. Caller holds the lock.
|
|
965
|
+
entry = data_get(k)
|
|
966
|
+
if entry is None:
|
|
967
|
+
return None
|
|
968
|
+
expires = entry[1]
|
|
969
|
+
if expires is not None and _core.monotonic() >= expires:
|
|
970
|
+
del data[k]
|
|
971
|
+
return _EXPIRED
|
|
972
|
+
if lru:
|
|
973
|
+
data.move_to_end(k)
|
|
974
|
+
return entry
|
|
975
|
+
|
|
976
|
+
async def amiss(b: Any, k: Hashable, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
|
|
977
|
+
# The async miss path: share one in-flight task per key and event loop.
|
|
978
|
+
nonlocal hits, misses
|
|
979
|
+
rk = (id(b), k) if per_instance else k
|
|
980
|
+
with lock:
|
|
981
|
+
loop = asyncio.get_running_loop()
|
|
982
|
+
loop_inflight = inflight.setdefault(loop, {})
|
|
983
|
+
shared_fut = loop_inflight.get(rk)
|
|
984
|
+
if shared_fut is None:
|
|
985
|
+
misses += 1
|
|
986
|
+
g = gen
|
|
987
|
+
shared = False
|
|
988
|
+
shared_fut = loop_inflight[rk] = _shared_future()
|
|
989
|
+
else:
|
|
990
|
+
hits += 1
|
|
991
|
+
shared = True
|
|
992
|
+
if not shared:
|
|
993
|
+
|
|
994
|
+
async def run() -> Any:
|
|
995
|
+
try:
|
|
996
|
+
result = await f(*args, **kwargs)
|
|
997
|
+
except exc_types as exc:
|
|
998
|
+
if not isinstance(exc, asyncio.CancelledError):
|
|
999
|
+
save(b, k, exc, g, True)
|
|
1000
|
+
raise
|
|
1001
|
+
save(b, k, result, g)
|
|
1002
|
+
return result
|
|
1003
|
+
|
|
1004
|
+
def done(_t: asyncio.Future[Any] | None) -> None:
|
|
1005
|
+
with lock:
|
|
1006
|
+
if loop_inflight.get(rk) is shared_fut:
|
|
1007
|
+
del loop_inflight[rk]
|
|
1008
|
+
|
|
1009
|
+
_start(run(), shared_fut, done)
|
|
1010
|
+
if _core.listeners:
|
|
1011
|
+
_core.emit("cache.hit" if shared else "cache.miss", name)
|
|
1012
|
+
# one cancelled caller must not cancel the shared computation
|
|
1013
|
+
return await _await_shared(shared_fut)
|
|
1014
|
+
|
|
1015
|
+
if _core.is_async(f) and builtin and not per_instance:
|
|
1016
|
+
|
|
1017
|
+
@functools.wraps(f)
|
|
1018
|
+
async def awrapper(*args: Any, **kwargs: Any) -> Any:
|
|
1019
|
+
nonlocal hits
|
|
1020
|
+
if ov.disable_caching:
|
|
1021
|
+
return await f(*args, **kwargs)
|
|
1022
|
+
k = args if len(args) == fast_n and not kwargs else build_key(args, kwargs)
|
|
1023
|
+
try:
|
|
1024
|
+
with lock:
|
|
1025
|
+
entry = fetch_main(k)
|
|
1026
|
+
if entry:
|
|
1027
|
+
hits += 1
|
|
1028
|
+
except TypeError:
|
|
1029
|
+
if k is not args:
|
|
1030
|
+
raise
|
|
1031
|
+
k = build_key(args, kwargs)
|
|
1032
|
+
with lock:
|
|
1033
|
+
entry = fetch_main(k)
|
|
1034
|
+
if entry:
|
|
1035
|
+
hits += 1
|
|
1036
|
+
if entry:
|
|
1037
|
+
if _core.listeners:
|
|
1038
|
+
_core.emit("cache.hit", name)
|
|
1039
|
+
if stale_s is not None and is_stale(entry):
|
|
1040
|
+
refresh_in_task(main, k, args, kwargs)
|
|
1041
|
+
if entry[2]:
|
|
1042
|
+
raise entry[0]
|
|
1043
|
+
return entry[0]
|
|
1044
|
+
if _core.listeners and entry is not None:
|
|
1045
|
+
report_expired()
|
|
1046
|
+
return await amiss(main, k, args, kwargs)
|
|
1047
|
+
|
|
1048
|
+
wrapper: Any = awrapper
|
|
1049
|
+
elif _core.is_async(f):
|
|
1050
|
+
|
|
1051
|
+
@functools.wraps(f)
|
|
1052
|
+
async def agwrapper(*args: Any, **kwargs: Any) -> Any:
|
|
1053
|
+
nonlocal hits
|
|
1054
|
+
if ov.disable_caching:
|
|
1055
|
+
return await f(*args, **kwargs)
|
|
1056
|
+
b = bucket_of(args)
|
|
1057
|
+
k = key_of(args, kwargs)
|
|
1058
|
+
with guard:
|
|
1059
|
+
entry = fetch(b, k)
|
|
1060
|
+
if entry:
|
|
1061
|
+
with lock:
|
|
1062
|
+
hits += 1
|
|
1063
|
+
if _core.listeners:
|
|
1064
|
+
_core.emit("cache.hit", name)
|
|
1065
|
+
if stale_s is not None and is_stale(entry):
|
|
1066
|
+
refresh_in_task(b, k, args, kwargs)
|
|
1067
|
+
if entry[2]:
|
|
1068
|
+
raise entry[0]
|
|
1069
|
+
return entry[0]
|
|
1070
|
+
if _core.listeners and entry is not None:
|
|
1071
|
+
report_expired()
|
|
1072
|
+
return await amiss(b, k, args, kwargs)
|
|
1073
|
+
|
|
1074
|
+
wrapper = agwrapper
|
|
1075
|
+
elif builtin and not per_instance:
|
|
1076
|
+
|
|
1077
|
+
@functools.wraps(f)
|
|
1078
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
1079
|
+
nonlocal hits, misses
|
|
1080
|
+
if ov.disable_caching:
|
|
1081
|
+
return f(*args, **kwargs)
|
|
1082
|
+
k = args if len(args) == fast_n and not kwargs else build_key(args, kwargs)
|
|
1083
|
+
try:
|
|
1084
|
+
with lock:
|
|
1085
|
+
entry = data_get(k)
|
|
1086
|
+
if entry is not None and entry[1] is None:
|
|
1087
|
+
# hot path: a live entry that never expires
|
|
1088
|
+
if lru:
|
|
1089
|
+
data.move_to_end(k)
|
|
1090
|
+
hits += 1
|
|
1091
|
+
else:
|
|
1092
|
+
entry = fetch_main(k)
|
|
1093
|
+
if entry:
|
|
1094
|
+
hits += 1
|
|
1095
|
+
else:
|
|
1096
|
+
misses += 1
|
|
1097
|
+
g = gen
|
|
1098
|
+
except TypeError:
|
|
1099
|
+
if k is not args:
|
|
1100
|
+
raise
|
|
1101
|
+
k = build_key(args, kwargs)
|
|
1102
|
+
with lock:
|
|
1103
|
+
entry = fetch_main(k)
|
|
1104
|
+
if entry:
|
|
1105
|
+
hits += 1
|
|
1106
|
+
else:
|
|
1107
|
+
misses += 1
|
|
1108
|
+
g = gen
|
|
1109
|
+
if entry:
|
|
1110
|
+
if _core.listeners:
|
|
1111
|
+
_core.emit("cache.hit", name)
|
|
1112
|
+
if stale_s is not None and is_stale(entry):
|
|
1113
|
+
refresh_in_thread(main, k, args, kwargs)
|
|
1114
|
+
if entry[2]:
|
|
1115
|
+
raise entry[0]
|
|
1116
|
+
return entry[0]
|
|
1117
|
+
if _core.listeners:
|
|
1118
|
+
if entry is not None:
|
|
1119
|
+
report_expired()
|
|
1120
|
+
_core.emit("cache.miss", name)
|
|
1121
|
+
try:
|
|
1122
|
+
result = f(*args, **kwargs)
|
|
1123
|
+
except exc_types as exc:
|
|
1124
|
+
save(main, k, exc, g, True)
|
|
1125
|
+
raise
|
|
1126
|
+
save(main, k, result, g)
|
|
1127
|
+
return result
|
|
1128
|
+
|
|
1129
|
+
else:
|
|
1130
|
+
|
|
1131
|
+
@functools.wraps(f)
|
|
1132
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
1133
|
+
nonlocal hits, misses
|
|
1134
|
+
if ov.disable_caching:
|
|
1135
|
+
return f(*args, **kwargs)
|
|
1136
|
+
b = bucket_of(args)
|
|
1137
|
+
k = key_of(args, kwargs)
|
|
1138
|
+
with guard:
|
|
1139
|
+
entry = fetch(b, k)
|
|
1140
|
+
with lock:
|
|
1141
|
+
if entry:
|
|
1142
|
+
hits += 1
|
|
1143
|
+
else:
|
|
1144
|
+
misses += 1
|
|
1145
|
+
g = gen
|
|
1146
|
+
if entry:
|
|
1147
|
+
if _core.listeners:
|
|
1148
|
+
_core.emit("cache.hit", name)
|
|
1149
|
+
if stale_s is not None and is_stale(entry):
|
|
1150
|
+
refresh_in_thread(b, k, args, kwargs)
|
|
1151
|
+
if entry[2]:
|
|
1152
|
+
raise entry[0]
|
|
1153
|
+
return entry[0]
|
|
1154
|
+
if _core.listeners:
|
|
1155
|
+
if entry is not None:
|
|
1156
|
+
report_expired()
|
|
1157
|
+
_core.emit("cache.miss", name)
|
|
1158
|
+
try:
|
|
1159
|
+
result = f(*args, **kwargs)
|
|
1160
|
+
except exc_types as exc:
|
|
1161
|
+
save(b, k, exc, g, True)
|
|
1162
|
+
raise
|
|
1163
|
+
save(b, k, result, g)
|
|
1164
|
+
return result
|
|
1165
|
+
|
|
1166
|
+
wrapper.cache_clear = cache_clear
|
|
1167
|
+
wrapper.cache_info = cache_info
|
|
1168
|
+
wrapper.cache_delete = cache_delete
|
|
1169
|
+
wrapper.cache_contains = cache_contains
|
|
1170
|
+
wrapper.cache_get = cache_get
|
|
1171
|
+
wrapper.cache_parameters = cache_parameters
|
|
1172
|
+
return wrapper
|
|
1173
|
+
|
|
1174
|
+
return decorate if func is None else decorate(func)
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def cache(
|
|
1178
|
+
duration: datetime.timedelta | float, *, maxsize: int | None = None
|
|
1179
|
+
) -> Callable[[Callable[P, R]], Memoized[P, R]]:
|
|
1180
|
+
"""Cache results for ``duration``; an alias of ``memoize(ttl=duration, maxsize=maxsize)``.
|
|
1181
|
+
|
|
1182
|
+
Kept for v0.1 compatibility. Prefer ``memoize(ttl=...)`` in new code; note this name
|
|
1183
|
+
also clashes with `functools.cache`. Every decorated function gets its own
|
|
1184
|
+
store, even when one ``cache(...)`` result is applied to several functions.
|
|
1185
|
+
|
|
1186
|
+
Args:
|
|
1187
|
+
duration: Seconds (or a ``timedelta``) an entry stays valid.
|
|
1188
|
+
maxsize: Maximum entries (LRU). None is unbounded.
|
|
1189
|
+
|
|
1190
|
+
Returns:
|
|
1191
|
+
A decorator.
|
|
1192
|
+
|
|
1193
|
+
Example:
|
|
1194
|
+
>>> @cache(datetime.timedelta(minutes=5))
|
|
1195
|
+
... def load(): ...
|
|
1196
|
+
"""
|
|
1197
|
+
|
|
1198
|
+
def decorator(func: Callable[P, R]) -> Memoized[P, R]:
|
|
1199
|
+
return memoize(ttl=duration, maxsize=maxsize)(func)
|
|
1200
|
+
|
|
1201
|
+
return decorator
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
# --------------------------------------------------------------------------- once
|
|
1205
|
+
|
|
1206
|
+
_RETRY: Any = _Tag("retry")
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
class _Flight:
|
|
1210
|
+
"""An async `once` run in progress, shared by every thread and event loop.
|
|
1211
|
+
|
|
1212
|
+
``shared`` belongs to the owning loop (callers on that loop await it, so they see
|
|
1213
|
+
the task's own outcome, cancellation included); ``done`` is a thread-level future
|
|
1214
|
+
that callers on other loops await through `asyncio.wrap_future`. A cancelled run
|
|
1215
|
+
resolves ``done`` with ``_RETRY`` so those callers start a new run themselves.
|
|
1216
|
+
"""
|
|
1217
|
+
|
|
1218
|
+
__slots__ = ("done", "loop", "shared")
|
|
1219
|
+
|
|
1220
|
+
def __init__(self, loop: asyncio.AbstractEventLoop, shared: asyncio.Future[Any]) -> None:
|
|
1221
|
+
self.loop = loop
|
|
1222
|
+
self.shared = shared
|
|
1223
|
+
self.done: concurrent.futures.Future[Any] = concurrent.futures.Future()
|
|
1224
|
+
|
|
1225
|
+
def publish(self, task: asyncio.Future[Any] | None) -> None:
|
|
1226
|
+
if self.done.done():
|
|
1227
|
+
return
|
|
1228
|
+
if task is None or task.cancelled():
|
|
1229
|
+
self.abandon()
|
|
1230
|
+
elif (exc := task.exception()) is not None:
|
|
1231
|
+
self.done.set_exception(exc)
|
|
1232
|
+
else:
|
|
1233
|
+
self.done.set_result(task.result())
|
|
1234
|
+
|
|
1235
|
+
def abandon(self) -> None:
|
|
1236
|
+
if not self.done.done():
|
|
1237
|
+
self.done.set_result(_RETRY)
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
@overload
|
|
1241
|
+
def once(func: Callable[P, R], /) -> Once[P, R]: ...
|
|
1242
|
+
@overload
|
|
1243
|
+
def once(func: None = None, /) -> Callable[[Callable[P, R]], Once[P, R]]: ...
|
|
1244
|
+
def once(func: Callable[..., Any] | None = None, /) -> Any:
|
|
1245
|
+
"""Run the function a single time; later calls return the first result.
|
|
1246
|
+
|
|
1247
|
+
Arguments of later calls are ignored. Thread-safe (double-checked lock) and
|
|
1248
|
+
async-safe: concurrent first awaits share one run, even across threads that each
|
|
1249
|
+
run their own event loop (it runs once per process). If the first call raises, the
|
|
1250
|
+
function is not marked done and the next call tries again. The wrapper has
|
|
1251
|
+
``.reset()`` and a ``.called`` flag. While ``python_wrap.testing.disable_caching()``
|
|
1252
|
+
is active it runs the function on every call and stores nothing.
|
|
1253
|
+
|
|
1254
|
+
Args:
|
|
1255
|
+
func: The function (the decorator is used bare).
|
|
1256
|
+
|
|
1257
|
+
Returns:
|
|
1258
|
+
The wrapped function.
|
|
1259
|
+
|
|
1260
|
+
Example:
|
|
1261
|
+
>>> @once
|
|
1262
|
+
... def setup():
|
|
1263
|
+
... return object()
|
|
1264
|
+
>>> setup() is setup()
|
|
1265
|
+
True
|
|
1266
|
+
"""
|
|
1267
|
+
|
|
1268
|
+
def decorate(f: Callable[..., Any]) -> Any:
|
|
1269
|
+
lock = threading.RLock()
|
|
1270
|
+
# "flight" is the run in progress (shared by every thread and event loop).
|
|
1271
|
+
state: dict[str, Any] = {"done": False, "result": None, "gen": 0, "flight": None}
|
|
1272
|
+
|
|
1273
|
+
if _core.is_async(f):
|
|
1274
|
+
|
|
1275
|
+
def finish(flight: _Flight, task: asyncio.Future[Any] | None) -> None:
|
|
1276
|
+
with lock:
|
|
1277
|
+
if state["flight"] is flight:
|
|
1278
|
+
state["flight"] = None
|
|
1279
|
+
flight.publish(task) # under the lock: abandon() may race from a thread
|
|
1280
|
+
|
|
1281
|
+
@functools.wraps(f)
|
|
1282
|
+
async def awrapper(*args: Any, **kwargs: Any) -> Any:
|
|
1283
|
+
if _core.overrides.disable_caching:
|
|
1284
|
+
return await f(*args, **kwargs)
|
|
1285
|
+
while True:
|
|
1286
|
+
if state["done"]:
|
|
1287
|
+
return state["result"]
|
|
1288
|
+
with lock:
|
|
1289
|
+
if state["done"]:
|
|
1290
|
+
return state["result"]
|
|
1291
|
+
loop = asyncio.get_running_loop()
|
|
1292
|
+
flight: _Flight | None = state["flight"]
|
|
1293
|
+
if flight is not None and flight.loop.is_closed():
|
|
1294
|
+
flight.abandon() # its loop died: waiters elsewhere retry
|
|
1295
|
+
flight = None
|
|
1296
|
+
owner = flight is None
|
|
1297
|
+
if flight is None:
|
|
1298
|
+
flight = state["flight"] = _Flight(loop, _shared_future())
|
|
1299
|
+
gen = state["gen"]
|
|
1300
|
+
if owner:
|
|
1301
|
+
|
|
1302
|
+
async def run(gen: int = gen) -> Any:
|
|
1303
|
+
result = await f(*args, **kwargs)
|
|
1304
|
+
with lock:
|
|
1305
|
+
if gen == state["gen"]: # reset() did not run meanwhile
|
|
1306
|
+
state.update(done=True, result=result)
|
|
1307
|
+
wrapper.called = True
|
|
1308
|
+
return result
|
|
1309
|
+
|
|
1310
|
+
_start(run(), flight.shared, functools.partial(finish, flight))
|
|
1311
|
+
if flight.loop is loop:
|
|
1312
|
+
return await _await_shared(flight.shared)
|
|
1313
|
+
# Another event loop runs it: wait on the thread-level future.
|
|
1314
|
+
result = await _await_shared(asyncio.wrap_future(flight.done))
|
|
1315
|
+
if result is not _RETRY:
|
|
1316
|
+
return result
|
|
1317
|
+
|
|
1318
|
+
wrapper: Any = awrapper
|
|
1319
|
+
else:
|
|
1320
|
+
|
|
1321
|
+
@functools.wraps(f)
|
|
1322
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
1323
|
+
if _core.overrides.disable_caching:
|
|
1324
|
+
return f(*args, **kwargs)
|
|
1325
|
+
if state["done"]:
|
|
1326
|
+
return state["result"]
|
|
1327
|
+
with lock:
|
|
1328
|
+
if not state["done"]:
|
|
1329
|
+
state["result"] = f(*args, **kwargs)
|
|
1330
|
+
state["done"] = True
|
|
1331
|
+
wrapper.called = True
|
|
1332
|
+
return state["result"]
|
|
1333
|
+
|
|
1334
|
+
wrapper.called = False
|
|
1335
|
+
|
|
1336
|
+
def reset() -> None:
|
|
1337
|
+
with lock:
|
|
1338
|
+
state.update(done=False, result=None, gen=state["gen"] + 1, flight=None)
|
|
1339
|
+
wrapper.called = False
|
|
1340
|
+
|
|
1341
|
+
wrapper.reset = reset
|
|
1342
|
+
return wrapper
|
|
1343
|
+
|
|
1344
|
+
return decorate if func is None else decorate(func)
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
# ------------------------------------------------------------------ cached_property
|
|
1348
|
+
|
|
1349
|
+
_STATE_KEY = "_python_wrap_cached_property_state"
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def _new_state() -> _PropState:
|
|
1353
|
+
return _PropState(0)
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
class _PropState:
|
|
1357
|
+
"""Per-instance bookkeeping for every `cached_property` of that instance.
|
|
1358
|
+
|
|
1359
|
+
Lives in the instance ``__dict__``. It remembers ``id(owner)`` so a shallow copy of the
|
|
1360
|
+
instance (which shares this object) notices and starts with its own state, and it
|
|
1361
|
+
pickles as an empty state so locks never end up in a pickle.
|
|
1362
|
+
"""
|
|
1363
|
+
|
|
1364
|
+
__slots__ = ("entries", "gens", "locks", "owner_id", "tasks")
|
|
1365
|
+
|
|
1366
|
+
def __init__(self, owner_id: int) -> None:
|
|
1367
|
+
self.owner_id = owner_id
|
|
1368
|
+
self.entries: dict[str, tuple[Any, float | None]] = {}
|
|
1369
|
+
self.locks: dict[str, threading.RLock] = {}
|
|
1370
|
+
self.gens: dict[str, int] = {}
|
|
1371
|
+
self.tasks: weakref.WeakKeyDictionary[
|
|
1372
|
+
asyncio.AbstractEventLoop, dict[str, asyncio.Future[Any]]
|
|
1373
|
+
] = weakref.WeakKeyDictionary()
|
|
1374
|
+
|
|
1375
|
+
def __reduce__(self) -> tuple[Callable[[], _PropState], tuple[()]]:
|
|
1376
|
+
return (_new_state, ())
|
|
1377
|
+
|
|
1378
|
+
def lock_for(self, name: str) -> threading.RLock:
|
|
1379
|
+
lock = self.locks.get(name)
|
|
1380
|
+
if lock is None:
|
|
1381
|
+
lock = self.locks.setdefault(name, threading.RLock()) # atomic: one winner
|
|
1382
|
+
return lock
|
|
1383
|
+
|
|
1384
|
+
def drop(self, name: str) -> bool:
|
|
1385
|
+
with self.lock_for(name):
|
|
1386
|
+
self.gens[name] = self.gens.get(name, 0) + 1 # discards results still in flight
|
|
1387
|
+
for per_loop in tuple(self.tasks.values()):
|
|
1388
|
+
per_loop.pop(name, None)
|
|
1389
|
+
return self.entries.pop(name, None) is not None
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
def _state_of(instance: object, *, create: bool = True) -> _PropState | None:
|
|
1393
|
+
try:
|
|
1394
|
+
d = instance.__dict__
|
|
1395
|
+
except AttributeError:
|
|
1396
|
+
raise TypeError(
|
|
1397
|
+
f"cached_property needs an instance __dict__, but {type(instance).__name__!r} "
|
|
1398
|
+
"has none (a __slots__ class must also list '__dict__')"
|
|
1399
|
+
) from None
|
|
1400
|
+
state: _PropState | None = d.get(_STATE_KEY)
|
|
1401
|
+
if state is None or state.owner_id != id(instance):
|
|
1402
|
+
if not create:
|
|
1403
|
+
return None
|
|
1404
|
+
state = d.setdefault(_STATE_KEY, _PropState(id(instance)))
|
|
1405
|
+
if state.owner_id != id(instance): # a copy still holding its original's state
|
|
1406
|
+
state = d[_STATE_KEY] = _PropState(id(instance))
|
|
1407
|
+
return state
|
|
1408
|
+
|
|
1409
|
+
|
|
1410
|
+
class CachedProperty(Generic[T]):
|
|
1411
|
+
"""The descriptor returned by `cached_property`; ``obj.prop`` has type ``T``.
|
|
1412
|
+
|
|
1413
|
+
For an ``async def`` getter ``T`` is ``Awaitable[X]``: ``await obj.prop`` gives ``X``.
|
|
1414
|
+
Instances are read-only data descriptors: assigning to the attribute raises
|
|
1415
|
+
``AttributeError``; ``del obj.prop`` invalidates the cached value.
|
|
1416
|
+
"""
|
|
1417
|
+
|
|
1418
|
+
def __init__(self, func: Callable[[Any], Any], ttl: float | datetime.timedelta | None) -> None:
|
|
1419
|
+
self.func = func
|
|
1420
|
+
self.ttl = _ttl_seconds(ttl)
|
|
1421
|
+
self.name = func.__name__
|
|
1422
|
+
self._named = False
|
|
1423
|
+
self.is_async = _core.is_async(func)
|
|
1424
|
+
self.__doc__ = func.__doc__
|
|
1425
|
+
self.__wrapped__ = func
|
|
1426
|
+
|
|
1427
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
1428
|
+
if self._named and name != self.name:
|
|
1429
|
+
raise TypeError(
|
|
1430
|
+
f"cannot use one cached_property under two names ({self.name!r} and {name!r})"
|
|
1431
|
+
)
|
|
1432
|
+
self.name = name
|
|
1433
|
+
self._named = True
|
|
1434
|
+
|
|
1435
|
+
@overload
|
|
1436
|
+
def __get__(self, instance: None, owner: type | None = None) -> CachedProperty[T]: ...
|
|
1437
|
+
@overload
|
|
1438
|
+
def __get__(self, instance: object, owner: type | None = None) -> T: ...
|
|
1439
|
+
def __get__(self, instance: object | None, owner: type | None = None) -> Any:
|
|
1440
|
+
if instance is None:
|
|
1441
|
+
return self
|
|
1442
|
+
if _core.overrides.disable_caching:
|
|
1443
|
+
return self.func(instance)
|
|
1444
|
+
state = _state_of(instance)
|
|
1445
|
+
assert state is not None
|
|
1446
|
+
if self.is_async:
|
|
1447
|
+
return self._aget(instance, state)
|
|
1448
|
+
entry = state.entries.get(self.name)
|
|
1449
|
+
if entry is not None and (entry[1] is None or _core.monotonic() < entry[1]):
|
|
1450
|
+
return entry[0]
|
|
1451
|
+
with state.lock_for(self.name):
|
|
1452
|
+
entry = state.entries.get(self.name)
|
|
1453
|
+
if entry is not None and (entry[1] is None or _core.monotonic() < entry[1]):
|
|
1454
|
+
return entry[0]
|
|
1455
|
+
gen = state.gens.get(self.name, 0)
|
|
1456
|
+
value = self.func(instance)
|
|
1457
|
+
if state.gens.get(self.name, 0) == gen: # not invalidated by the getter itself
|
|
1458
|
+
self._store(state, value)
|
|
1459
|
+
return value
|
|
1460
|
+
|
|
1461
|
+
def _store(self, state: _PropState, value: Any) -> None:
|
|
1462
|
+
expires = None if self.ttl is None else _core.monotonic() + self.ttl
|
|
1463
|
+
state.entries[self.name] = (value, expires)
|
|
1464
|
+
|
|
1465
|
+
async def _aget(self, instance: object, state: _PropState) -> Any:
|
|
1466
|
+
name = self.name
|
|
1467
|
+
lock = state.lock_for(name)
|
|
1468
|
+
with lock:
|
|
1469
|
+
entry = state.entries.get(name)
|
|
1470
|
+
if entry is not None and (entry[1] is None or _core.monotonic() < entry[1]):
|
|
1471
|
+
return entry[0]
|
|
1472
|
+
per_loop = state.tasks.setdefault(asyncio.get_running_loop(), {})
|
|
1473
|
+
shared = per_loop.get(name)
|
|
1474
|
+
owner = shared is None
|
|
1475
|
+
if shared is None:
|
|
1476
|
+
shared = per_loop[name] = _shared_future()
|
|
1477
|
+
gen = state.gens.get(name, 0)
|
|
1478
|
+
if owner:
|
|
1479
|
+
|
|
1480
|
+
async def run() -> Any:
|
|
1481
|
+
value = await self.func(instance)
|
|
1482
|
+
with lock:
|
|
1483
|
+
if state.gens.get(name, 0) == gen: # not invalidated meanwhile
|
|
1484
|
+
self._store(state, value)
|
|
1485
|
+
return value
|
|
1486
|
+
|
|
1487
|
+
def done(_t: asyncio.Future[Any] | None) -> None:
|
|
1488
|
+
with lock:
|
|
1489
|
+
if per_loop.get(name) is shared:
|
|
1490
|
+
del per_loop[name]
|
|
1491
|
+
|
|
1492
|
+
_start(run(), shared, done)
|
|
1493
|
+
return await _await_shared(shared)
|
|
1494
|
+
|
|
1495
|
+
def __delete__(self, instance: object) -> None:
|
|
1496
|
+
state = _state_of(instance, create=False)
|
|
1497
|
+
if state is not None:
|
|
1498
|
+
state.drop(self.name)
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
class _CachedPropertyDecorator(Protocol):
|
|
1502
|
+
@overload
|
|
1503
|
+
def __call__(self, func: Callable[[Any], Awaitable[T]], /) -> CachedProperty[Awaitable[T]]: ...
|
|
1504
|
+
@overload
|
|
1505
|
+
def __call__(self, func: Callable[[Any], T], /) -> CachedProperty[T]: ...
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
@overload
|
|
1509
|
+
def cached_property(func: Callable[[Any], Awaitable[T]], /) -> CachedProperty[Awaitable[T]]: ...
|
|
1510
|
+
@overload
|
|
1511
|
+
def cached_property(func: Callable[[Any], T], /) -> CachedProperty[T]: ...
|
|
1512
|
+
@overload
|
|
1513
|
+
def cached_property(
|
|
1514
|
+
func: None = None, /, *, ttl: float | datetime.timedelta | None = None
|
|
1515
|
+
) -> _CachedPropertyDecorator: ...
|
|
1516
|
+
def cached_property(
|
|
1517
|
+
func: Callable[[Any], Any] | None = None,
|
|
1518
|
+
/,
|
|
1519
|
+
*,
|
|
1520
|
+
ttl: float | datetime.timedelta | None = None,
|
|
1521
|
+
) -> Any:
|
|
1522
|
+
"""Compute an attribute once per instance and cache it, like `functools.cached_property`.
|
|
1523
|
+
|
|
1524
|
+
Differences from the standard library version:
|
|
1525
|
+
|
|
1526
|
+
* ``ttl`` makes the value expire (seconds or a ``timedelta``, read through the
|
|
1527
|
+
package clock so ``fake_clock`` works).
|
|
1528
|
+
* Thread-safe without a global lock: each instance and property has its own lock,
|
|
1529
|
+
created lazily, so a slow getter blocks only other readers of the same value.
|
|
1530
|
+
* ``async def`` getters work: ``await obj.prop``. Concurrent awaits on one instance
|
|
1531
|
+
share a single task per event loop, and a failure is not cached.
|
|
1532
|
+
* Explicit invalidation: ``del obj.prop`` or `invalidate` ``(obj, "prop")``.
|
|
1533
|
+
The attribute is read-only otherwise.
|
|
1534
|
+
* Needs an instance ``__dict__``; on a ``__slots__`` class without one, access raises
|
|
1535
|
+
``TypeError``. Values are kept in a hidden ``__dict__`` entry that copies and
|
|
1536
|
+
pickles do not carry over (a copy recomputes).
|
|
1537
|
+
|
|
1538
|
+
With ``python_wrap.testing.disable_caching()`` active the getter runs on every access.
|
|
1539
|
+
|
|
1540
|
+
Args:
|
|
1541
|
+
func: The getter (when used bare).
|
|
1542
|
+
ttl: Seconds (or a ``timedelta``) the value stays valid. None never expires.
|
|
1543
|
+
|
|
1544
|
+
Returns:
|
|
1545
|
+
A `CachedProperty` descriptor, or a decorator when called with options.
|
|
1546
|
+
|
|
1547
|
+
Raises:
|
|
1548
|
+
ValueError: If ``ttl`` is not positive.
|
|
1549
|
+
TypeError: On access, if the instance has no ``__dict__``.
|
|
1550
|
+
|
|
1551
|
+
Example:
|
|
1552
|
+
>>> class Report:
|
|
1553
|
+
... @cached_property(ttl=60)
|
|
1554
|
+
... def totals(self):
|
|
1555
|
+
... return sum(range(10))
|
|
1556
|
+
>>> r = Report()
|
|
1557
|
+
>>> r.totals
|
|
1558
|
+
45
|
|
1559
|
+
>>> del r.totals # next access recomputes
|
|
1560
|
+
"""
|
|
1561
|
+
|
|
1562
|
+
_ttl_seconds(ttl) # fail early, at decoration
|
|
1563
|
+
|
|
1564
|
+
def decorate(f: Callable[[Any], Any]) -> CachedProperty[Any]:
|
|
1565
|
+
return CachedProperty(f, ttl)
|
|
1566
|
+
|
|
1567
|
+
return decorate if func is None else decorate(func)
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
def invalidate(instance: object, *names: str) -> int:
|
|
1571
|
+
"""Forget cached `cached_property` values of ``instance``.
|
|
1572
|
+
|
|
1573
|
+
Args:
|
|
1574
|
+
instance: The object that owns the properties.
|
|
1575
|
+
*names: Property names to invalidate. With none given, every cached property of
|
|
1576
|
+
the instance is invalidated.
|
|
1577
|
+
|
|
1578
|
+
Returns:
|
|
1579
|
+
The number of cached values that were dropped.
|
|
1580
|
+
|
|
1581
|
+
Raises:
|
|
1582
|
+
AttributeError: If a name is not a `cached_property` of the instance's class.
|
|
1583
|
+
TypeError: If the instance has no ``__dict__``.
|
|
1584
|
+
|
|
1585
|
+
Example:
|
|
1586
|
+
>>> class C:
|
|
1587
|
+
... @cached_property
|
|
1588
|
+
... def x(self):
|
|
1589
|
+
... return 1
|
|
1590
|
+
>>> c = C(); c.x
|
|
1591
|
+
1
|
|
1592
|
+
>>> invalidate(c, "x")
|
|
1593
|
+
1
|
|
1594
|
+
"""
|
|
1595
|
+
for name in names:
|
|
1596
|
+
for klass in type(instance).__mro__:
|
|
1597
|
+
attr = klass.__dict__.get(name)
|
|
1598
|
+
if attr is not None:
|
|
1599
|
+
if isinstance(attr, CachedProperty):
|
|
1600
|
+
break
|
|
1601
|
+
raise AttributeError(f"{name!r} is not a cached_property")
|
|
1602
|
+
else:
|
|
1603
|
+
raise AttributeError(f"{type(instance).__name__!r} has no cached_property {name!r}")
|
|
1604
|
+
state = _state_of(instance, create=False)
|
|
1605
|
+
if state is None:
|
|
1606
|
+
return 0
|
|
1607
|
+
if names:
|
|
1608
|
+
targets = names
|
|
1609
|
+
else:
|
|
1610
|
+
targets = tuple(
|
|
1611
|
+
set(state.entries).union(*(set(per_loop) for per_loop in tuple(state.tasks.values())))
|
|
1612
|
+
)
|
|
1613
|
+
return sum(state.drop(n) for n in targets)
|