xtr-cache 1.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.
Files changed (45) hide show
  1. xtr_cache/__init__.py +111 -0
  2. xtr_cache/adapter/__init__.py +31 -0
  3. xtr_cache/adapter/abstract_adapter.py +356 -0
  4. xtr_cache/adapter/adapter_factory.py +134 -0
  5. xtr_cache/adapter/adapter_interface.py +42 -0
  6. xtr_cache/adapter/array_adapter.py +165 -0
  7. xtr_cache/adapter/chain_adapter.py +185 -0
  8. xtr_cache/adapter/contracts_mixin.py +190 -0
  9. xtr_cache/adapter/deferred_items_mixin.py +78 -0
  10. xtr_cache/adapter/filesystem_adapter.py +256 -0
  11. xtr_cache/adapter/null_adapter.py +84 -0
  12. xtr_cache/adapter/redis_adapter.py +234 -0
  13. xtr_cache/adapter/tag_aware_adapter.py +261 -0
  14. xtr_cache/adapter/tag_aware_adapter_interface.py +16 -0
  15. xtr_cache/adapter/tagged_value.py +28 -0
  16. xtr_cache/bundle/__init__.py +17 -0
  17. xtr_cache/bundle/cache_bundle.py +289 -0
  18. xtr_cache/bundle/cache_config.py +115 -0
  19. xtr_cache/bundle/pool_config.py +69 -0
  20. xtr_cache/cache_item.py +262 -0
  21. xtr_cache/cache_pool_clearer.py +79 -0
  22. xtr_cache/command/__init__.py +33 -0
  23. xtr_cache/command/cache_pool_clear_command.py +65 -0
  24. xtr_cache/command/cache_pool_delete_command.py +50 -0
  25. xtr_cache/command/cache_pool_invalidate_tags_command.py +99 -0
  26. xtr_cache/command/cache_pool_list_command.py +32 -0
  27. xtr_cache/command/cache_pool_prune_command.py +47 -0
  28. xtr_cache/command/pool_command.py +37 -0
  29. xtr_cache/command/pools.py +35 -0
  30. xtr_cache/exception/__init__.py +20 -0
  31. xtr_cache/exception/logic_error.py +25 -0
  32. xtr_cache/exception/marshalling_error.py +27 -0
  33. xtr_cache/lock_registry.py +165 -0
  34. xtr_cache/marshaller/__init__.py +15 -0
  35. xtr_cache/marshaller/default_marshaller.py +61 -0
  36. xtr_cache/marshaller/deflate_marshaller.py +51 -0
  37. xtr_cache/marshaller/marshaller_interface.py +39 -0
  38. xtr_cache/marshaller/sodium_marshaller.py +143 -0
  39. xtr_cache/pruneable_interface.py +26 -0
  40. xtr_cache/py.typed +0 -0
  41. xtr_cache/value_wrapper.py +30 -0
  42. xtr_cache-1.2.0.dist-info/METADATA +412 -0
  43. xtr_cache-1.2.0.dist-info/RECORD +45 -0
  44. xtr_cache-1.2.0.dist-info/WHEEL +4 -0
  45. xtr_cache-1.2.0.dist-info/licenses/LICENSE +21 -0
