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/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)