cloud-dog-cache 0.2.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,84 @@
1
+ # Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """cloud_dog_cache — Platform caching package for LLM and tool call results.
16
+
17
+ Provides a transparent caching layer with configurable TTL, pluggable backends
18
+ (in-memory LRU, Redis), and event-based invalidation for context rebuilds,
19
+ config changes, and prompt template changes.
20
+
21
+ Usage::
22
+
23
+ from cloud_dog_cache import cached, CacheManager, get_cache_manager
24
+
25
+ @cached(ttl=3600, invalidate_on=["context_rebuild"])
26
+ async def generate_sql(query: str, context_hash: str, model: str) -> str:
27
+ ...
28
+
29
+ # Manual access
30
+ manager = get_cache_manager()
31
+ await manager.flush()
32
+ stats = await manager.stats()
33
+ """
34
+
35
+ __version__ = "0.3.0"
36
+
37
+ from cloud_dog_cache.manager import CacheManager, get_cache_manager, init_cache
38
+ from cloud_dog_cache.decorator import cached
39
+ from cloud_dog_cache.keys import cache_key, hash_text, hash_config
40
+ from cloud_dog_cache.stats import CacheStats
41
+ from cloud_dog_cache.models import CacheConfig, CacheEntry
42
+ from cloud_dog_cache.invalidation import invalidate_event
43
+ from cloud_dog_cache.runtime import init_cache_from_config
44
+ from cloud_dog_cache.api import create_cache_router
45
+ from cloud_dog_cache.access import (
46
+ AccessUrlConfig,
47
+ AccessUrlEntry,
48
+ URLAddressableCache,
49
+ create_access_url_router,
50
+ )
51
+ from cloud_dog_cache.memory import (
52
+ MemoryScope,
53
+ MemoryNamespace,
54
+ MemoryEntry,
55
+ MemoryStore,
56
+ VectorSearchAdapter,
57
+ DEFAULT_SCOPE_TTL,
58
+ )
59
+
60
+ __all__ = [
61
+ "AccessUrlConfig",
62
+ "AccessUrlEntry",
63
+ "CacheConfig",
64
+ "CacheEntry",
65
+ "CacheManager",
66
+ "CacheStats",
67
+ "DEFAULT_SCOPE_TTL",
68
+ "MemoryEntry",
69
+ "MemoryNamespace",
70
+ "MemoryScope",
71
+ "MemoryStore",
72
+ "URLAddressableCache",
73
+ "VectorSearchAdapter",
74
+ "cached",
75
+ "cache_key",
76
+ "create_access_url_router",
77
+ "create_cache_router",
78
+ "get_cache_manager",
79
+ "hash_config",
80
+ "hash_text",
81
+ "init_cache",
82
+ "init_cache_from_config",
83
+ "invalidate_event",
84
+ ]
@@ -0,0 +1,386 @@
1
+ # Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """URL-addressable cache extension for cloud_dog_cache.
5
+
6
+ This module provides an additive layer on top of :class:`CacheManager` that
7
+ exposes cached entries via a URL — either an unsigned key path (relying on
8
+ upstream auth) or an HMAC-signed URL with embedded expiry (for tokenless
9
+ downstream consumers such as image renderers, e-mail bodies, or remote
10
+ notification clients).
11
+
12
+ It is the platform replacement for the bespoke ``access_url`` semantic
13
+ historically implemented by ``ImageCacheManager`` in
14
+ ``notification-agent-mcp-server`` (see W28A-#A16-REPORT-2026-05-04 §2.B).
15
+
16
+ Key design properties:
17
+
18
+ * **Additive.** Pure new module. ``CacheManager`` is unchanged. Existing
19
+ ``cached``/``init_cache``/``get_cache_manager`` consumers see no behaviour
20
+ change.
21
+ * **TTL is independent.** The cache content TTL (how long the value lives in
22
+ the cache backend) is separate from the URL TTL (how long a signed URL is
23
+ valid). Callers can hold a long-lived cache entry but mint short-lived
24
+ access URLs against it.
25
+ * **Two URL modes.** Unsigned URLs are simple ``{base}/cache/access/{key}``
26
+ paths — the upstream caller is responsible for auth. Signed URLs append
27
+ ``?expires=<unix_ts>&sig=<hex>`` and are HMAC-SHA256 verifiable without
28
+ any per-request server lookup beyond the cache itself.
29
+ * **No new dependencies.** Uses only ``hmac``/``hashlib`` from stdlib.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import hashlib
35
+ import hmac
36
+ from dataclasses import dataclass, field
37
+ from datetime import datetime, timedelta, timezone
38
+ from typing import Any, Optional
39
+ from urllib.parse import parse_qs, quote, urlsplit
40
+
41
+ from cloud_dog_cache.manager import CacheManager
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Configuration & data models
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class AccessUrlConfig:
51
+ """Runtime configuration for the URL-addressable cache layer.
52
+
53
+ Attributes:
54
+ base_url: Public base URL where the cache router is mounted, e.g.
55
+ ``https://notificationagent0.cloud-dog.net``. The access path
56
+ ``/cache/access/{key}`` is appended.
57
+ signing_secret: Secret used for HMAC-SHA256 signing. When empty,
58
+ ``signed=True`` calls raise ``ValueError`` — services MUST
59
+ provide a secret to mint signed URLs. Reading the secret from
60
+ Vault and passing it here is the responsibility of the consuming
61
+ service.
62
+ default_url_ttl_seconds: Default validity window for signed URLs
63
+ when caller does not specify ``url_ttl``.
64
+ access_path_prefix: Path prefix appended to ``base_url`` to form
65
+ the access URL. Defaults to ``"/cache/access"``.
66
+ """
67
+
68
+ base_url: str = ""
69
+ signing_secret: str = ""
70
+ default_url_ttl_seconds: int = 300
71
+ access_path_prefix: str = "/cache/access"
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class AccessUrlEntry:
76
+ """A cache entry plus its mint URL metadata.
77
+
78
+ Returned by :meth:`URLAddressableCache.set_with_url`. Mirrors the shape
79
+ historically returned by ``ImageCacheManager.cache_image``:
80
+ ``{cache_key, access_url, ...}``.
81
+ """
82
+
83
+ key: str
84
+ url: str
85
+ url_expires_at: Optional[datetime] = None
86
+ signed: bool = False
87
+ cache_ttl: Optional[int] = None
88
+ metadata: dict[str, Any] = field(default_factory=dict)
89
+
90
+ def to_dict(self) -> dict[str, Any]:
91
+ """Plain-dict view for JSON responses."""
92
+ return {
93
+ "cache_key": self.key,
94
+ "access_url": self.url,
95
+ "url_expires_at": (
96
+ self.url_expires_at.isoformat() if self.url_expires_at else None
97
+ ),
98
+ "signed": self.signed,
99
+ "cache_ttl_seconds": self.cache_ttl,
100
+ "metadata": dict(self.metadata),
101
+ }
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # URLAddressableCache
106
+ # ---------------------------------------------------------------------------
107
+
108
+
109
+ class URLAddressableCache:
110
+ """Wraps a :class:`CacheManager` with URL-addressable access semantics.
111
+
112
+ This class is the migration target for ``ImageCacheManager``: it stores
113
+ a value in the cache and returns an externally-fetchable URL alongside
114
+ the cache key.
115
+ """
116
+
117
+ def __init__(
118
+ self,
119
+ manager: CacheManager,
120
+ config: AccessUrlConfig | None = None,
121
+ ) -> None:
122
+ """Initialise with a cache manager and access-URL configuration.
123
+
124
+ Args:
125
+ manager: An existing :class:`CacheManager` instance. The cache
126
+ content lives here unchanged.
127
+ config: URL-addressable configuration. If ``None``, a default
128
+ with empty ``base_url`` and empty signing secret is used —
129
+ the caller can still mint **relative** unsigned URLs but
130
+ signed URLs will require an explicit secret.
131
+ """
132
+ self._manager = manager
133
+ self._config = config or AccessUrlConfig()
134
+
135
+ # ------------------------------------------------------------------
136
+ # URL minting
137
+ # ------------------------------------------------------------------
138
+
139
+ def build_url(
140
+ self,
141
+ key: str,
142
+ *,
143
+ url_ttl: int | None = None,
144
+ signed: bool = False,
145
+ ) -> tuple[str, Optional[datetime]]:
146
+ """Build an access URL for an existing cache key.
147
+
148
+ This does NOT touch the cache backend — it only constructs the URL.
149
+ Useful when the caller has already populated the cache via
150
+ :meth:`CacheManager.set` and now wants an access URL for an
151
+ existing key.
152
+
153
+ Args:
154
+ key: The cache key already present in the manager.
155
+ url_ttl: URL validity window in seconds. Only meaningful when
156
+ ``signed=True``. Defaults to ``config.default_url_ttl_seconds``.
157
+ signed: When ``True``, append ``expires`` and ``sig`` query
158
+ parameters (HMAC-SHA256). When ``False``, return a bare
159
+ ``{base}/{prefix}/{key}`` URL — upstream auth is required.
160
+
161
+ Returns:
162
+ ``(url, url_expires_at)``. ``url_expires_at`` is ``None`` for
163
+ unsigned URLs.
164
+
165
+ Raises:
166
+ ValueError: ``signed=True`` requested but no signing secret in
167
+ config.
168
+ """
169
+ path = f"{self._config.access_path_prefix.rstrip('/')}/{quote(key, safe='')}"
170
+ base = self._config.base_url.rstrip("/")
171
+ if signed:
172
+ if not self._config.signing_secret:
173
+ raise ValueError(
174
+ "signed=True requested but AccessUrlConfig.signing_secret is empty"
175
+ )
176
+ ttl = (
177
+ url_ttl
178
+ if url_ttl is not None
179
+ else self._config.default_url_ttl_seconds
180
+ )
181
+ expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl)
182
+ expires_ts = int(expires_at.timestamp())
183
+ sig = self._sign(key, expires_ts)
184
+ return (
185
+ f"{base}{path}?expires={expires_ts}&sig={sig}",
186
+ expires_at,
187
+ )
188
+ return f"{base}{path}", None
189
+
190
+ async def set_with_url(
191
+ self,
192
+ key: str,
193
+ value: Any,
194
+ *,
195
+ ttl: int | None = None,
196
+ url_ttl: int | None = None,
197
+ signed: bool = False,
198
+ tags: tuple[str, ...] = (),
199
+ metadata: dict[str, Any] | None = None,
200
+ ) -> AccessUrlEntry:
201
+ """Store a value AND mint an access URL for it.
202
+
203
+ This is the canonical replacement for ``ImageCacheManager.cache_image``.
204
+
205
+ Args:
206
+ key: Cache key (typically a content hash — see
207
+ :func:`cloud_dog_cache.keys.hash_text`).
208
+ value: Cached value. Any type that the underlying backend can
209
+ serialise (bytes, str, dict, etc.).
210
+ ttl: Cache content TTL in seconds. ``None`` uses the manager's
211
+ configured default.
212
+ url_ttl: URL validity TTL in seconds (signed URLs only).
213
+ signed: Whether to mint a signed URL.
214
+ tags: Invalidation tags applied to the cache entry.
215
+ metadata: Free-form metadata attached to the returned
216
+ :class:`AccessUrlEntry` (e.g. content-type, original URI).
217
+ NOT stored in the cache itself — this is a convenience for
218
+ the caller's response shaping.
219
+
220
+ Returns:
221
+ :class:`AccessUrlEntry` with key, URL, expiry, and metadata.
222
+ """
223
+ await self._manager.set(key, value, ttl=ttl, tags=tags)
224
+ url, expires_at = self.build_url(key, url_ttl=url_ttl, signed=signed)
225
+ return AccessUrlEntry(
226
+ key=key,
227
+ url=url,
228
+ url_expires_at=expires_at,
229
+ signed=signed,
230
+ cache_ttl=ttl,
231
+ metadata=dict(metadata or {}),
232
+ )
233
+
234
+ # ------------------------------------------------------------------
235
+ # URL verification & retrieval
236
+ # ------------------------------------------------------------------
237
+
238
+ def verify_url(self, url: str) -> Optional[tuple[str, Optional[datetime]]]:
239
+ """Verify a signed or unsigned URL and return ``(key, expires_at)``.
240
+
241
+ For unsigned URLs (no ``sig`` query param), this just extracts the
242
+ key from the path. For signed URLs, this validates the HMAC and the
243
+ expiry timestamp.
244
+
245
+ Returns:
246
+ ``(key, expires_at)`` on success, ``None`` on any failure
247
+ (bad signature, expired, malformed path).
248
+ """
249
+ try:
250
+ parts = urlsplit(url)
251
+ except ValueError:
252
+ return None
253
+ prefix = self._config.access_path_prefix.rstrip("/")
254
+ path = parts.path
255
+ if prefix and not path.startswith(prefix + "/"):
256
+ return None
257
+ key_segment = path[len(prefix) + 1 :] if prefix else path.lstrip("/")
258
+ if not key_segment:
259
+ return None
260
+ # urllib.parse.unquote — matches the quote() in build_url
261
+ from urllib.parse import unquote
262
+
263
+ key = unquote(key_segment)
264
+ query = parse_qs(parts.query)
265
+ sig_values = query.get("sig", [])
266
+ if not sig_values:
267
+ return (key, None)
268
+ # Signed URL — validate
269
+ expires_values = query.get("expires", [])
270
+ if not expires_values:
271
+ return None
272
+ try:
273
+ expires_ts = int(expires_values[0])
274
+ except (TypeError, ValueError):
275
+ return None
276
+ if not self._config.signing_secret:
277
+ return None
278
+ expected = self._sign(key, expires_ts)
279
+ if not hmac.compare_digest(expected, sig_values[0]):
280
+ return None
281
+ expires_at = datetime.fromtimestamp(expires_ts, tz=timezone.utc)
282
+ if datetime.now(timezone.utc) >= expires_at:
283
+ return None
284
+ return (key, expires_at)
285
+
286
+ async def get_by_url(self, url: str) -> Optional[Any]:
287
+ """Resolve a URL to its cached value.
288
+
289
+ Returns ``None`` if the URL fails verification, has expired, or the
290
+ cache key has been evicted/expired from the underlying manager.
291
+ """
292
+ verified = self.verify_url(url)
293
+ if verified is None:
294
+ return None
295
+ key, _ = verified
296
+ return await self._manager.get(key)
297
+
298
+ # ------------------------------------------------------------------
299
+ # Internals
300
+ # ------------------------------------------------------------------
301
+
302
+ def _sign(self, key: str, expires_ts: int) -> str:
303
+ """HMAC-SHA256 over ``"{key}|{expires_ts}"``."""
304
+ msg = f"{key}|{expires_ts}".encode("utf-8")
305
+ secret = self._config.signing_secret.encode("utf-8")
306
+ return hmac.new(secret, msg, hashlib.sha256).hexdigest()
307
+
308
+ @property
309
+ def manager(self) -> CacheManager:
310
+ """The underlying :class:`CacheManager`."""
311
+ return self._manager
312
+
313
+ @property
314
+ def config(self) -> AccessUrlConfig:
315
+ """The active :class:`AccessUrlConfig`."""
316
+ return self._config
317
+
318
+
319
+ # ---------------------------------------------------------------------------
320
+ # FastAPI router
321
+ # ---------------------------------------------------------------------------
322
+
323
+
324
+ def create_access_url_router(
325
+ cache: URLAddressableCache,
326
+ *,
327
+ media_type_resolver: Optional[Any] = None,
328
+ ) -> Any:
329
+ """Create a FastAPI router exposing ``GET {access_path_prefix}/{key}``.
330
+
331
+ The router validates the URL (signature + expiry when present), looks
332
+ up the cache entry, and returns the value. ``bytes``/``bytearray``
333
+ values are returned as a binary ``Response`` (with optional
334
+ ``media_type_resolver(key, value) -> str``); other values are returned
335
+ as JSON.
336
+
337
+ Args:
338
+ cache: The :class:`URLAddressableCache` to serve from.
339
+ media_type_resolver: Optional callable receiving ``(key, value)``
340
+ and returning a content-type string for binary responses.
341
+
342
+ Returns:
343
+ A FastAPI ``APIRouter``.
344
+ """
345
+ from fastapi import APIRouter, HTTPException, Request, Response
346
+
347
+ prefix = cache.config.access_path_prefix.rstrip("/")
348
+ router = APIRouter(tags=["cache-access"])
349
+
350
+ @router.get(prefix + "/{key:path}")
351
+ async def access(key: str, request: Request) -> Response:
352
+ # Reconstruct the URL exactly as a downstream consumer would have
353
+ # received it, then verify against the live config.
354
+ full_url = (
355
+ f"{cache.config.base_url.rstrip('/')}{prefix}/{quote(key, safe='')}"
356
+ )
357
+ if request.url.query:
358
+ full_url = f"{full_url}?{request.url.query}"
359
+ verified = cache.verify_url(full_url)
360
+ if verified is None:
361
+ raise HTTPException(status_code=403, detail="invalid or expired url")
362
+ verified_key, _ = verified
363
+ value = await cache.manager.get(verified_key)
364
+ if value is None:
365
+ raise HTTPException(status_code=404, detail="cache miss")
366
+ if isinstance(value, (bytes, bytearray)):
367
+ content_type = (
368
+ media_type_resolver(verified_key, value)
369
+ if media_type_resolver is not None
370
+ else "application/octet-stream"
371
+ )
372
+ return Response(content=bytes(value), media_type=content_type)
373
+ # JSON fall-through
374
+ from fastapi.responses import JSONResponse
375
+
376
+ return JSONResponse(content=value)
377
+
378
+ return router
379
+
380
+
381
+ __all__ = [
382
+ "AccessUrlConfig",
383
+ "AccessUrlEntry",
384
+ "URLAddressableCache",
385
+ "create_access_url_router",
386
+ ]
cloud_dog_cache/api.py ADDED
@@ -0,0 +1,37 @@
1
+ # Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """FastAPI router for cache statistics and management endpoints."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from cloud_dog_cache.manager import get_cache_manager
11
+
12
+
13
+ def create_cache_router() -> Any:
14
+ """Create a FastAPI APIRouter with /cache/stats and /cache/flush endpoints."""
15
+ from fastapi import APIRouter, HTTPException
16
+
17
+ router = APIRouter(prefix="/cache", tags=["cache"])
18
+
19
+ @router.get("/stats")
20
+ async def cache_stats() -> dict[str, object]:
21
+ """Return current cache statistics."""
22
+ manager = get_cache_manager()
23
+ if manager is None:
24
+ return {"enabled": False, "stats": {}}
25
+ stats = await manager.stats()
26
+ return {"enabled": manager.enabled, "stats": stats.to_dict()}
27
+
28
+ @router.post("/flush")
29
+ async def cache_flush() -> dict[str, str]:
30
+ """Flush all cache entries."""
31
+ manager = get_cache_manager()
32
+ if manager is None:
33
+ raise HTTPException(status_code=503, detail="Cache not initialised")
34
+ await manager.flush()
35
+ return {"status": "flushed"}
36
+
37
+ return router
@@ -0,0 +1,9 @@
1
+ # Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """Cache backend implementations."""
5
+
6
+ from cloud_dog_cache.backends.base import CacheBackend
7
+ from cloud_dog_cache.backends.memory import MemoryCacheBackend
8
+
9
+ __all__ = ["CacheBackend", "MemoryCacheBackend"]
@@ -0,0 +1,39 @@
1
+ # Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """Abstract cache backend protocol."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Optional, Protocol
9
+
10
+ from cloud_dog_cache.models import CacheEntry
11
+ from cloud_dog_cache.stats import CacheStats
12
+
13
+
14
+ class CacheBackend(Protocol):
15
+ """Protocol for pluggable cache storage backends."""
16
+
17
+ async def get(self, key: str) -> Optional[CacheEntry]:
18
+ """Retrieve a cache entry by key. Return None if missing or expired."""
19
+ ...
20
+
21
+ async def set(self, key: str, entry: CacheEntry, ttl: int) -> None:
22
+ """Store a cache entry with the given TTL in seconds."""
23
+ ...
24
+
25
+ async def delete(self, key: str) -> None:
26
+ """Remove a single entry by key."""
27
+ ...
28
+
29
+ async def flush(self) -> None:
30
+ """Remove all entries from the cache."""
31
+ ...
32
+
33
+ async def stats(self) -> CacheStats:
34
+ """Return current cache statistics."""
35
+ ...
36
+
37
+ async def flush_by_tag(self, tag: str) -> int:
38
+ """Remove all entries matching the given tag. Return count removed."""
39
+ ...
@@ -0,0 +1,91 @@
1
+ # Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """In-memory LRU cache backend with TTL support."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ import threading
10
+ from collections import OrderedDict
11
+ from datetime import datetime, timedelta, timezone
12
+ from typing import Optional
13
+
14
+ from cloud_dog_cache.models import CacheEntry
15
+ from cloud_dog_cache.stats import CacheStats
16
+
17
+
18
+ class MemoryCacheBackend:
19
+ """Thread-safe in-memory LRU cache with per-entry TTL and tag-based invalidation."""
20
+
21
+ def __init__(self, *, max_entries: int = 1000) -> None:
22
+ """Initialise with the given maximum entry count."""
23
+ self._max_entries = max(1, max_entries)
24
+ self._store: OrderedDict[str, CacheEntry] = OrderedDict()
25
+ self._lock = threading.Lock()
26
+ self._hits = 0
27
+ self._misses = 0
28
+ self._evictions = 0
29
+
30
+ async def get(self, key: str) -> Optional[CacheEntry]:
31
+ """Retrieve an entry, promoting it in LRU order."""
32
+ with self._lock:
33
+ entry = self._store.get(key)
34
+ if entry is None:
35
+ self._misses += 1
36
+ return None
37
+ if entry.expired:
38
+ del self._store[key]
39
+ self._misses += 1
40
+ return None
41
+ self._store.move_to_end(key)
42
+ self._hits += 1
43
+ return entry
44
+
45
+ async def set(self, key: str, entry: CacheEntry, ttl: int) -> None:
46
+ """Store an entry with TTL. Evict oldest if at capacity."""
47
+ expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl)
48
+ stored = CacheEntry(
49
+ key=entry.key,
50
+ value=entry.value,
51
+ created_at=entry.created_at,
52
+ expires_at=expires_at,
53
+ tags=entry.tags,
54
+ )
55
+ with self._lock:
56
+ if key in self._store:
57
+ del self._store[key]
58
+ while len(self._store) >= self._max_entries:
59
+ self._store.popitem(last=False)
60
+ self._evictions += 1
61
+ self._store[key] = stored
62
+
63
+ async def delete(self, key: str) -> None:
64
+ """Remove a single entry."""
65
+ with self._lock:
66
+ self._store.pop(key, None)
67
+
68
+ async def flush(self) -> None:
69
+ """Remove all entries."""
70
+ with self._lock:
71
+ self._store.clear()
72
+
73
+ async def stats(self) -> CacheStats:
74
+ """Return current statistics snapshot."""
75
+ with self._lock:
76
+ mem = sum(sys.getsizeof(e.value) for e in self._store.values())
77
+ return CacheStats(
78
+ hits=self._hits,
79
+ misses=self._misses,
80
+ entries=len(self._store),
81
+ evictions=self._evictions,
82
+ memory_bytes=mem,
83
+ )
84
+
85
+ async def flush_by_tag(self, tag: str) -> int:
86
+ """Remove all entries with the given tag."""
87
+ with self._lock:
88
+ to_remove = [k for k, v in self._store.items() if tag in v.tags]
89
+ for k in to_remove:
90
+ del self._store[k]
91
+ return len(to_remove)