cachekit 0.20.0__cp314-cp314-win_amd64.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.
Files changed (80) hide show
  1. LICENSE +21 -0
  2. README.md +539 -0
  3. cachekit/__init__.py +136 -0
  4. cachekit/_rust_serializer.cp314-win_amd64.pyd +0 -0
  5. cachekit/backends/__init__.py +114 -0
  6. cachekit/backends/base.py +450 -0
  7. cachekit/backends/base_config.py +104 -0
  8. cachekit/backends/cachekitio/__init__.py +12 -0
  9. cachekit/backends/cachekitio/backend.py +911 -0
  10. cachekit/backends/cachekitio/client.py +187 -0
  11. cachekit/backends/cachekitio/config.py +225 -0
  12. cachekit/backends/cachekitio/error_handler.py +134 -0
  13. cachekit/backends/cachekitio/session.py +59 -0
  14. cachekit/backends/errors.py +198 -0
  15. cachekit/backends/file/__init__.py +30 -0
  16. cachekit/backends/file/backend.py +1159 -0
  17. cachekit/backends/file/config.py +191 -0
  18. cachekit/backends/memcached/__init__.py +25 -0
  19. cachekit/backends/memcached/backend.py +255 -0
  20. cachekit/backends/memcached/config.py +137 -0
  21. cachekit/backends/memcached/error_handler.py +98 -0
  22. cachekit/backends/provider.py +255 -0
  23. cachekit/backends/redis/__init__.py +12 -0
  24. cachekit/backends/redis/backend.py +285 -0
  25. cachekit/backends/redis/client.py +263 -0
  26. cachekit/backends/redis/config.py +117 -0
  27. cachekit/backends/redis/error_handler.py +185 -0
  28. cachekit/backends/redis/provider.py +804 -0
  29. cachekit/cache_handler.py +2301 -0
  30. cachekit/config/__init__.py +48 -0
  31. cachekit/config/decorator.py +603 -0
  32. cachekit/config/nested.py +393 -0
  33. cachekit/config/settings.py +382 -0
  34. cachekit/config/singleton.py +100 -0
  35. cachekit/config/validation.py +243 -0
  36. cachekit/decorators/__init__.py +15 -0
  37. cachekit/decorators/intent.py +283 -0
  38. cachekit/decorators/local_wrapper.py +145 -0
  39. cachekit/decorators/main.py +7 -0
  40. cachekit/decorators/orchestrator.py +419 -0
  41. cachekit/decorators/session.py +141 -0
  42. cachekit/decorators/stats_context.py +64 -0
  43. cachekit/decorators/tenant_context.py +256 -0
  44. cachekit/decorators/utils/__init__.py +3 -0
  45. cachekit/decorators/wrapper.py +2490 -0
  46. cachekit/di.py +55 -0
  47. cachekit/hash_utils.py +145 -0
  48. cachekit/health.py +647 -0
  49. cachekit/hiredis_compat.py +98 -0
  50. cachekit/imports.py +48 -0
  51. cachekit/interop.py +518 -0
  52. cachekit/key_generator.py +450 -0
  53. cachekit/l1_cache.py +642 -0
  54. cachekit/logging.py +386 -0
  55. cachekit/monitoring/__init__.py +8 -0
  56. cachekit/monitoring/pool_monitor.py +346 -0
  57. cachekit/monitoring/protocols.py +50 -0
  58. cachekit/object_cache.py +471 -0
  59. cachekit/py.typed +0 -0
  60. cachekit/reliability/__init__.py +36 -0
  61. cachekit/reliability/async_metrics.py +804 -0
  62. cachekit/reliability/circuit_breaker.py +459 -0
  63. cachekit/reliability/error_classification.py +91 -0
  64. cachekit/reliability/load_control.py +214 -0
  65. cachekit/reliability/metrics_collection.py +407 -0
  66. cachekit/reliability/profiles.py +236 -0
  67. cachekit/serializers/__init__.py +260 -0
  68. cachekit/serializers/arrow_serializer.py +450 -0
  69. cachekit/serializers/auto_serializer.py +1262 -0
  70. cachekit/serializers/base.py +480 -0
  71. cachekit/serializers/encryption_wrapper.py +885 -0
  72. cachekit/serializers/interop_serializer.py +64 -0
  73. cachekit/serializers/orjson_serializer.py +212 -0
  74. cachekit/serializers/standard_serializer.py +411 -0
  75. cachekit/serializers/wrapper.py +169 -0
  76. cachekit-0.20.0.dist-info/METADATA +596 -0
  77. cachekit-0.20.0.dist-info/RECORD +80 -0
  78. cachekit-0.20.0.dist-info/WHEEL +4 -0
  79. cachekit-0.20.0.dist-info/licenses/LICENSE +21 -0
  80. cachekit-0.20.0.dist-info/sboms/cachekit-rs.cyclonedx.json +3546 -0