xtr_cache/__init__.py ADDED
@@ -0,0 +1,111 @@
1
+ """Cache pools in memory, in files, in Redis, chained or tag-aware.
2
+
3
+ Every pool implements the ``xtr-cache-contracts`` interfaces, which this
4
+ package re-exports rather than redefines — ``xtr_cache.CacheInterface is
5
+ xtr_cache_contracts.CacheInterface`` — so code written against the contract
6
+ receives these pools unchanged.
7
+
8
+ ```python
9
+ cache = FilesystemAdapter("app")
10
+
11
+
12
+ async def load_profile(item: ItemInterface) -> Profile:
13
+ item.expires_after(3600)
14
+ return await profiles.fetch(user_id)
15
+
16
+
17
+ profile = await cache.get(f"profile.{user_id}", load_profile)
18
+ ```
19
+
20
+ Every call that reaches a backend is awaited. A backend failing never raises:
21
+ reads miss, writes return ``False``, and the pool logs why.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from importlib.metadata import PackageNotFoundError, version
27
+
28
+ from xtr_cache_contracts import (
29
+ RESERVED_CHARACTERS,
30
+ CacheInterface,
31
+ CacheItemPoolInterface,
32
+ CacheMixin,
33
+ Callback,
34
+ ItemInterface,
35
+ Metadata,
36
+ NamespacedPoolInterface,
37
+ TagAwareCacheInterface,
38
+ )
39
+
40
+ from .adapter import (
41
+ AbstractAdapter,
42
+ AdapterFactory,
43
+ AdapterInterface,
44
+ ArrayAdapter,
45
+ ChainAdapter,
46
+ ContractsMixin,
47
+ FilesystemAdapter,
48
+ NullAdapter,
49
+ RedisAdapter,
50
+ TagAwareAdapter,
51
+ TagAwareAdapterInterface,
52
+ TaggedValue,
53
+ )
54
+ from .cache_item import CacheItem
55
+ from .cache_pool_clearer import CachePoolClearer, PoolProvider
56
+ from .exception import CacheError, InvalidArgumentError, LogicError, MarshallingError
57
+ from .lock_registry import LockRegistry
58
+ from .marshaller import (
59
+ DefaultMarshaller,
60
+ DeflateMarshaller,
61
+ MarshallerInterface,
62
+ SodiumMarshaller,
63
+ )
64
+ from .pruneable_interface import PruneableInterface
65
+ from .value_wrapper import ValueWrapper
66
+
67
+ try:
68
+ __version__ = version("xtr-cache")
69
+ except PackageNotFoundError: # pragma: no cover
70
+ # Running from a source tree or a vendored copy, with no installed
71
+ # metadata to read. Having no version is better than refusing to import.
72
+ __version__ = "0+unknown"
73
+
74
+ __all__ = [
75
+ "RESERVED_CHARACTERS",
76
+ "AbstractAdapter",
77
+ "AdapterFactory",
78
+ "AdapterInterface",
79
+ "ArrayAdapter",
80
+ "CacheError",
81
+ "CacheInterface",
82
+ "CacheItem",
83
+ "CacheItemPoolInterface",
84
+ "CacheMixin",
85
+ "CachePoolClearer",
86
+ "Callback",
87
+ "ChainAdapter",
88
+ "ContractsMixin",
89
+ "DefaultMarshaller",
90
+ "DeflateMarshaller",
91
+ "FilesystemAdapter",
92
+ "InvalidArgumentError",
93
+ "ItemInterface",
94
+ "LockRegistry",
95
+ "LogicError",
96
+ "MarshallerInterface",
97
+ "MarshallingError",
98
+ "Metadata",
99
+ "NamespacedPoolInterface",
100
+ "NullAdapter",
101
+ "PoolProvider",
102
+ "PruneableInterface",
103
+ "RedisAdapter",
104
+ "SodiumMarshaller",
105
+ "TagAwareAdapter",
106
+ "TagAwareAdapterInterface",
107
+ "TagAwareCacheInterface",
108
+ "TaggedValue",
109
+ "ValueWrapper",
110
+ "__version__",
111
+ ]
@@ -0,0 +1,31 @@
1
+ """The pools: in memory, in files, in Redis, nowhere, chained, or tag-aware."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .abstract_adapter import AbstractAdapter
6
+ from .adapter_factory import AdapterFactory
7
+ from .adapter_interface import AdapterInterface
8
+ from .array_adapter import ArrayAdapter
9
+ from .chain_adapter import ChainAdapter
10
+ from .contracts_mixin import ContractsMixin
11
+ from .filesystem_adapter import FilesystemAdapter
12
+ from .null_adapter import NullAdapter
13
+ from .redis_adapter import RedisAdapter
14
+ from .tag_aware_adapter import TagAwareAdapter
15
+ from .tag_aware_adapter_interface import TagAwareAdapterInterface
16
+ from .tagged_value import TaggedValue
17
+
18
+ __all__ = [
19
+ "AbstractAdapter",
20
+ "AdapterFactory",
21
+ "AdapterInterface",
22
+ "ArrayAdapter",
23
+ "ChainAdapter",
24
+ "ContractsMixin",
25
+ "FilesystemAdapter",
26
+ "NullAdapter",
27
+ "RedisAdapter",
28
+ "TagAwareAdapter",
29
+ "TagAwareAdapterInterface",
30
+ "TaggedValue",
31
+ ]
@@ -0,0 +1,356 @@
1
+ """The shared machinery of a pool over a key-value backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import contextlib
7
+ import hashlib
8
+ import math
9
+ import re
10
+ from abc import ABC, abstractmethod
11
+ from typing import TYPE_CHECKING, ClassVar, Final, Self
12
+
13
+ from typing_extensions import override
14
+ from xtr_cache_contracts import InvalidArgumentError, NamespacedPoolInterface
15
+
16
+ from xtr_cache.cache_item import CacheItem
17
+ from xtr_cache.exception.marshalling_error import MarshallingError
18
+
19
+ from .adapter_interface import AdapterInterface
20
+ from .contracts_mixin import ContractsMixin
21
+ from .deferred_items_mixin import DeferredItemsMixin
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Iterable, Mapping, Sequence
25
+
26
+ from xtr_clock import ClockInterface
27
+
28
+ from xtr_cache.marshaller.marshaller_interface import MarshallerInterface
29
+
30
+ __all__ = ["AbstractAdapter"]
31
+
32
+ _PREFIX_PATTERN: Final = re.compile(r"[-+.:_A-Za-z0-9]*")
33
+
34
+ _MISSING: Final = object()
35
+
36
+
37
+ class AbstractAdapter(
38
+ DeferredItemsMixin, ContractsMixin, AdapterInterface, NamespacedPoolInterface, ABC
39
+ ):
40
+ """A pool whose backend stores values by identifier, with a lifetime each.
41
+
42
+ A subclass implements five operations on identifiers — fetch, has, clear,
43
+ delete, save — and gets every pool method on top: keys validated and
44
+ namespaced, deferred saves committed in batches by lifetime, sub-namespaces,
45
+ fetch-or-compute, and failures logged instead of raised. A backend that
46
+ cannot be reached makes reads miss and writes return ``False``; the code
47
+ caching keeps running.
48
+
49
+ An identifier is the pool's namespace followed by the key. A backend with
50
+ a limit on identifier length sets :attr:`max_id_length`, and keys that
51
+ would exceed it are hashed.
52
+ """
53
+
54
+ NS_SEPARATOR: ClassVar[str] = ":"
55
+ """What separates a namespace from what follows it in an identifier."""
56
+
57
+ max_id_length: ClassVar[int | None] = None
58
+ """The longest identifier the backend takes, or ``None`` for no limit."""
59
+
60
+ _namespace: str
61
+ _default_lifetime: float
62
+ _clock: ClockInterface
63
+ _deferred: dict[str, CacheItem]
64
+
65
+ def __init__(
66
+ self,
67
+ namespace: str = "",
68
+ default_lifetime: float = 0.0,
69
+ *,
70
+ clock: ClockInterface | None = None,
71
+ ) -> None:
72
+ """Keep values under ``namespace``, living ``default_lifetime`` unless an item says not.
73
+
74
+ Args:
75
+ namespace: Put in front of every key; ``:`` separates
76
+ sub-namespaces. Empty for none.
77
+ default_lifetime: Seconds a value lives when its item sets no
78
+ expiry; ``0`` keeps it until it is deleted.
79
+ clock: What lifetimes are counted from. ``None`` reads the clock
80
+ in force.
81
+
82
+ Raises:
83
+ InvalidArgumentError: When ``namespace`` is not a valid key once
84
+ its separators are removed, has an empty sub-namespace, or
85
+ leaves no room for keys within :attr:`max_id_length`.
86
+ """
87
+ if namespace:
88
+ if self.NS_SEPARATOR * 2 in namespace:
89
+ raise InvalidArgumentError(
90
+ f'Cache namespace "{namespace}" contains an empty sub-namespace.',
91
+ )
92
+ _ = CacheItem.validate_key(namespace.replace(self.NS_SEPARATOR, ""))
93
+ if self.max_id_length is not None and len(namespace) > self.max_id_length - 24:
94
+ raise InvalidArgumentError(
95
+ f"A cache namespace must be {self.max_id_length - 24} characters at most, "
96
+ f"got {len(namespace)}.",
97
+ )
98
+ namespace += self.NS_SEPARATOR
99
+
100
+ self._namespace = namespace
101
+ self._default_lifetime = default_lifetime
102
+ self._deferred = {}
103
+ if clock is not None:
104
+ self._clock = clock
105
+
106
+ @property
107
+ def namespace(self) -> str:
108
+ """What every identifier starts with, separator included; empty for none."""
109
+ return self._namespace
110
+
111
+ @property
112
+ def default_lifetime(self) -> float:
113
+ """Seconds a value lives when its item sets no expiry; ``0`` for no limit."""
114
+ return self._default_lifetime
115
+
116
+ @abstractmethod
117
+ async def _do_fetch(self, ids: Sequence[str]) -> Mapping[str, object]:
118
+ """Return the stored value of every identifier the backend holds, leaving out the rest."""
119
+
120
+ @abstractmethod
121
+ async def _do_have(self, id_: str) -> bool:
122
+ """Tell whether the backend holds a live value under ``id_``."""
123
+
124
+ @abstractmethod
125
+ async def _do_clear(self, namespace: str) -> bool:
126
+ """Remove every value whose identifier starts with ``namespace``; all of them when empty."""
127
+
128
+ @abstractmethod
129
+ async def _do_delete(self, ids: Sequence[str]) -> bool:
130
+ """Remove the values under ``ids``; ``False`` when that failed."""
131
+
132
+ @abstractmethod
133
+ async def _do_save(self, values: Mapping[str, object], lifetime: float) -> bool | Sequence[str]:
134
+ """Store ``values`` by identifier for ``lifetime`` seconds, ``0`` meaning no limit.
135
+
136
+ Returns:
137
+ ``True`` when every value was stored, ``False`` when none could
138
+ be and why is unknown, or the identifiers that failed.
139
+ """
140
+
141
+ @override
142
+ async def get_item(self, key: str, /) -> CacheItem:
143
+ id_ = self._get_id(key)
144
+ await self._commit_if_queued([key])
145
+
146
+ try:
147
+ found = await self._do_fetch([id_])
148
+ except Exception as error: # noqa: BLE001 — a cache failure must never fail the caller.
149
+ self._log('Failed to fetch key "{key}": {reason}', error, key=key)
150
+ return self._create_item(key)
151
+
152
+ stored = found.get(id_, _MISSING)
153
+ if stored is _MISSING:
154
+ return self._create_item(key)
155
+
156
+ return self._create_item(key, stored)
157
+
158
+ @override
159
+ async def get_items(self, keys: Iterable[str], /) -> Mapping[str, CacheItem]:
160
+ wanted = list(dict.fromkeys(keys))
161
+ ids = {key: self._get_id(key) for key in wanted}
162
+ await self._commit_if_queued(wanted)
163
+
164
+ found: Mapping[str, object] = {}
165
+ try:
166
+ found = await self._do_fetch(list(ids.values()))
167
+ except Exception as error: # noqa: BLE001 — a cache failure must never fail the caller.
168
+ self._log("Failed to fetch items: {reason}", error, keys=wanted)
169
+
170
+ items: dict[str, CacheItem] = {}
171
+ for key, id_ in ids.items():
172
+ stored = found.get(id_, _MISSING)
173
+ items[key] = (
174
+ self._create_item(key) if stored is _MISSING else self._create_item(key, stored)
175
+ )
176
+
177
+ return items
178
+
179
+ @override
180
+ async def has_item(self, key: str, /) -> bool:
181
+ id_ = self._get_id(key)
182
+ await self._commit_if_queued([key])
183
+
184
+ try:
185
+ return await self._do_have(id_)
186
+ except Exception as error: # noqa: BLE001 — a cache failure must never fail the caller.
187
+ self._log('Failed to check if key "{key}" is cached: {reason}', error, key=key)
188
+ return False
189
+
190
+ @override
191
+ async def clear(self, prefix: str = "") -> bool:
192
+ self._drop_deferred(prefix)
193
+ if not _PREFIX_PATTERN.fullmatch(prefix):
194
+ self._log("Failed to clear the cache: the prefix contains invalid characters.")
195
+ return False
196
+
197
+ try:
198
+ return await self._do_clear(self._namespace + prefix)
199
+ except Exception as error: # noqa: BLE001 — a cache failure must never fail the caller.
200
+ self._log("Failed to clear the cache: {reason}", error)
201
+ return False
202
+
203
+ @override
204
+ async def delete_item(self, key: str, /) -> bool:
205
+ return await self.delete_items([key])
206
+
207
+ @override
208
+ async def delete_items(self, keys: Iterable[str], /) -> bool:
209
+ ids = {key: self._get_id(key) for key in keys}
210
+ self._forget_deferred(ids)
211
+
212
+ if not ids:
213
+ return True
214
+
215
+ # A batch failing is retried one key at a time below, where each failure is logged.
216
+ with contextlib.suppress(Exception):
217
+ if await self._do_delete(list(ids.values())):
218
+ return True
219
+
220
+ ok = True
221
+ for key, id_ in ids.items():
222
+ try:
223
+ if await self._do_delete([id_]):
224
+ continue
225
+ self._log('Failed to delete key "{key}".', key=key)
226
+ except Exception as error: # noqa: BLE001 — a cache failure must never fail the caller.
227
+ self._log('Failed to delete key "{key}": {reason}', error, key=key)
228
+ ok = False
229
+
230
+ return ok
231
+
232
+ @override
233
+ async def commit(self) -> bool:
234
+ deferred, self._deferred = self._deferred, {}
235
+ if not deferred:
236
+ return True
237
+
238
+ now = self._clock.now().timestamp()
239
+ default_expiry = now + self._default_lifetime if self._default_lifetime > 0 else None
240
+ by_lifetime: dict[float, dict[str, object]] = {}
241
+ keys_by_id: dict[str, str] = {}
242
+ expired: list[str] = []
243
+ for key, item in deferred.items():
244
+ id_ = self._get_id(key)
245
+ keys_by_id[id_] = key
246
+ if item.expiry is None:
247
+ lifetime = max(self._default_lifetime, 0.0)
248
+ else:
249
+ lifetime = math.ceil((item.expiry - now) * 1000) / 1000
250
+ if lifetime <= 0:
251
+ expired.append(id_)
252
+ continue
253
+ by_lifetime.setdefault(lifetime, {})[id_] = item.pack(default_expiry)
254
+
255
+ ok = True
256
+ if expired:
257
+ try:
258
+ _ = await self._do_delete(expired)
259
+ except Exception as error: # noqa: BLE001 — a cache failure must never fail the caller.
260
+ ok = False
261
+ self._log("Failed to delete expired items: {reason}", error)
262
+
263
+ for lifetime, values in by_lifetime.items():
264
+ ok = await self._save_batch(values, lifetime, keys_by_id) and ok
265
+
266
+ return ok
267
+
268
+ @override
269
+ def with_sub_namespace(self, namespace: str, /) -> Self:
270
+ clone = self._unqueued_copy()
271
+ clone._namespace = ( # noqa: SLF001 — a copy of this very class.
272
+ self._namespace + CacheItem.validate_key(namespace) + self.NS_SEPARATOR
273
+ )
274
+ return clone
275
+
276
+ @override
277
+ def _scope(self) -> str:
278
+ return self._namespace
279
+
280
+ async def _save_batch(
281
+ self,
282
+ values: Mapping[str, object],
283
+ lifetime: float,
284
+ keys_by_id: Mapping[str, str],
285
+ ) -> bool:
286
+ """Save one batch, retrying value by value when the backend did not say what failed."""
287
+ error: Exception | None = None
288
+ try:
289
+ result = await self._do_save(values, lifetime)
290
+ except Exception as raised: # noqa: BLE001 — logged below, per value.
291
+ result, error = False, raised
292
+
293
+ if result is True:
294
+ return True
295
+ if result is not False:
296
+ for id_ in result:
297
+ self._log_save_failure(keys_by_id[id_], values[id_])
298
+ return len(result) == 0
299
+ if len(values) == 1:
300
+ for id_, value in values.items():
301
+ self._log_save_failure(keys_by_id[id_], value, error)
302
+ return False
303
+
304
+ ok = True
305
+ for id_, value in values.items():
306
+ ok = await self._save_batch({id_: value}, lifetime, keys_by_id) and ok
307
+
308
+ return ok
309
+
310
+ def _create_item(self, key: str, stored: object = _MISSING) -> CacheItem:
311
+ if stored is _MISSING:
312
+ return CacheItem(key, clock=self._clock)
313
+
314
+ return CacheItem.from_stored(key, stored, clock=self._clock)
315
+
316
+ def _get_id(self, key: str) -> str:
317
+ """Return the backend identifier of ``key``, hashing it when it would be too long.
318
+
319
+ Raises:
320
+ InvalidArgumentError: When ``key`` is not a valid key.
321
+ """
322
+ id_ = self._namespace + CacheItem.validate_key(key)
323
+ if self.max_id_length is None or len(id_) <= self.max_id_length:
324
+ return id_
325
+
326
+ digest = base64.urlsafe_b64encode(hashlib.sha256(key.encode()).digest()[:16]).decode()
327
+ return self._namespace + digest.rstrip("=") + self.NS_SEPARATOR
328
+
329
+ def _unmarshall_found(
330
+ self,
331
+ marshaller: MarshallerInterface,
332
+ raw: Mapping[str, bytes],
333
+ ) -> dict[str, object]:
334
+ """Decode what a byte backend returned, leaving out — and logging — what does not decode."""
335
+ values: dict[str, object] = {}
336
+ for id_, data in raw.items():
337
+ try:
338
+ values[id_] = marshaller.unmarshall(data)
339
+ except MarshallingError as error:
340
+ self._log('Failed to read key "{key}": {reason}', error, key=id_)
341
+
342
+ return values
343
+
344
+ def _log_save_failure(self, key: str, value: object, error: Exception | None = None) -> None:
345
+ kind = type(value).__qualname__
346
+ if error is None:
347
+ self._log(f'Failed to save key "{{key}}" of type {kind}.', key=key)
348
+ else:
349
+ self._log(f'Failed to save key "{{key}}" of type {kind}: {{reason}}', error, key=key)
350
+
351
+ def _log(self, message: str, error: Exception | None = None, **context: object) -> None:
352
+ context["cache_adapter"] = type(self).__name__
353
+ if error is not None:
354
+ context["exception"] = error
355
+ context["reason"] = str(error)
356
+ self.logger.warning(message, context)
@@ -0,0 +1,134 @@
1
+ """Builds an adapter from a DSN, a keyword, or a connection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Final, final
6
+
7
+ from xtr_lock.store import is_redis_client, is_redis_dsn, redis_installed
8
+
9
+ from xtr_cache.exception import InvalidArgumentError
10
+
11
+ from .array_adapter import ArrayAdapter
12
+ from .filesystem_adapter import FilesystemAdapter
13
+ from .null_adapter import NullAdapter
14
+ from .redis_adapter import RedisAdapter
15
+
16
+ if TYPE_CHECKING:
17
+ import os
18
+
19
+ from xtr_clock import ClockInterface
20
+
21
+ from xtr_cache.marshaller.marshaller_interface import MarshallerInterface
22
+
23
+ from .adapter_interface import AdapterInterface
24
+
25
+ __all__ = ["AdapterFactory"]
26
+
27
+ _FILESYSTEM: Final = "filesystem"
28
+ _FILESYSTEM_PREFIX: Final = "filesystem://"
29
+ _KEYWORDS: Final = ("array", "null", _FILESYSTEM)
30
+
31
+
32
+ @final
33
+ class AdapterFactory:
34
+ """Turns what a configuration names into an adapter.
35
+
36
+ | Given | Adapter |
37
+ |---|---|
38
+ | ``"array"`` | :class:`ArrayAdapter` |
39
+ | ``"null"`` | :class:`NullAdapter` |
40
+ | ``"filesystem"`` | :class:`FilesystemAdapter` in ``directory``, or the temporary one |
41
+ | ``"filesystem:///var/cache/app"`` | :class:`FilesystemAdapter` in that directory |
42
+ | ``"redis://…"``, ``"rediss://…"``, ``"unix://…"`` | :class:`RedisAdapter`, its own client |
43
+ | ``"valkey://…"``, ``"valkeys://…"`` | the same, read as ``redis`` / ``rediss`` |
44
+ | an asyncio Redis client | :class:`RedisAdapter` on that client |
45
+ """
46
+
47
+ __slots__ = ()
48
+
49
+ @staticmethod
50
+ def create_adapter( # noqa: PLR0913 — every option past the connection is keyword-only.
51
+ connection: object,
52
+ namespace: str = "",
53
+ default_lifetime: float = 0.0,
54
+ *,
55
+ marshaller: MarshallerInterface | None = None,
56
+ directory: str | os.PathLike[str] | None = None,
57
+ clock: ClockInterface | None = None,
58
+ ) -> AdapterInterface:
59
+ """Build the adapter ``connection`` names.
60
+
61
+ Args:
62
+ connection: A DSN, a keyword, or an asyncio Redis client.
63
+ namespace: The pool's namespace; an in-memory pool has no use for one.
64
+ default_lifetime: Seconds a value lives when its item sets no expiry.
65
+ marshaller: What turns values into bytes, for a backend storing bytes.
66
+ directory: Where ``"filesystem"`` keeps its files.
67
+ clock: What lifetimes are counted from.
68
+
69
+ Raises:
70
+ InvalidArgumentError: When no adapter serves ``connection``. The
71
+ message names its scheme or type only, never credentials.
72
+ """
73
+ if is_redis_client(connection):
74
+ return RedisAdapter(
75
+ connection,
76
+ namespace,
77
+ default_lifetime,
78
+ marshaller=marshaller,
79
+ clock=clock,
80
+ )
81
+
82
+ if not isinstance(connection, str):
83
+ raise InvalidArgumentError(
84
+ f'Unsupported cache connection: "{type(connection).__qualname__}".',
85
+ )
86
+
87
+ AdapterFactory.validate(connection)
88
+
89
+ if connection == "array":
90
+ return ArrayAdapter(default_lifetime, marshaller=marshaller, clock=clock)
91
+ if connection == "null":
92
+ return NullAdapter()
93
+ if connection == _FILESYSTEM or connection.startswith(_FILESYSTEM_PREFIX):
94
+ path = (
95
+ connection.removeprefix(_FILESYSTEM_PREFIX) if connection != _FILESYSTEM else None
96
+ )
97
+ return FilesystemAdapter(
98
+ namespace,
99
+ default_lifetime,
100
+ path or directory,
101
+ marshaller=marshaller,
102
+ clock=clock,
103
+ )
104
+
105
+ # The only thing validate() lets through that is not handled above.
106
+ return RedisAdapter.from_url(
107
+ connection,
108
+ namespace,
109
+ default_lifetime,
110
+ marshaller=marshaller,
111
+ clock=clock,
112
+ )
113
+
114
+ @staticmethod
115
+ def validate(connection: str) -> None:
116
+ """Refuse a DSN no adapter serves, without building one or connecting to anything.
117
+
118
+ Raises:
119
+ InvalidArgumentError: When no adapter serves ``connection``, or it
120
+ needs an extra that is not installed. The message names its
121
+ scheme only, never credentials.
122
+ """
123
+ if connection in _KEYWORDS or connection.startswith(_FILESYSTEM_PREFIX):
124
+ return
125
+
126
+ if is_redis_dsn(connection):
127
+ if not redis_installed():
128
+ raise InvalidArgumentError(RedisAdapter.MISSING_CLIENT)
129
+ return
130
+
131
+ scheme, separator, _ = connection.partition(":")
132
+ described = f"{scheme}:" if separator else connection
133
+
134
+ raise InvalidArgumentError(f'Unsupported cache connection: "{described}".')
@@ -0,0 +1,42 @@
1
+ """What every pool of this library is: an item pool, a cache, and resettable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
6
+
7
+ from typing_extensions import override
8
+ from xtr_cache_contracts import CacheInterface, CacheItemPoolInterface
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Iterable, Mapping
12
+
13
+ from xtr_cache.cache_item import CacheItem
14
+
15
+ __all__ = ["AdapterInterface"]
16
+
17
+
18
+ @runtime_checkable
19
+ class AdapterInterface(CacheItemPoolInterface, CacheInterface, Protocol):
20
+ """A pool handing out :class:`~xtr_cache.cache_item.CacheItem`, with fetch-or-compute on top.
21
+
22
+ Every adapter of this library is one, which is what lets them wrap and
23
+ chain each other: an item one hands out, another stores.
24
+ """
25
+
26
+ @override
27
+ async def get_item(self, key: str, /) -> CacheItem:
28
+ """Return the item for ``key``, a hit or a miss."""
29
+ ...
30
+
31
+ @override
32
+ async def get_items(self, keys: Iterable[str], /) -> Mapping[str, CacheItem]:
33
+ """Return an item for each of ``keys``, in the order asked."""
34
+ ...
35
+
36
+ async def reset(self) -> None:
37
+ """End a unit of work: store what was saved as deferred, and forget per-unit state.
38
+
39
+ What a long-running process calls between messages or requests, so
40
+ deferred items do not wait for a commit that nothing will call.
41
+ """
42
+ ...