veilrender 0.1.0__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.
@@ -0,0 +1,1023 @@
1
+ # /// zerodep
2
+ # version = "0.2.4"
3
+ # deps = []
4
+ # tier = "subsystem"
5
+ # category = "storage"
6
+ # note = "Install/update via `zerodep add cache`"
7
+ # ///
8
+ """Zero-dependency caching with TTL, eviction policies, and async support.
9
+
10
+ stdlib only, Python 3.10+.
11
+
12
+ Part of zerodep: https://github.com/Oaklight/zerodep
13
+ Copyright (c) 2026 Peng Ding. MIT License.
14
+
15
+ Provides: LRUCache, FIFOCache, LFUCache, TTLCache cache classes (MutableMapping),
16
+ ``cached`` decorator with sync/async auto-detection, and convenience decorators
17
+ ``lru_cache``, ``ttl_cache``, ``lfu_cache``, ``fifo_cache``.
18
+
19
+ Does NOT implement: TLRUCache (per-item TTL function), MRUCache, RRCache,
20
+ ``cachedmethod``, ``condition`` parameter, multiple backends, serialization.
21
+
22
+ Example::
23
+
24
+ from cache import lru_cache, ttl_cache, LRUCache, cached
25
+
26
+ # Convenience decorator (like functools.lru_cache but with more policies)
27
+ @lru_cache(maxsize=256)
28
+ def fibonacci(n):
29
+ return n if n < 2 else fibonacci(n - 1) + fibonacci(n - 2)
30
+
31
+ # TTL decorator
32
+ @ttl_cache(maxsize=100, ttl=60)
33
+ def fetch_config(key):
34
+ return load_from_db(key)
35
+
36
+ # Async support (auto-detected)
37
+ @lru_cache(maxsize=128)
38
+ async def fetch_data(url):
39
+ return await async_get(url)
40
+
41
+ # Direct cache class usage
42
+ cache = LRUCache(maxsize=1024)
43
+ cache["key"] = "value"
44
+
45
+ # Advanced: cached() with explicit lock
46
+ import threading
47
+ @cached(LRUCache(128), lock=threading.Lock(), info=True)
48
+ def thread_safe_compute(x):
49
+ return expensive(x)
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import collections
55
+ import collections.abc
56
+ import functools
57
+ import inspect
58
+ import time
59
+ from typing import Any, Callable
60
+
61
+ # ── Key Functions ──────────────────────────────────────────────────────────
62
+
63
+
64
+ class _HashedTuple(tuple):
65
+ """Tuple subclass that caches its hash value for fast repeated lookups."""
66
+
67
+ __hashvalue: int | None = None
68
+
69
+ def __hash__(self, hash=tuple.__hash__): # type: ignore[override]
70
+ hashvalue = self.__hashvalue
71
+ if hashvalue is None:
72
+ self.__hashvalue = hashvalue = hash(self)
73
+ return hashvalue
74
+
75
+ def __add__(self, other, add=tuple.__add__):
76
+ return _HashedTuple(add(self, other))
77
+
78
+ def __radd__(self, other, add=tuple.__add__):
79
+ return _HashedTuple(add(other, self))
80
+
81
+
82
+ _KWMARK = (_HashedTuple,)
83
+
84
+
85
+ def hashkey(*args: Any, **kwargs: Any) -> _HashedTuple:
86
+ """Return a cache key for the given positional and keyword arguments.
87
+
88
+ This is the default key function for all cache decorators.
89
+ """
90
+ if kwargs:
91
+ return _HashedTuple(args + _KWMARK + tuple(sorted(kwargs.items())))
92
+ return _HashedTuple(args)
93
+
94
+
95
+ def methodkey(_self: Any, *args: Any, **kwargs: Any) -> _HashedTuple:
96
+ """Like ``hashkey`` but ignores the first positional argument (``self``).
97
+
98
+ Use as the key function when caching instance methods.
99
+ """
100
+ return hashkey(*args, **kwargs)
101
+
102
+
103
+ def typedkey(*args: Any, **kwargs: Any) -> _HashedTuple:
104
+ """Like ``hashkey`` but includes type information, so ``f(3)`` and
105
+ ``f(3.0)`` are cached separately.
106
+ """
107
+ if kwargs:
108
+ sorted_items = tuple(sorted(kwargs.items()))
109
+ return _HashedTuple(
110
+ args
111
+ + _KWMARK
112
+ + sorted_items
113
+ + tuple(type(v) for v in args)
114
+ + tuple(type(v) for _, v in sorted_items)
115
+ )
116
+ return _HashedTuple(args + tuple(type(v) for v in args))
117
+
118
+
119
+ # ── Cache Info ─────────────────────────────────────────────────────────────
120
+
121
+ CacheInfo = collections.namedtuple(
122
+ "CacheInfo", ["hits", "misses", "maxsize", "currsize"]
123
+ )
124
+
125
+ # ── Cache Base Class ───────────────────────────────────────────────────────
126
+
127
+
128
+ class _DefaultSize:
129
+ """Lightweight size tracker that always returns 1 for any key."""
130
+
131
+ __slots__ = ()
132
+
133
+ def __getitem__(self, _: Any) -> int:
134
+ return 1
135
+
136
+ def __setitem__(self, _: Any, __: Any) -> None:
137
+ pass
138
+
139
+ def pop(self, _: Any, *args: Any) -> int:
140
+ return 1
141
+
142
+ def clear(self) -> None:
143
+ pass
144
+
145
+
146
+ class Cache(collections.abc.MutableMapping):
147
+ """Mutable mapping to serve as a simple cache or cache base class.
148
+
149
+ Subclasses must override ``popitem`` to implement a specific eviction
150
+ policy. The base class evicts arbitrary items (dict order).
151
+
152
+ Args:
153
+ maxsize: Maximum capacity (item count or weighted size).
154
+ getsizeof: Optional callable returning the size of a value.
155
+ Defaults to 1 per item.
156
+ """
157
+
158
+ __marker = object()
159
+
160
+ def __init__(
161
+ self,
162
+ maxsize: int | float,
163
+ getsizeof: Callable[[Any], int] | None = None,
164
+ ):
165
+ if maxsize < 0:
166
+ raise ValueError("maxsize must be non-negative")
167
+ self.__data: dict[Any, Any] = {}
168
+ self.__currsize: int | float = 0
169
+ self.__maxsize = maxsize
170
+ if getsizeof is not None:
171
+ self.__size: dict | _DefaultSize = {}
172
+ self.getsizeof = getsizeof # type: ignore[assignment]
173
+ else:
174
+ self.__size = _DefaultSize()
175
+
176
+ def __repr__(self) -> str:
177
+ cls = self.__class__.__name__
178
+ return (
179
+ f"{cls}({self.__data!r}, "
180
+ f"maxsize={self.__maxsize}, currsize={self.__currsize})"
181
+ )
182
+
183
+ def __getitem__(self, key: Any) -> Any:
184
+ try:
185
+ return self.__data[key]
186
+ except KeyError:
187
+ return self.__missing__(key)
188
+
189
+ def __setitem__(self, key: Any, value: Any) -> None:
190
+ maxsize = self.__maxsize
191
+ size = self.getsizeof(value)
192
+ if size > maxsize:
193
+ raise ValueError("value too large")
194
+ if key in self.__data:
195
+ diffsize = size - self.__size[key]
196
+ else:
197
+ diffsize = size
198
+ while self.__currsize + diffsize > maxsize:
199
+ self.popitem()
200
+ if key in self.__data:
201
+ # Key might have been evicted during popitem loop
202
+ self.__currsize -= self.__size[key]
203
+ self.__data[key] = value
204
+ self.__size[key] = size
205
+ self.__currsize += size
206
+
207
+ def __delitem__(self, key: Any) -> None:
208
+ size = self.__size.pop(key)
209
+ del self.__data[key]
210
+ self.__currsize -= size
211
+
212
+ def __contains__(self, key: object) -> bool:
213
+ return key in self.__data
214
+
215
+ def __missing__(self, key: Any) -> Any:
216
+ raise KeyError(key)
217
+
218
+ def __iter__(self):
219
+ return iter(self.__data)
220
+
221
+ def __len__(self) -> int:
222
+ return len(self.__data)
223
+
224
+ @staticmethod
225
+ def getsizeof(value: Any) -> int:
226
+ """Return the size of *value*. Defaults to 1."""
227
+ return 1
228
+
229
+ @property
230
+ def maxsize(self) -> int | float:
231
+ """Maximum cache capacity."""
232
+ return self.__maxsize
233
+
234
+ @property
235
+ def currsize(self) -> int | float:
236
+ """Current cache size (sum of item sizes)."""
237
+ return self.__currsize
238
+
239
+ def get(self, key: Any, default: Any = None) -> Any:
240
+ if key in self:
241
+ return self[key]
242
+ return default
243
+
244
+ def pop(self, key: Any, default: Any = __marker) -> Any:
245
+ if key in self:
246
+ value = self[key]
247
+ del self[key]
248
+ return value
249
+ if default is self.__marker:
250
+ raise KeyError(key)
251
+ return default
252
+
253
+ def setdefault(self, key: Any, default: Any = None) -> Any:
254
+ if key in self:
255
+ return self[key]
256
+ try:
257
+ self[key] = default
258
+ except ValueError:
259
+ pass
260
+ return default
261
+
262
+ def popitem(self) -> tuple[Any, Any]:
263
+ """Remove and return an arbitrary ``(key, value)`` pair.
264
+
265
+ Subclasses override this to implement eviction policies.
266
+ """
267
+ try:
268
+ key = next(iter(self.__data))
269
+ except StopIteration:
270
+ raise KeyError(f"{type(self).__name__} is empty") from None
271
+ return key, self.pop(key)
272
+
273
+ def clear(self) -> None:
274
+ self.__data.clear()
275
+ self.__size.clear()
276
+ self.__currsize = 0
277
+
278
+
279
+ # ── FIFO Cache ─────────────────────────────────────────────────────────────
280
+
281
+
282
+ class FIFOCache(Cache):
283
+ """First In First Out (FIFO) cache implementation.
284
+
285
+ Evicts the oldest inserted item when the cache is full. Accessing an
286
+ item does **not** change its eviction priority.
287
+ """
288
+
289
+ def __init__(
290
+ self,
291
+ maxsize: int | float,
292
+ getsizeof: Callable[[Any], int] | None = None,
293
+ ):
294
+ super().__init__(maxsize, getsizeof)
295
+ self.__order: collections.OrderedDict[Any, None] = collections.OrderedDict()
296
+
297
+ def __setitem__(self, key: Any, value: Any) -> None:
298
+ if key in self:
299
+ del self.__order[key]
300
+ super().__setitem__(key, value)
301
+ self.__order[key] = None
302
+
303
+ def __delitem__(self, key: Any) -> None:
304
+ super().__delitem__(key)
305
+ del self.__order[key]
306
+
307
+ def popitem(self) -> tuple[Any, Any]:
308
+ """Remove and return the oldest inserted ``(key, value)`` pair."""
309
+ try:
310
+ key = next(iter(self.__order))
311
+ except StopIteration:
312
+ raise KeyError(f"{type(self).__name__} is empty") from None
313
+ return key, self.pop(key)
314
+
315
+ def clear(self) -> None:
316
+ super().clear()
317
+ self.__order.clear()
318
+
319
+
320
+ # ── LRU Cache ──────────────────────────────────────────────────────────────
321
+
322
+
323
+ class LRUCache(Cache):
324
+ """Least Recently Used (LRU) cache implementation.
325
+
326
+ Evicts the least recently accessed item when the cache is full.
327
+ Both reads and writes count as accesses.
328
+ """
329
+
330
+ def __init__(
331
+ self,
332
+ maxsize: int | float,
333
+ getsizeof: Callable[[Any], int] | None = None,
334
+ ):
335
+ super().__init__(maxsize, getsizeof)
336
+ self.__order: collections.OrderedDict[Any, None] = collections.OrderedDict()
337
+
338
+ def __getitem__(self, key: Any) -> Any:
339
+ # Bypass super().__getitem__ to avoid MRO dispatch overhead;
340
+ # call Cache.__getitem__ directly + inline move_to_end.
341
+ value = Cache.__getitem__(self, key)
342
+ self.__order.move_to_end(key)
343
+ return value
344
+
345
+ def __setitem__(self, key: Any, value: Any) -> None:
346
+ Cache.__setitem__(self, key, value)
347
+ try:
348
+ self.__order.move_to_end(key)
349
+ except KeyError:
350
+ self.__order[key] = None
351
+
352
+ def __delitem__(self, key: Any) -> None:
353
+ Cache.__delitem__(self, key)
354
+ del self.__order[key]
355
+
356
+ def popitem(self) -> tuple[Any, Any]:
357
+ """Remove and return the least recently used ``(key, value)`` pair."""
358
+ try:
359
+ key = next(iter(self.__order))
360
+ except StopIteration:
361
+ raise KeyError(f"{type(self).__name__} is empty") from None
362
+ return key, self.pop(key)
363
+
364
+ def clear(self) -> None:
365
+ Cache.clear(self)
366
+ self.__order.clear()
367
+
368
+
369
+ # ── LFU Cache ──────────────────────────────────────────────────────────────
370
+
371
+
372
+ class _LFUNode:
373
+ """Doubly-linked node for the LFU frequency list."""
374
+
375
+ __slots__ = ("count", "keys", "prev", "next")
376
+
377
+ def __init__(self, count: int = 0):
378
+ self.count = count
379
+ self.keys: set[Any] = set()
380
+ self.prev: _LFUNode = self
381
+ self.next: _LFUNode = self
382
+
383
+ def insert_after(self, node: _LFUNode) -> None:
384
+ """Insert *node* after this node."""
385
+ node.prev = self
386
+ node.next = self.next
387
+ self.next.prev = node
388
+ self.next = node
389
+
390
+ def unlink(self) -> None:
391
+ """Remove this node from the linked list."""
392
+ self.prev.next = self.next
393
+ self.next.prev = self.prev
394
+
395
+
396
+ class LFUCache(Cache):
397
+ """Least Frequently Used (LFU) cache implementation.
398
+
399
+ Evicts the least frequently accessed item when the cache is full.
400
+ All operations are O(1).
401
+ """
402
+
403
+ def __init__(
404
+ self,
405
+ maxsize: int | float,
406
+ getsizeof: Callable[[Any], int] | None = None,
407
+ ):
408
+ super().__init__(maxsize, getsizeof)
409
+ self.__root = _LFUNode() # sentinel
410
+ self.__links: dict[Any, _LFUNode] = {}
411
+
412
+ def __getitem__(self, key: Any) -> Any:
413
+ value = super().__getitem__(key)
414
+ self.__touch(key)
415
+ return value
416
+
417
+ def __setitem__(self, key: Any, value: Any) -> None:
418
+ existed = key in self
419
+ super().__setitem__(key, value)
420
+ if existed:
421
+ self.__touch(key)
422
+ else:
423
+ # New key starts at count=1
424
+ root = self.__root
425
+ first = root.next
426
+ if first is root or first.count != 1:
427
+ node = _LFUNode(count=1)
428
+ root.insert_after(node)
429
+ else:
430
+ node = first
431
+ node.keys.add(key)
432
+ self.__links[key] = node
433
+
434
+ def __delitem__(self, key: Any) -> None:
435
+ super().__delitem__(key)
436
+ node = self.__links.pop(key)
437
+ node.keys.discard(key)
438
+ if not node.keys:
439
+ node.unlink()
440
+
441
+ def __touch(self, key: Any) -> None:
442
+ """Increment frequency count for *key*."""
443
+ node = self.__links[key]
444
+ new_count = node.count + 1
445
+ nxt = node.next
446
+ if nxt is self.__root or nxt.count != new_count:
447
+ new_node = _LFUNode(count=new_count)
448
+ node.insert_after(new_node)
449
+ else:
450
+ new_node = nxt
451
+ new_node.keys.add(key)
452
+ node.keys.discard(key)
453
+ self.__links[key] = new_node
454
+ if not node.keys:
455
+ node.unlink()
456
+
457
+ def popitem(self) -> tuple[Any, Any]:
458
+ """Remove and return the least frequently used ``(key, value)`` pair."""
459
+ root = self.__root
460
+ node = root.next
461
+ if node is root:
462
+ raise KeyError(f"{type(self).__name__} is empty")
463
+ key = next(iter(node.keys))
464
+ # Bypass self.pop → self[key] → __touch to avoid wasteful
465
+ # frequency increment on a key that is about to be deleted.
466
+ value = Cache.__getitem__(self, key)
467
+ del self[key]
468
+ return key, value
469
+
470
+ def clear(self) -> None:
471
+ super().clear()
472
+ self.__links.clear()
473
+ self.__root.prev = self.__root
474
+ self.__root.next = self.__root
475
+
476
+
477
+ # ── TTL Cache ──────────────────────────────────────────────────────────────
478
+
479
+
480
+ class _Timer:
481
+ """Wraps a timer function with nestable time-freezing.
482
+
483
+ Used by TTLCache to ensure time consistency within a single operation.
484
+ """
485
+
486
+ __slots__ = ("_timer", "_nesting", "_time")
487
+
488
+ def __init__(self, timer: Callable[[], float] = time.monotonic):
489
+ self._timer = timer
490
+ self._nesting = 0
491
+ self._time: float = 0.0
492
+
493
+ def __call__(self) -> float:
494
+ if self._nesting == 0:
495
+ return self._timer()
496
+ return self._time
497
+
498
+ def __enter__(self) -> float:
499
+ if self._nesting == 0:
500
+ self._time = self._timer()
501
+ self._nesting += 1
502
+ return self._time
503
+
504
+ def __exit__(self, *exc: Any) -> None:
505
+ self._nesting -= 1
506
+
507
+
508
+ class _TTLLink:
509
+ """Doubly-linked node for TTLCache expiry ordering."""
510
+
511
+ __slots__ = ("key", "expires", "prev", "next")
512
+
513
+ def __init__(
514
+ self,
515
+ key: Any = None,
516
+ expires: float = 0.0,
517
+ ):
518
+ self.key = key
519
+ self.expires = expires
520
+ self.prev: _TTLLink = self
521
+ self.next: _TTLLink = self
522
+
523
+ def unlink(self) -> None:
524
+ self.prev.next = self.next
525
+ self.next.prev = self.prev
526
+
527
+
528
+ class TTLCache(LRUCache):
529
+ """LRU cache with per-item time-to-live (TTL).
530
+
531
+ Items are evicted when they expire (checked lazily on access) or when
532
+ the cache is full (LRU order).
533
+
534
+ Args:
535
+ maxsize: Maximum cache capacity.
536
+ ttl: Time-to-live in seconds for each item.
537
+ timer: Callable returning the current time (default: ``time.monotonic``).
538
+ getsizeof: Optional callable returning the size of a value.
539
+ """
540
+
541
+ def __init__(
542
+ self,
543
+ maxsize: int | float,
544
+ ttl: float,
545
+ *,
546
+ timer: Callable[[], float] = time.monotonic,
547
+ getsizeof: Callable[[Any], int] | None = None,
548
+ ):
549
+ LRUCache.__init__(self, maxsize, getsizeof)
550
+ self.__ttl = ttl
551
+ self.__timer = _Timer(timer)
552
+ self.__root = _TTLLink() # sentinel for expiry list
553
+ self.__links: dict[Any, _TTLLink] = {}
554
+
555
+ @property
556
+ def ttl(self) -> float:
557
+ """Time-to-live in seconds."""
558
+ return self.__ttl
559
+
560
+ @property
561
+ def timer(self) -> _Timer:
562
+ """The timer function."""
563
+ return self.__timer
564
+
565
+ def __contains__(self, key: object) -> bool:
566
+ link = self.__links.get(key)
567
+ if link is None:
568
+ return False
569
+ return self.__timer() < link.expires
570
+
571
+ def __getitem__(self, key: Any) -> Any:
572
+ link = self.__links.get(key)
573
+ if link is not None and self.__timer() >= link.expires:
574
+ del self[key]
575
+ return self.__missing__(key)
576
+ # Bypass LRUCache.__getitem__ → Cache.__getitem__ call chain;
577
+ # inline both to save one method-call and one dict lookup.
578
+ # Same direct-call pattern used by LFUCache.popitem.
579
+ value = Cache.__getitem__(self, key)
580
+ self._LRUCache__order.move_to_end(key) # type: ignore[attr-defined]
581
+ return value
582
+
583
+ def __setitem__(self, key: Any, value: Any) -> None:
584
+ with self.__timer as now:
585
+ self.expire(now)
586
+ LRUCache.__setitem__(self, key, value)
587
+ # Update or create expiry link
588
+ link = self.__links.get(key)
589
+ if link is not None:
590
+ link.unlink()
591
+ else:
592
+ link = _TTLLink()
593
+ self.__links[key] = link
594
+ link.key = key
595
+ link.expires = now + self.__ttl
596
+ # Append to end of expiry list (newest)
597
+ tail = self.__root.prev
598
+ link.prev = tail
599
+ link.next = self.__root
600
+ tail.next = link
601
+ self.__root.prev = link
602
+
603
+ def __delitem__(self, key: Any) -> None:
604
+ LRUCache.__delitem__(self, key)
605
+ link = self.__links.pop(key)
606
+ link.unlink()
607
+
608
+ def __iter__(self):
609
+ now = self.__timer()
610
+ for key in super().__iter__():
611
+ link = self.__links.get(key)
612
+ if link is not None and now < link.expires:
613
+ yield key
614
+
615
+ def __len__(self) -> int:
616
+ now = self.__timer()
617
+ # Single dict lookup via .get() instead of ``key in`` + ``[key]``.
618
+ links_get = self.__links.get
619
+ return sum(
620
+ 1
621
+ for key in super().__iter__()
622
+ if (link := links_get(key)) is not None and now < link.expires
623
+ )
624
+
625
+ @property
626
+ def currsize(self) -> int | float:
627
+ """Current size, excluding expired items."""
628
+ self.expire()
629
+ return super().currsize
630
+
631
+ def expire(self, time: float | None = None) -> list[tuple[Any, Any]]:
632
+ """Remove expired items and return them as a list of ``(key, value)`` pairs."""
633
+ if time is None:
634
+ time = self.__timer()
635
+ root = self.__root
636
+ expired: list[tuple[Any, Any]] = []
637
+ link = root.next
638
+ while link is not root and link.expires <= time:
639
+ nxt = link.next
640
+ key = link.key
641
+ try:
642
+ value = Cache.__getitem__(self, key)
643
+ expired.append((key, value))
644
+ del self[key]
645
+ except KeyError:
646
+ pass
647
+ link = nxt
648
+ return expired
649
+
650
+ def clear(self) -> None:
651
+ LRUCache.clear(self)
652
+ self.__links.clear()
653
+ self.__root.prev = self.__root
654
+ self.__root.next = self.__root
655
+
656
+
657
+ # ── Decorators ─────────────────────────────────────────────────────────────
658
+
659
+ _MISS = object() # sentinel for cache lookup miss
660
+
661
+
662
+ class _Stats:
663
+ """Mutable hit/miss counters shared by cache wrapper closures."""
664
+
665
+ __slots__ = ("hits", "misses")
666
+
667
+ def __init__(self) -> None:
668
+ self.hits = 0
669
+ self.misses = 0
670
+
671
+
672
+ def _get_cache_maxsize(
673
+ cache: collections.abc.MutableMapping,
674
+ ) -> int | float | None:
675
+ """Return maxsize if *cache* is a ``Cache`` instance, else ``None``."""
676
+ return cache.maxsize if isinstance(cache, Cache) else None
677
+
678
+
679
+ def _get_cache_currsize(cache: collections.abc.MutableMapping) -> int | float:
680
+ """Return current size of *cache*."""
681
+ return cache.currsize if isinstance(cache, Cache) else len(cache)
682
+
683
+
684
+ def _attach_cache_info(
685
+ wrapper: Callable,
686
+ cache: collections.abc.MutableMapping,
687
+ lock: Any | None,
688
+ stats: _Stats,
689
+ maxsize: int | float | None,
690
+ ) -> None:
691
+ """Attach ``cache_info()`` and ``cache_clear()`` methods to *wrapper*."""
692
+
693
+ def cache_info() -> CacheInfo:
694
+ return CacheInfo(stats.hits, stats.misses, maxsize, _get_cache_currsize(cache))
695
+
696
+ def cache_clear() -> None:
697
+ with lock if lock else _no_lock():
698
+ cache.clear()
699
+ stats.hits = stats.misses = 0
700
+
701
+ wrapper.cache_info = cache_info # type: ignore[attr-defined]
702
+ wrapper.cache_clear = cache_clear # type: ignore[attr-defined]
703
+
704
+
705
+ def _attach_noop_info(
706
+ wrapper: Callable,
707
+ stats: _Stats,
708
+ ) -> None:
709
+ """Attach ``cache_info()`` and ``cache_clear()`` for a no-op wrapper."""
710
+
711
+ def cache_info() -> CacheInfo:
712
+ return CacheInfo(stats.hits, stats.misses, None, 0)
713
+
714
+ def cache_clear() -> None:
715
+ stats.hits = stats.misses = 0
716
+
717
+ wrapper.cache_info = cache_info # type: ignore[attr-defined]
718
+ wrapper.cache_clear = cache_clear # type: ignore[attr-defined]
719
+
720
+
721
+ def _make_sync_wrapper(
722
+ fn: Callable,
723
+ cache: collections.abc.MutableMapping | None,
724
+ key: Callable,
725
+ lock: Any | None,
726
+ info: bool,
727
+ ) -> Callable:
728
+ """Build a synchronous caching wrapper."""
729
+ stats = _Stats()
730
+
731
+ if cache is None:
732
+
733
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
734
+ stats.misses += 1
735
+ return fn(*args, **kwargs)
736
+
737
+ if info:
738
+ _attach_noop_info(wrapper, stats)
739
+ return wrapper
740
+
741
+ # Pre-cache method references to avoid attribute lookup per call.
742
+ cache_getitem = cache.__getitem__
743
+ cache_setitem = cache.__setitem__
744
+ cache_setdefault = cache.setdefault
745
+
746
+ if lock is not None:
747
+
748
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
749
+ k = key(*args, **kwargs)
750
+ with lock:
751
+ try:
752
+ result = cache_getitem(k)
753
+ except KeyError:
754
+ result = _MISS
755
+ if result is not _MISS:
756
+ stats.hits += 1
757
+ return result
758
+ stats.misses += 1
759
+ v = fn(*args, **kwargs)
760
+ # Inline thread-safe store (was _cache_store_default).
761
+ with lock:
762
+ try:
763
+ return cache_setdefault(k, v)
764
+ except ValueError:
765
+ return v
766
+
767
+ else:
768
+
769
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
770
+ k = key(*args, **kwargs)
771
+ try:
772
+ result = cache_getitem(k)
773
+ except KeyError:
774
+ stats.misses += 1
775
+ v = fn(*args, **kwargs)
776
+ try:
777
+ cache_setitem(k, v)
778
+ except ValueError:
779
+ pass
780
+ return v
781
+ stats.hits += 1
782
+ return result
783
+
784
+ if info:
785
+ _attach_cache_info(wrapper, cache, lock, stats, _get_cache_maxsize(cache))
786
+
787
+ return wrapper
788
+
789
+
790
+ def _make_async_wrapper(
791
+ fn: Callable,
792
+ cache: collections.abc.MutableMapping | None,
793
+ key: Callable,
794
+ lock: Any | None,
795
+ info: bool,
796
+ ) -> Callable:
797
+ """Build an asynchronous caching wrapper."""
798
+ stats = _Stats()
799
+
800
+ if cache is None:
801
+
802
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
803
+ stats.misses += 1
804
+ return await fn(*args, **kwargs)
805
+
806
+ if info:
807
+ _attach_noop_info(wrapper, stats)
808
+ return wrapper
809
+
810
+ # Pre-cache method references to avoid attribute lookup per call.
811
+ cache_getitem = cache.__getitem__
812
+ cache_setitem = cache.__setitem__
813
+ cache_setdefault = cache.setdefault
814
+
815
+ if lock is not None:
816
+
817
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
818
+ k = key(*args, **kwargs)
819
+ async with lock:
820
+ try:
821
+ result = cache_getitem(k)
822
+ except KeyError:
823
+ result = _MISS
824
+ if result is not _MISS:
825
+ stats.hits += 1
826
+ return result
827
+ stats.misses += 1
828
+ v = await fn(*args, **kwargs)
829
+ # Inline thread-safe store (was _cache_store_default).
830
+ async with lock:
831
+ try:
832
+ return cache_setdefault(k, v)
833
+ except ValueError:
834
+ return v
835
+
836
+ else:
837
+
838
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
839
+ k = key(*args, **kwargs)
840
+ try:
841
+ result = cache_getitem(k)
842
+ except KeyError:
843
+ stats.misses += 1
844
+ v = await fn(*args, **kwargs)
845
+ try:
846
+ cache_setitem(k, v)
847
+ except ValueError:
848
+ pass
849
+ return v
850
+ stats.hits += 1
851
+ return result
852
+
853
+ if info:
854
+ _attach_cache_info(wrapper, cache, lock, stats, _get_cache_maxsize(cache))
855
+
856
+ return wrapper
857
+
858
+
859
+ class _no_lock:
860
+ """Dummy context manager when no lock is provided."""
861
+
862
+ def __enter__(self) -> None:
863
+ pass
864
+
865
+ def __exit__(self, *exc: Any) -> None:
866
+ pass
867
+
868
+
869
+ def cached(
870
+ cache: Cache | collections.abc.MutableMapping | None,
871
+ *,
872
+ key: Callable[..., Any] = hashkey,
873
+ lock: Any | None = None,
874
+ info: bool = False,
875
+ ) -> Callable:
876
+ """Decorator to wrap a function with a memoizing callable.
877
+
878
+ Args:
879
+ cache: A cache instance (any MutableMapping) or ``None`` to disable.
880
+ key: Key function (default ``hashkey``).
881
+ lock: A lock object (``threading.Lock`` for sync, ``asyncio.Lock``
882
+ for async). The lock is released while the wrapped function
883
+ executes.
884
+ info: If ``True``, attach ``cache_info()`` and ``cache_clear()``
885
+ methods to the wrapper.
886
+ """
887
+
888
+ def decorator(fn: Callable) -> Callable:
889
+ if inspect.iscoroutinefunction(fn):
890
+ wrapper = _make_async_wrapper(fn, cache, key, lock, info)
891
+ else:
892
+ wrapper = _make_sync_wrapper(fn, cache, key, lock, info)
893
+ wrapper.cache = cache # type: ignore[attr-defined]
894
+ wrapper.cache_key = key # type: ignore[attr-defined]
895
+ wrapper.cache_lock = lock # type: ignore[attr-defined]
896
+ return functools.update_wrapper(wrapper, fn)
897
+
898
+ return decorator
899
+
900
+
901
+ # ── Convenience Decorators ─────────────────────────────────────────────────
902
+
903
+
904
+ def lru_cache(
905
+ fn: Callable[..., Any] | None = None,
906
+ *,
907
+ maxsize: int = 128,
908
+ key: Callable[..., Any] = hashkey,
909
+ lock: Any | None = None,
910
+ info: bool = True,
911
+ ) -> Callable[..., Any]:
912
+ """LRU cache decorator. Auto-detects sync/async.
913
+
914
+ Unlike ``functools.lru_cache``, supports TTL (via ``ttl_cache``),
915
+ async functions, and explicit lock objects.
916
+
917
+ Args:
918
+ maxsize: Maximum number of cached results.
919
+ key: Key function (default ``hashkey``).
920
+ lock: Optional lock for thread/async safety.
921
+ info: Attach ``cache_info()``/``cache_clear()`` (default ``True``).
922
+ """
923
+
924
+ def decorator(fn: Callable) -> Callable:
925
+ c = LRUCache(maxsize)
926
+ return cached(c, key=key, lock=lock, info=info)(fn)
927
+
928
+ if fn is not None:
929
+ return decorator(fn)
930
+ return decorator
931
+
932
+
933
+ def fifo_cache(
934
+ fn: Callable[..., Any] | None = None,
935
+ *,
936
+ maxsize: int = 128,
937
+ key: Callable[..., Any] = hashkey,
938
+ lock: Any | None = None,
939
+ info: bool = True,
940
+ ) -> Callable[..., Any]:
941
+ """FIFO cache decorator. Auto-detects sync/async.
942
+
943
+ Evicts the oldest inserted item regardless of access pattern.
944
+ """
945
+
946
+ def decorator(fn: Callable) -> Callable:
947
+ c = FIFOCache(maxsize)
948
+ return cached(c, key=key, lock=lock, info=info)(fn)
949
+
950
+ if fn is not None:
951
+ return decorator(fn)
952
+ return decorator
953
+
954
+
955
+ def lfu_cache(
956
+ fn: Callable[..., Any] | None = None,
957
+ *,
958
+ maxsize: int = 128,
959
+ key: Callable[..., Any] = hashkey,
960
+ lock: Any | None = None,
961
+ info: bool = True,
962
+ ) -> Callable[..., Any]:
963
+ """LFU cache decorator. Auto-detects sync/async.
964
+
965
+ Evicts the least frequently accessed item.
966
+ """
967
+
968
+ def decorator(fn: Callable) -> Callable:
969
+ c = LFUCache(maxsize)
970
+ return cached(c, key=key, lock=lock, info=info)(fn)
971
+
972
+ if fn is not None:
973
+ return decorator(fn)
974
+ return decorator
975
+
976
+
977
+ def ttl_cache(
978
+ fn: Callable[..., Any] | None = None,
979
+ *,
980
+ maxsize: int = 128,
981
+ ttl: float = 600,
982
+ timer: Callable[[], float] = time.monotonic,
983
+ key: Callable[..., Any] = hashkey,
984
+ lock: Any | None = None,
985
+ info: bool = True,
986
+ ) -> Callable[..., Any]:
987
+ """TTL cache decorator. Auto-detects sync/async.
988
+
989
+ Items expire after *ttl* seconds (default 600 = 10 minutes).
990
+ Eviction follows LRU order when the cache is full.
991
+ """
992
+
993
+ def decorator(fn: Callable) -> Callable:
994
+ c = TTLCache(maxsize, ttl, timer=timer)
995
+ return cached(c, key=key, lock=lock, info=info)(fn)
996
+
997
+ if fn is not None:
998
+ return decorator(fn)
999
+ return decorator
1000
+
1001
+
1002
+ # ── Public API ─────────────────────────────────────────────────────────────
1003
+
1004
+ __all__ = [
1005
+ # Cache classes
1006
+ "Cache",
1007
+ "FIFOCache",
1008
+ "LFUCache",
1009
+ "LRUCache",
1010
+ "TTLCache",
1011
+ # Decorators
1012
+ "cached",
1013
+ "fifo_cache",
1014
+ "lfu_cache",
1015
+ "lru_cache",
1016
+ "ttl_cache",
1017
+ # Key functions
1018
+ "hashkey",
1019
+ "methodkey",
1020
+ "typedkey",
1021
+ # Stats
1022
+ "CacheInfo",
1023
+ ]