@@ -0,0 +1,114 @@
1
+ """Backend storage abstraction for cachekit.
2
+
3
+ This module provides protocol-based abstraction for L2 backend storage with
4
+ dependency injection pattern. Backends can be Redis, HTTP, DynamoDB, Memcached,
5
+ or any key-value store.
6
+
7
+ Public API:
8
+ - BaseBackend: Core protocol (5 methods: get, set, delete, exists, health_check)
9
+ - TTLInspectableBackend: Optional protocol for TTL inspection/refresh
10
+ - LockableBackend: Optional protocol for distributed locking
11
+ - KeyTrackableBackend: Optional protocol for server-side key tracking (whole-function invalidation)
12
+ - TimeoutConfigurableBackend: Optional protocol for per-operation timeouts
13
+ - BackendProvider: Dependency injection protocol
14
+ - BackendError: Exception raised by backend operations
15
+ - BackendErrorType: Error classification enum
16
+ - CapabilityNotAvailableError: Exception for missing optional capabilities
17
+ - RedisBackend: Redis implementation (default)
18
+ - MemcachedBackend: Memcached implementation (requires pymemcache)
19
+
20
+ Usage:
21
+ >>> from cachekit.backends import BaseBackend, RedisBackend, BackendError
22
+ >>> # RedisBackend usage requires Redis connection
23
+ >>> # See RedisBackend documentation for connection details
24
+
25
+ Dependency injection pattern:
26
+ >>> from cachekit.backends import BackendProvider
27
+ >>> # Implement BackendProvider protocol in your application
28
+ >>> # See BackendProvider documentation for implementation examples
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from typing import Protocol
34
+
35
+ from cachekit.backends.base import (
36
+ BaseBackend,
37
+ KeyTrackableBackend,
38
+ LockableBackend,
39
+ TimeoutConfigurableBackend,
40
+ TTLInspectableBackend,
41
+ )
42
+ from cachekit.backends.base_config import BaseBackendConfig, inherit_config
43
+ from cachekit.backends.errors import (
44
+ BackendError,
45
+ BackendErrorType,
46
+ CapabilityNotAvailableError,
47
+ )
48
+ from cachekit.backends.redis import RedisBackend
49
+
50
+ __all__ = [
51
+ "BaseBackend",
52
+ "BaseBackendConfig",
53
+ "inherit_config",
54
+ "TTLInspectableBackend",
55
+ "LockableBackend",
56
+ "KeyTrackableBackend",
57
+ "TimeoutConfigurableBackend",
58
+ "BackendProvider",
59
+ "BackendError",
60
+ "BackendErrorType",
61
+ "CapabilityNotAvailableError",
62
+ "RedisBackend",
63
+ "MemcachedBackend",
64
+ ]
65
+
66
+
67
+ class BackendProvider(Protocol):
68
+ """Protocol for dependency injection of backend instances.
69
+
70
+ Enables testability and pluggable backends without hardcoding concrete
71
+ implementations. The provider manages backend lifecycle (singleton,
72
+ pooling, etc.). A decorator calls ``get_backend()`` once and keeps the
73
+ result for the life of the process, so the backend returned must be safe to
74
+ share across requests — anything request-scoped (e.g. the tenant) has to be
75
+ resolved per operation, not captured when the backend is built.
76
+
77
+ Example:
78
+ >>> from cachekit.backends import BackendProvider, BaseBackend
79
+ >>> # Implement BackendProvider protocol:
80
+ >>> # class MyProvider:
81
+ >>> # def get_backend(self) -> BaseBackend:
82
+ >>> # return RedisBackend()
83
+ >>> # provider = MyProvider()
84
+ >>> # backend = provider.get_backend()
85
+
86
+ Note:
87
+ This is a structural protocol (PEP 544) - any class with a get_backend()
88
+ method satisfies this protocol without explicit inheritance.
89
+ """
90
+
91
+ def get_backend(self) -> BaseBackend:
92
+ """Return a BaseBackend instance.
93
+
94
+ Implementation can manage singleton or pooling depending on backend
95
+ requirements; the result must be safe to share across requests.
96
+
97
+ Returns:
98
+ BaseBackend instance ready for cache operations
99
+
100
+ Example:
101
+ >>> provider = MyProvider() # doctest: +SKIP
102
+ >>> backend = provider.get_backend() # doctest: +SKIP
103
+ >>> backend.set("key", b"value", ttl=60) # doctest: +SKIP
104
+ """
105
+ ...
106
+
107
+
108
+ def __getattr__(name: str):
109
+ """Lazy import for optional backends (pymemcache may not be installed)."""
110
+ if name == "MemcachedBackend":
111
+ from cachekit.backends.memcached import MemcachedBackend
112
+
113
+ return MemcachedBackend
114
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,450 @@
1
+ """Base backend protocol definitions.
2
+
3
+ This module defines the storage backend contract using PEP 544 protocol-based abstraction.
4
+ All L2 backends (Redis, HTTP, etc.) must implement BaseBackend protocol.
5
+
6
+ Optional capability protocols (TTLInspectableBackend, LockableBackend, KeyTrackableBackend,
7
+ TimeoutConfigurableBackend, BufferReadableBackend, BufferWritableBackend)
8
+ enable advanced features with graceful degradation.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import AsyncIterator, Callable, Iterable
14
+ from contextlib import AbstractAsyncContextManager
15
+ from typing import Any, BinaryIO, Optional, Protocol, runtime_checkable
16
+
17
+ # Re-export BackendError for convenience (public API)
18
+ from cachekit.backends.errors import BackendError # noqa: F401
19
+
20
+
21
+ @runtime_checkable
22
+ class BaseBackend(Protocol):
23
+ """Protocol defining the L2 backend storage contract.
24
+
25
+ All backend implementations must support these four operations on bytes.
26
+ This protocol uses structural subtyping (PEP 544) - any class implementing
27
+ these methods is considered a valid backend.
28
+
29
+ Design principles:
30
+ - Stateless operations (no connection management in protocol)
31
+ - Bytes-only interface (language-agnostic, no Python-specific types)
32
+ - No Redis-specific concepts (works for any backend: Redis, HTTP, DynamoDB, etc.)
33
+ - Simple and focused (KISS principle)
34
+
35
+ Example:
36
+ >>> from cachekit.backends import BaseBackend, RedisBackend
37
+ >>> issubclass(RedisBackend, BaseBackend) # structural (runtime-checkable) protocol
38
+ True
39
+ >>> backend = RedisBackend() # doctest: +SKIP
40
+ >>> backend.set("key", b"value", ttl=60) # doctest: +SKIP
41
+ >>> data = backend.get("key") # doctest: +SKIP
42
+ """
43
+
44
+ def get(self, key: str) -> Optional[bytes]:
45
+ """Retrieve value from backend storage.
46
+
47
+ Args:
48
+ key: Cache key to retrieve
49
+
50
+ Returns:
51
+ Bytes value if found, None if key doesn't exist
52
+
53
+ Raises:
54
+ BackendError: If backend operation fails (network, timeout, etc.)
55
+ """
56
+ ...
57
+
58
+ def set(self, key: str, value: bytes, ttl: Optional[int] = None) -> None:
59
+ """Store value in backend storage.
60
+
61
+ Args:
62
+ key: Cache key to store
63
+ value: Bytes value to store (encrypted or plaintext msgpack)
64
+ ttl: Time-to-live in seconds (None = no expiry)
65
+
66
+ Raises:
67
+ BackendError: If backend operation fails (network, timeout, etc.)
68
+ """
69
+ ...
70
+
71
+ def delete(self, key: str) -> bool:
72
+ """Delete key from backend storage.
73
+
74
+ Args:
75
+ key: Cache key to delete
76
+
77
+ Returns:
78
+ True if key was deleted, False if key didn't exist
79
+
80
+ Raises:
81
+ BackendError: If backend operation fails (network, timeout, etc.)
82
+ """
83
+ ...
84
+
85
+ def exists(self, key: str) -> bool:
86
+ """Check if key exists in backend storage.
87
+
88
+ Args:
89
+ key: Cache key to check
90
+
91
+ Returns:
92
+ True if key exists, False otherwise
93
+
94
+ Raises:
95
+ BackendError: If backend operation fails (network, timeout, etc.)
96
+ """
97
+ ...
98
+
99
+ def health_check(self) -> tuple[bool, dict[str, Any]]:
100
+ """Check backend health status.
101
+
102
+ Returns:
103
+ Tuple of (is_healthy, details_dict)
104
+ Details must include 'latency_ms' and 'backend_type'
105
+
106
+ Example:
107
+ >>> backend = RedisBackend() # doctest: +SKIP
108
+ >>> is_healthy, details = backend.health_check() # doctest: +SKIP
109
+ >>> assert 'latency_ms' in details # doctest: +SKIP
110
+ >>> assert 'backend_type' in details # doctest: +SKIP
111
+ """
112
+ ...
113
+
114
+
115
+ @runtime_checkable
116
+ class TTLInspectableBackend(Protocol):
117
+ """Optional protocol for backends that support TTL inspection and refresh.
118
+
119
+ Backends implementing this protocol can report remaining TTL on keys
120
+ and refresh TTLs on existing keys. This enables features like automatic
121
+ TTL refresh for frequently-accessed keys.
122
+
123
+ Not all backends support this capability:
124
+ - Supported: Redis, CachekitIO, File, PostgreSQL, DynamoDB, SQLite
125
+ - Not supported: HTTP (stateless), S3 (limited)
126
+ - Memcached: partial — implements ``refresh_ttl`` (via ``touch``) but NOT ``get_ttl``
127
+ (no protocol command to read remaining TTL), so it is not a full TTLInspectableBackend.
128
+
129
+ Example:
130
+ >>> # TTL inspection pattern (async context):
131
+ >>> # if hasattr(backend, 'get_ttl'):
132
+ >>> # ttl = await backend.get_ttl("user:123")
133
+ >>> # if ttl and ttl < 60:
134
+ >>> # await backend.refresh_ttl("user:123", 3600)
135
+ """
136
+
137
+ async def get_ttl(self, key: str) -> Optional[int]:
138
+ """Get remaining TTL on key (in seconds).
139
+
140
+ Args:
141
+ key: Cache key to inspect
142
+
143
+ Returns:
144
+ Remaining TTL in seconds, or None if:
145
+ - Key doesn't exist
146
+ - Key has no expiration (permanent)
147
+
148
+ Raises:
149
+ BackendError: If backend operation fails
150
+
151
+ Example:
152
+ >>> ttl = await backend.get_ttl("user:123") # doctest: +SKIP
153
+ >>> if ttl and ttl < 60: # doctest: +SKIP
154
+ ... # Key expiring soon
155
+ ... pass # doctest: +SKIP
156
+ """
157
+ ...
158
+
159
+ async def refresh_ttl(self, key: str, ttl: int) -> bool:
160
+ """Refresh (update) TTL on existing key.
161
+
162
+ Args:
163
+ key: Cache key to refresh
164
+ ttl: New TTL in seconds
165
+
166
+ Returns:
167
+ True if key existed and TTL was refreshed
168
+ False if key doesn't exist (no-op)
169
+
170
+ Raises:
171
+ BackendError: If backend operation fails
172
+
173
+ Example:
174
+ >>> refreshed = await backend.refresh_ttl("user:123", 3600) # doctest: +SKIP
175
+ >>> if refreshed: # doctest: +SKIP
176
+ ... # TTL successfully updated
177
+ ... pass # doctest: +SKIP
178
+ """
179
+ ...
180
+
181
+
182
+ @runtime_checkable
183
+ class BufferHandle(Protocol):
184
+ """A borrowed, zero-copy view of a cached value plus the resource backing it.
185
+
186
+ Returned by ``BufferReadableBackend.get_buffer``. ``view`` aliases backend-owned memory (e.g.
187
+ mmap'd file pages), not a heap copy — so the consumer must finish reading and ``close()``
188
+ before the view is touched again. The view DANGLES after close (touching it can segfault), so
189
+ it must never be stored (e.g. in L1) nor returned past the read call frame (#171).
190
+ """
191
+
192
+ view: memoryview
193
+ """Zero-copy view of the payload (valid only until close())."""
194
+
195
+ def close(self) -> None:
196
+ """Release the view and its backing resource. Idempotent."""
197
+ ...
198
+
199
+
200
+ @runtime_checkable
201
+ class BufferReadableBackend(Protocol):
202
+ """Optional protocol for backends that can return a zero-copy buffer instead of materializing
203
+ the whole value on the heap.
204
+
205
+ Lets large plaintext values (e.g. uncompressed Arrow IPC) be read without copying the payload.
206
+ Only the File backend implements this today (mmap, POSIX). Backends that don't implement it
207
+ are simply read via ``get`` as usual.
208
+ """
209
+
210
+ def get_buffer(self, key: str) -> Optional[BufferHandle]:
211
+ """Return a borrowed zero-copy handle for ``key``, or None when the value is not mappable
212
+ (missing/expired/too large/non-POSIX) — the caller then falls back to ``get``. The caller
213
+ MUST ``close()`` the handle when done reading."""
214
+ ...
215
+
216
+
217
+ @runtime_checkable
218
+ class BufferWritableBackend(Protocol):
219
+ """Optional protocol for backends that can accept a value written incrementally through a
220
+ seekable sink, instead of one materialized ``bytes`` blob (LAB-766, write-side twin of
221
+ ``BufferReadableBackend``).
222
+
223
+ Lets large values (e.g. multi-GB Arrow IPC) reach storage without the whole serialized
224
+ payload ever existing in memory. Only the File backend implements this today; backends that
225
+ don't are simply written via ``set`` as usual (the caller falls back transparently).
226
+
227
+ Why a writer callback and not an ``Iterable[bytes]``: the Arrow envelope stores an
228
+ xxHash3-64 checksum BEFORE the payload it covers, so the producer must patch bytes it has
229
+ already emitted once the stream is complete. A forward-only chunk iterator cannot express
230
+ that without serializing twice; a seekable sink can. This deliberately scopes the protocol
231
+ to random-access backends (File) — a forward-only transport (chunked HTTP upload) needs a
232
+ different contract and is out of scope until a backend demands it.
233
+
234
+ Contract:
235
+ - ``write_payload`` receives a writable, seekable binary sink positioned at the START of
236
+ the payload region. Producers MUST anchor seeks with ``tell()`` (never absolute offset 0):
237
+ backends may keep private framing before the payload.
238
+ - The value becomes visible under ``key`` only if ``write_payload`` returns successfully;
239
+ on any exception the backend discards the partial write, leaves any previous value for
240
+ the key intact, and re-raises the producer's exception unwrapped (so serialization errors
241
+ keep their type for the caller's error handling).
242
+ - ``ttl`` semantics are identical to ``set``.
243
+ - A successful ``set_streaming(key, w, ttl)`` is observably identical to
244
+ ``set(key, full_bytes, ttl)`` for the same payload bytes — same readback via
245
+ ``get``/``get_buffer``, same expiry, same size-limit enforcement (violations raise
246
+ ``BackendError`` and nothing is stored).
247
+ """
248
+
249
+ def set_streaming(self, key: str, write_payload: Callable[[BinaryIO], None], ttl: Optional[int] = None) -> None:
250
+ """Store the payload produced by ``write_payload`` under ``key``, never holding it whole.
251
+
252
+ Args:
253
+ key: Cache key to store
254
+ write_payload: Callable that writes the complete payload to the provided sink
255
+ ttl: Time-to-live in seconds (None = no expiry), same semantics as ``set``
256
+
257
+ Raises:
258
+ BackendError: If the backend operation fails (I/O, size limits, TTL bounds)
259
+ Exception: Whatever ``write_payload`` raises, unwrapped (partial write discarded)
260
+ """
261
+ ...
262
+
263
+
264
+ @runtime_checkable
265
+ class LockableBackend(Protocol):
266
+ """Optional protocol for backends supporting distributed locking.
267
+
268
+ Backends implementing this protocol can provide distributed lock semantics
269
+ for coordinating access across multiple processes/servers. This enables
270
+ features like cache stampede prevention and critical sections.
271
+
272
+ Not all backends support this capability:
273
+ - Supported: ``PerRequestRedisBackend`` — the tenant-scoped Redis backend
274
+ ``RedisBackendProvider`` hands out and the env-resolved Redis path uses — and
275
+ ``CachekitIOBackend`` (SaaS ``POST /v1/cache/{key}/lock``).
276
+ - Not supported: ``RedisBackend`` constructed directly and passed as
277
+ ``backend=``, ``FileBackend``, L1-only (in-memory).
278
+
279
+ Contract — bare cache key:
280
+ ``acquire_lock`` receives the **bare cache key**, identical to what
281
+ ``get``/``set``/``delete`` receive. Callers MUST NOT append suffixes
282
+ like ``:lock`` before passing the key. Each backend owns its own
283
+ lock-namespace derivation:
284
+
285
+ - Redis: derives ``<key>:lock`` internally for the on-wire lock name.
286
+ - CachekitIO (SaaS): the lock endpoint is ``POST /v1/cache/{key}/lock`` —
287
+ no key derivation needed.
288
+
289
+ This convergence keeps the Python SDK byte-for-byte compatible with
290
+ the cachekit-rs and cachekit-ts SDKs at the protocol boundary.
291
+
292
+ Example:
293
+ >>> # Distributed locking pattern (async context):
294
+ >>> # if hasattr(backend, 'acquire_lock'):
295
+ >>> # async with backend.acquire_lock("user:123:profile", timeout=30) as acquired:
296
+ >>> # if acquired:
297
+ >>> # result = expensive_computation()
298
+ """
299
+
300
+ def acquire_lock(
301
+ self,
302
+ key: str,
303
+ timeout: float,
304
+ blocking_timeout: Optional[float] = None,
305
+ ) -> AbstractAsyncContextManager[bool]:
306
+ """Acquire a distributed lock on key.
307
+
308
+ Args:
309
+ key: Bare cache key (e.g., ``"user:123"``) — the SAME shape passed
310
+ to ``get``/``set``/``delete``. Implementations are responsible
311
+ for any internal lock-namespace derivation; callers MUST NOT
312
+ pre-suffix the key.
313
+ timeout: How long to hold the lock (seconds) before auto-release
314
+ blocking_timeout: Max time to wait for lock acquisition (None = non-blocking)
315
+
316
+ Returns:
317
+ An async context manager yielding True if the lock was acquired,
318
+ False if the wait timed out. Implementations are ``async``
319
+ generators wrapped in ``@asynccontextmanager``, so this protocol
320
+ declares the *decorated* shape.
321
+
322
+ Raises:
323
+ BackendError: If backend operation fails
324
+
325
+ Example:
326
+ >>> async with backend.acquire_lock("user:123:profile", timeout=30, blocking_timeout=5) as acquired: # doctest: +SKIP
327
+ ... if acquired: # doctest: +SKIP
328
+ ... # Lock held, safe to proceed
329
+ ... pass # doctest: +SKIP
330
+ ... else: # doctest: +SKIP
331
+ ... # Timeout waiting for lock
332
+ ... pass # doctest: +SKIP
333
+
334
+ Note:
335
+ Lock is automatically released on context exit, even if exception occurs.
336
+ """
337
+ ...
338
+
339
+
340
+ @runtime_checkable
341
+ class KeyTrackableBackend(Protocol):
342
+ """Optional protocol for backends supporting server-side key tracking.
343
+
344
+ A decorated function's no-argument ``invalidate_cache()`` must delete every key
345
+ ANY process wrote for it, not only the keys the calling process remembers. A
346
+ tracking backend keeps one server-side set of written keys per decorated
347
+ function (the registry id) — per tenant on a tenant-scoped backend — and drains
348
+ the calling tenant's set on whole-function invalidation.
349
+
350
+ Not all backends support this capability:
351
+ - Supported: ``PerRequestRedisBackend`` — what ``RedisBackendProvider`` and
352
+ therefore the env-resolved Redis path hand out.
353
+ - Not supported: ``RedisBackend`` constructed directly and passed as
354
+ ``backend=``, ``FileBackend``, ``MemcachedBackend``, ``CachekitIOBackend``,
355
+ L1-only. These keep process-local invalidation: the calling process deletes
356
+ the keys it wrote or read itself.
357
+
358
+ Both methods are sync, like ``BaseBackend.get``/``set``; async callers run
359
+ them through ``asyncio.to_thread``.
360
+
361
+ Example:
362
+ >>> from cachekit.backends.base import KeyTrackableBackend
363
+ >>> from cachekit.backends.redis.provider import PerRequestRedisBackend
364
+ >>> from cachekit.backends.file import FileBackend
365
+ >>> issubclass(PerRequestRedisBackend, KeyTrackableBackend)
366
+ True
367
+ >>> issubclass(FileBackend, KeyTrackableBackend)
368
+ False
369
+ """
370
+
371
+ def track_key(self, registry_id: str, key: str) -> None:
372
+ """Add a cache key to the server-side tracking set for ``registry_id``.
373
+
374
+ ``key`` is the raw cache key as the caller wrote it and is stored raw.
375
+ Implementations scope ``registry_id`` (tenant prefix) and apply the same
376
+ scoping to members only at drain time, so a member can never name a key
377
+ outside the implementation's own scope. Refreshes the set's TTL on every call.
378
+
379
+ Raises:
380
+ Exception: On any backend failure. Tracking is advisory: callers
381
+ swallow the error, and a key whose tracking failed is still
382
+ removed by the caller's next drain through ``local_keys``.
383
+ """
384
+ ...
385
+
386
+ def drain_tracked(self, registry_id: str, local_keys: Iterable[str]) -> set[str]:
387
+ """Drain the tracking set in bounded atomic steps, deleting every member's key,
388
+ then delete every key in ``local_keys`` that no step popped.
389
+
390
+ Args:
391
+ registry_id: Unscoped registry id of the decorated function.
392
+ local_keys: The caller's snapshot of the keys it knows it wrote or read.
393
+ Required: these are the keys the tracking set may be missing (a failed
394
+ ``track_key``, a key written before tracking existed, a lapsed set),
395
+ and deleting them is what makes the drain complete.
396
+
397
+ Returns:
398
+ The raw keys deleted — decoded popped members plus all of ``local_keys`` —
399
+ for L1 eviction.
400
+
401
+ Raises:
402
+ Exception: If any command fails. The caller then falls back to deleting
403
+ its local keys one by one.
404
+ """
405
+ ...
406
+
407
+
408
+ @runtime_checkable
409
+ class TimeoutConfigurableBackend(Protocol):
410
+ """Optional protocol for per-operation timeout configuration.
411
+
412
+ Backends implementing this protocol allow fine-grained timeout control
413
+ per operation, so a caller can set an explicit timeout for a single
414
+ operation where the backend supports it.
415
+
416
+ All backends support some timeout mechanism, but granularity varies:
417
+ - Per-operation: HTTP, DynamoDB, PostgreSQL
418
+ - Per-socket/transaction: Redis, Memcached, SQLite
419
+ - Global: KV, S3
420
+
421
+ Example:
422
+ >>> # Per-operation timeout pattern (async context):
423
+ >>> # if hasattr(backend, 'with_timeout'):
424
+ >>> # async with backend.with_timeout("get", 100):
425
+ >>> # value = await backend.get("key")
426
+ """
427
+
428
+ async def with_timeout(
429
+ self,
430
+ operation: str,
431
+ timeout_ms: int,
432
+ ) -> AsyncIterator[None]:
433
+ """Set timeout for operations within context.
434
+
435
+ Args:
436
+ operation: Operation name (e.g., "get", "set", "delete")
437
+ timeout_ms: Timeout in milliseconds
438
+
439
+ Raises:
440
+ BackendError: With error_type=TIMEOUT if timeout exceeded
441
+
442
+ Example:
443
+ >>> async with backend.with_timeout("get", 100): # doctest: +SKIP
444
+ ... value = await backend.get("key") # doctest: +SKIP
445
+
446
+ Note:
447
+ Backends without per-operation timeout may apply timeout at
448
+ socket or global level (coarser-grained fallback).
449
+ """
450
+ ...
@@ -0,0 +1,104 @@
1
+ """Base configuration for all backend implementations.
2
+
3
+ All backend configs MUST inherit from BaseBackendConfig to ensure
4
+ consistent behavior, validation, and environment variable handling.
5
+
6
+ Design rationale:
7
+ - Pydantic v2 model_config does NOT merge on inheritance (replaces)
8
+ - Child classes must explicitly spread parent config via inherit_config()
9
+ - This enables isinstance() checks and enforces patterns
10
+
11
+ Usage:
12
+ >>> from cachekit.backends.base_config import BaseBackendConfig, inherit_config
13
+ >>> from pydantic_settings import SettingsConfigDict
14
+ >>>
15
+ >>> class MyBackendConfig(BaseBackendConfig):
16
+ ... model_config = SettingsConfigDict(
17
+ ... **inherit_config(BaseBackendConfig),
18
+ ... env_prefix="CACHEKIT_MY_",
19
+ ... )
20
+ ... my_setting: str = "default"
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Any
26
+
27
+ from pydantic_settings import BaseSettings, SettingsConfigDict
28
+
29
+ from cachekit.config.validation import RedactingSettings
30
+
31
+ # Keys that child classes MUST override (not inherited from base)
32
+ _CHILD_OVERRIDE_KEYS = frozenset({"env_prefix"})
33
+
34
+
35
+ def inherit_config(base_cls: type[BaseSettings]) -> dict[str, Any]:
36
+ """Extract inheritable model_config settings from a base class.
37
+
38
+ Filters out keys that child classes must explicitly set (like env_prefix).
39
+
40
+ Args:
41
+ base_cls: The base class to inherit config from
42
+
43
+ Returns:
44
+ Dict of model_config settings safe to spread into child class
45
+
46
+ Example:
47
+ >>> class ChildConfig(BaseBackendConfig):
48
+ ... model_config = SettingsConfigDict(
49
+ ... **inherit_config(BaseBackendConfig),
50
+ ... env_prefix="MY_PREFIX_",
51
+ ... )
52
+ """
53
+ return {k: v for k, v in base_cls.model_config.items() if k not in _CHILD_OVERRIDE_KEYS}
54
+
55
+
56
+ class BaseBackendConfig(RedactingSettings):
57
+ """Base class for all backend configurations.
58
+
59
+ Provides consistent settings for environment variable parsing,
60
+ validation strictness, and configuration patterns.
61
+
62
+ All backend configs MUST:
63
+ 1. Inherit from this class
64
+ 2. Spread model_config via inherit_config(): `**inherit_config(BaseBackendConfig)`
65
+ 3. Add their own env_prefix
66
+ 4. Implement from_env() classmethod
67
+
68
+ Attributes:
69
+ model_config: Standard pydantic-settings configuration with:
70
+ - env_nested_delimiter="__" for nested config support
71
+ - case_sensitive=False for env var flexibility
72
+ - extra="forbid" for strict validation (catch typos)
73
+ - populate_by_name=True for alias support
74
+ - hide_input_in_errors=True so no error text echoes a credential
75
+
76
+ Example:
77
+ >>> class MyBackendConfig(BaseBackendConfig):
78
+ ... model_config = SettingsConfigDict(
79
+ ... **inherit_config(BaseBackendConfig),
80
+ ... env_prefix="CACHEKIT_MY_",
81
+ ... )
82
+ ... my_url: str = "http://localhost:8080"
83
+ """
84
+
85
+ model_config = SettingsConfigDict(
86
+ env_nested_delimiter="__",
87
+ case_sensitive=False,
88
+ extra="forbid",
89
+ populate_by_name=True,
90
+ # Backend configs hold credentials (an API key, a password in a Redis URL). RedactingSettings
91
+ # redacts errors()/json() on construction; this keeps str()/repr() input-free on every path.
92
+ hide_input_in_errors=True,
93
+ )
94
+
95
+ @classmethod
96
+ def from_env(cls) -> BaseBackendConfig:
97
+ """Create configuration from environment variables.
98
+
99
+ Subclasses should override type hint but can reuse implementation.
100
+
101
+ Returns:
102
+ Configuration instance loaded from environment
103
+ """
104
+ return cls()
@@ -0,0 +1,12 @@
1
+ """cachekit.io backend implementation.
2
+
3
+ Provides cachekit.io storage backend implementing BaseBackend protocol.
4
+ """
5
+
6
+ from cachekit.backends.cachekitio.backend import CachekitIOBackend
7
+ from cachekit.backends.cachekitio.config import CachekitIOBackendConfig
8
+
9
+ __all__ = [
10
+ "CachekitIOBackend",
11
+ "CachekitIOBackendConfig",
12
+ ]