xtr-cache-contracts 1.2.0__tar.gz
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.
- xtr_cache_contracts-1.2.0/LICENSE +21 -0
- xtr_cache_contracts-1.2.0/PKG-INFO +239 -0
- xtr_cache_contracts-1.2.0/README.md +217 -0
- xtr_cache_contracts-1.2.0/pyproject.toml +143 -0
- xtr_cache_contracts-1.2.0/pyproject.toml.orig +137 -0
- xtr_cache_contracts-1.2.0/src/xtr_cache_contracts/__init__.py +67 -0
- xtr_cache_contracts-1.2.0/src/xtr_cache_contracts/cache_interface.py +85 -0
- xtr_cache_contracts-1.2.0/src/xtr_cache_contracts/cache_item_pool_interface.py +133 -0
- xtr_cache_contracts-1.2.0/src/xtr_cache_contracts/cache_mixin.py +169 -0
- xtr_cache_contracts-1.2.0/src/xtr_cache_contracts/callback.py +33 -0
- xtr_cache_contracts-1.2.0/src/xtr_cache_contracts/exception/__init__.py +17 -0
- xtr_cache_contracts-1.2.0/src/xtr_cache_contracts/exception/cache_error.py +20 -0
- xtr_cache_contracts-1.2.0/src/xtr_cache_contracts/exception/invalid_argument_error.py +28 -0
- xtr_cache_contracts-1.2.0/src/xtr_cache_contracts/item_interface.py +93 -0
- xtr_cache_contracts-1.2.0/src/xtr_cache_contracts/metadata.py +37 -0
- xtr_cache_contracts-1.2.0/src/xtr_cache_contracts/namespaced_pool_interface.py +29 -0
- xtr_cache_contracts-1.2.0/src/xtr_cache_contracts/py.typed +0 -0
- xtr_cache_contracts-1.2.0/src/xtr_cache_contracts/tag_aware_cache_interface.py +47 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xterr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xtr-cache-contracts
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: The caching contract: compute-once reads, item pools, tags and namespaces, with no backend attached.
|
|
5
|
+
Keywords: cache,contracts,interface,protocol,asyncio,stampede
|
|
6
|
+
Author: Razvan Ceana
|
|
7
|
+
Author-email: Razvan Ceana <razvan@ceana.ro>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Dist: typing-extensions>=4.4
|
|
19
|
+
Requires-Dist: xtr-clock>=1.0,<2
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
<div align="center">
|
|
24
|
+
|
|
25
|
+
# xtr-cache-contracts
|
|
26
|
+
|
|
27
|
+
**The caching contract, and nothing else â so a library that caches installs nothing else.**
|
|
28
|
+
|
|
29
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
30
|
+
<img alt="asyncio" src="https://img.shields.io/badge/asyncio-native-1f6feb">
|
|
31
|
+
<img alt="core dependencies: 2" src="https://img.shields.io/badge/core%20deps-2-3FB950">
|
|
32
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
33
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
34
|
+
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Why?
|
|
40
|
+
|
|
41
|
+
A library that caches should not decide where values go. It takes a `CacheInterface`, hands it a
|
|
42
|
+
key and the function that computes the value, and leaves memory, files or a server to the
|
|
43
|
+
application that wires it.
|
|
44
|
+
|
|
45
|
+
That needs the *contract*, not a backend. This package is that dependency, reduced to what the
|
|
46
|
+
seam is made of:
|
|
47
|
+
|
|
48
|
+
- ð **`CacheInterface`** â fetch-or-compute in one call, and delete.
|
|
49
|
+
- ð·ïļ **`TagAwareCacheInterface`** â drop every value carrying a tag, whatever its key.
|
|
50
|
+
- ðïļ **`CacheItemPoolInterface`** â the items underneath: hits told apart from misses, several
|
|
51
|
+
keys per round trip, batched writes.
|
|
52
|
+
- ð **`NamespacedPoolInterface`** â a view of a pool confined to a sub-namespace.
|
|
53
|
+
- ð§Đ **`CacheMixin`** â `CacheInterface` for free on any pool, with early recomputation.
|
|
54
|
+
- ðŠķ **Two small dependencies** â `typing-extensions`, for `@override` on 3.11, and
|
|
55
|
+
`xtr-clock`, which has none of its own.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from xtr_cache_contracts import CacheInterface, ItemInterface
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Profiles:
|
|
62
|
+
def __init__(self, cache: CacheInterface) -> None:
|
|
63
|
+
self._cache = cache
|
|
64
|
+
|
|
65
|
+
async def get(self, user_id: int) -> Profile:
|
|
66
|
+
async def load(item: ItemInterface) -> Profile:
|
|
67
|
+
item.expires_after(3600)
|
|
68
|
+
return await fetch_profile(user_id)
|
|
69
|
+
|
|
70
|
+
return await self._cache.get(f"profile.{user_id}", load)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Install
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
uv add xtr-cache-contracts
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Requires Python 3.11+.
|
|
80
|
+
|
|
81
|
+
## Who installs what
|
|
82
|
+
|
|
83
|
+
| | Depends on |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| **A library that caches** | `xtr-cache-contracts` at runtime. |
|
|
86
|
+
| **An application** | `xtr-cache`, which implements this contract with adapters and wires pools from configuration. |
|
|
87
|
+
|
|
88
|
+
`xtr-cache` **re-exports** every symbol here rather than redefining it, so
|
|
89
|
+
`xtr_cache.CacheInterface is xtr_cache_contracts.CacheInterface`. That identity is what lets a
|
|
90
|
+
container register a pool under the interface and a library, which never imported `xtr-cache`,
|
|
91
|
+
receive it.
|
|
92
|
+
|
|
93
|
+
## Fetch-or-compute
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
class CacheInterface(Protocol):
|
|
97
|
+
async def get(
|
|
98
|
+
self,
|
|
99
|
+
key: str,
|
|
100
|
+
callback: Callback[T],
|
|
101
|
+
/,
|
|
102
|
+
*,
|
|
103
|
+
beta: float | None = None,
|
|
104
|
+
metadata: Metadata | None = None,
|
|
105
|
+
) -> T: ...
|
|
106
|
+
|
|
107
|
+
async def delete(self, key: str, /) -> bool: ...
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
On a miss, `callback` is awaited with the item for `key`; what it returns is saved and returned.
|
|
111
|
+
On a hit, the stored value comes back as the type `callback` returns â keep one key for one type.
|
|
112
|
+
|
|
113
|
+
Checking for a value, computing it and saving it is three steps with a race between each. Handing
|
|
114
|
+
the cache the computation instead lets it decide when to run it, which is what makes stampede
|
|
115
|
+
protection possible: computing a missing key once for every caller waiting on it, or refreshing
|
|
116
|
+
a value just before it expires.
|
|
117
|
+
|
|
118
|
+
- **The callback** is any `async def` taking the item, or an object with an `async def __call__`.
|
|
119
|
+
It sets the value's lifetime and tags through the item. If it raises, nothing is stored and
|
|
120
|
+
the error reaches the caller unchanged.
|
|
121
|
+
- **`beta`** controls early recomputation. A hit whose metadata says when it expires and how long
|
|
122
|
+
it took to compute may be recomputed before it expires, with a chance that grows as expiry
|
|
123
|
+
nears and as `beta` grows. `0` disables it, `math.inf` forces recomputation now, and `None`
|
|
124
|
+
leaves the choice to the implementation (`1.0` in `CacheMixin`).
|
|
125
|
+
- **`metadata`** is a dict the cache fills: `expiry` (Unix timestamp), `ctime` (milliseconds the
|
|
126
|
+
value took to compute), `tags`, and `save_failed` when a computed value could not be stored.
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from xtr_cache_contracts import Metadata
|
|
130
|
+
|
|
131
|
+
metadata: Metadata = {}
|
|
132
|
+
report = await cache.get("report.daily", build_report, metadata=metadata)
|
|
133
|
+
if metadata.get("save_failed"):
|
|
134
|
+
...
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Items and pools
|
|
138
|
+
|
|
139
|
+
`CacheItemPoolInterface` is the level below: for code that needs a hit told apart from a miss,
|
|
140
|
+
several keys in one round trip, or writes batched until `commit()` â and the level a backend
|
|
141
|
+
implements.
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
item = await pool.get_item("rate.42")
|
|
145
|
+
if not item.is_hit():
|
|
146
|
+
await pool.save(item.set(0).expires_after(60))
|
|
147
|
+
|
|
148
|
+
items = await pool.get_items(["a", "b", "c"]) # every key, hit or miss, in the order asked
|
|
149
|
+
await pool.save_deferred(item) # queued ...
|
|
150
|
+
await pool.commit() # ... stored
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
An item is returned for every key, found or not, so `None` is a value like any other. Changing
|
|
154
|
+
an item reaches the backend only once it is saved. A pool refuses, with `False`, an item of a kind
|
|
155
|
+
it does not store.
|
|
156
|
+
|
|
157
|
+
The rules:
|
|
158
|
+
|
|
159
|
+
- **Keys** are non-empty strings without any of `{}()/\@:` (`RESERVED_CHARACTERS`). Letters,
|
|
160
|
+
digits, `_` and `.` up to 64 characters work everywhere; a pool may accept more. Tags follow
|
|
161
|
+
the same rules.
|
|
162
|
+
- **A backend failure never raises.** The call returns `False`, or reads as a miss, and the
|
|
163
|
+
implementation logs it: code that caches keeps working when the cache does not. What raises
|
|
164
|
+
is a mistake in the calling code â an invalid key, a tag on an item whose pool cannot store
|
|
165
|
+
tags.
|
|
166
|
+
- **Lifetimes** are set on the item: `expires_after(seconds or timedelta)` or
|
|
167
|
+
`expires_at(datetime)`. `None` falls back to the pool's default; a lifetime of zero or less
|
|
168
|
+
removes the key when the item is saved.
|
|
169
|
+
- **Deferred items** are committed by `commit()`, or before their key is read from the same pool.
|
|
170
|
+
Nothing commits them when the pool is discarded.
|
|
171
|
+
|
|
172
|
+
## Tags and namespaces
|
|
173
|
+
|
|
174
|
+
A tag-aware cache drops values by tag rather than by key. Tag a value when computing it:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
async def load_invoice(item: ItemInterface) -> Invoice:
|
|
178
|
+
item.tag([f"customer.{customer_id}", "invoices"])
|
|
179
|
+
return await fetch_invoice(invoice_id)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
await cache.get(f"invoice.{invoice_id}", load_invoice)
|
|
183
|
+
await cache.invalidate_tags([f"customer.{customer_id}"]) # every invoice of that customer
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
A namespaced pool hands out a view of itself whose keys live under a sub-namespace, so a group of
|
|
187
|
+
keys can be cleared together: `pool.with_sub_namespace("tenant42")`. The original pool is left as
|
|
188
|
+
it was. Tags ignore sub-namespaces.
|
|
189
|
+
|
|
190
|
+
## Implementing a pool
|
|
191
|
+
|
|
192
|
+
Implement `CacheItemPoolInterface` and derive from `CacheMixin` to get `get()` and `delete()`
|
|
193
|
+
built on your `get_item()`, `save()` and `delete_item()`:
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
from xtr_cache_contracts import CacheItemPoolInterface, CacheMixin
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class MyPool(CacheItemPoolInterface, CacheMixin): ...
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`CacheMixin` computes a miss, saves it, reports a failed save in `metadata`, and recomputes a hit
|
|
203
|
+
early when its metadata allows. It reads the time from the clock in force (`xtr_clock.now()`),
|
|
204
|
+
so `mock_time()` or a `MockClock` installed by a test freezes it too. It does not make concurrent misses on one key compute once â that
|
|
205
|
+
needs a lock or a shared in-flight computation, and is the implementation's to add by overriding
|
|
206
|
+
`get()`.
|
|
207
|
+
|
|
208
|
+
## What is not here
|
|
209
|
+
|
|
210
|
+
Everything that stores or acts on values: the item class, adapters for memory, files and Redis,
|
|
211
|
+
serialisation, stampede locking, chaining, and the bundle. All of that is
|
|
212
|
+
[xtr-cache](https://github.com/xterr/python-xtr-cache).
|
|
213
|
+
|
|
214
|
+
There is no simple key-value interface (`get(key, default)` / `set(key, value, ttl)`): it cannot
|
|
215
|
+
tell a cached `None` from a miss, and checking then reading is a race. Fetch-or-compute covers the
|
|
216
|
+
common case, and the item pool covers the rest.
|
|
217
|
+
|
|
218
|
+
## Errors
|
|
219
|
+
|
|
220
|
+
| Error | Raised when |
|
|
221
|
+
| --- | --- |
|
|
222
|
+
| `CacheError` | Never directly â the base every caching error derives from, `xtr-cache`'s included, such as the one `tag()` raises on an item whose pool cannot store tags |
|
|
223
|
+
| `InvalidArgumentError` | A key, a tag, a namespace or `beta` is invalid (also a `ValueError`); what is wrong is in `reason` |
|
|
224
|
+
|
|
225
|
+
## Development
|
|
226
|
+
|
|
227
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
228
|
+
`packages/xtr-cache-contracts`; run the commands below from there. The
|
|
229
|
+
`python-xtr-cache-contracts` repository is a read-only copy, so send issues and pull requests to
|
|
230
|
+
the monorepo.
|
|
231
|
+
|
|
232
|
+
```sh
|
|
233
|
+
uv sync
|
|
234
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## License
|
|
238
|
+
|
|
239
|
+
MIT â see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# xtr-cache-contracts
|
|
4
|
+
|
|
5
|
+
**The caching contract, and nothing else â so a library that caches installs nothing else.**
|
|
6
|
+
|
|
7
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
8
|
+
<img alt="asyncio" src="https://img.shields.io/badge/asyncio-native-1f6feb">
|
|
9
|
+
<img alt="core dependencies: 2" src="https://img.shields.io/badge/core%20deps-2-3FB950">
|
|
10
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
11
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
12
|
+
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Why?
|
|
18
|
+
|
|
19
|
+
A library that caches should not decide where values go. It takes a `CacheInterface`, hands it a
|
|
20
|
+
key and the function that computes the value, and leaves memory, files or a server to the
|
|
21
|
+
application that wires it.
|
|
22
|
+
|
|
23
|
+
That needs the *contract*, not a backend. This package is that dependency, reduced to what the
|
|
24
|
+
seam is made of:
|
|
25
|
+
|
|
26
|
+
- ð **`CacheInterface`** â fetch-or-compute in one call, and delete.
|
|
27
|
+
- ð·ïļ **`TagAwareCacheInterface`** â drop every value carrying a tag, whatever its key.
|
|
28
|
+
- ðïļ **`CacheItemPoolInterface`** â the items underneath: hits told apart from misses, several
|
|
29
|
+
keys per round trip, batched writes.
|
|
30
|
+
- ð **`NamespacedPoolInterface`** â a view of a pool confined to a sub-namespace.
|
|
31
|
+
- ð§Đ **`CacheMixin`** â `CacheInterface` for free on any pool, with early recomputation.
|
|
32
|
+
- ðŠķ **Two small dependencies** â `typing-extensions`, for `@override` on 3.11, and
|
|
33
|
+
`xtr-clock`, which has none of its own.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from xtr_cache_contracts import CacheInterface, ItemInterface
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Profiles:
|
|
40
|
+
def __init__(self, cache: CacheInterface) -> None:
|
|
41
|
+
self._cache = cache
|
|
42
|
+
|
|
43
|
+
async def get(self, user_id: int) -> Profile:
|
|
44
|
+
async def load(item: ItemInterface) -> Profile:
|
|
45
|
+
item.expires_after(3600)
|
|
46
|
+
return await fetch_profile(user_id)
|
|
47
|
+
|
|
48
|
+
return await self._cache.get(f"profile.{user_id}", load)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
uv add xtr-cache-contracts
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Requires Python 3.11+.
|
|
58
|
+
|
|
59
|
+
## Who installs what
|
|
60
|
+
|
|
61
|
+
| | Depends on |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| **A library that caches** | `xtr-cache-contracts` at runtime. |
|
|
64
|
+
| **An application** | `xtr-cache`, which implements this contract with adapters and wires pools from configuration. |
|
|
65
|
+
|
|
66
|
+
`xtr-cache` **re-exports** every symbol here rather than redefining it, so
|
|
67
|
+
`xtr_cache.CacheInterface is xtr_cache_contracts.CacheInterface`. That identity is what lets a
|
|
68
|
+
container register a pool under the interface and a library, which never imported `xtr-cache`,
|
|
69
|
+
receive it.
|
|
70
|
+
|
|
71
|
+
## Fetch-or-compute
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
class CacheInterface(Protocol):
|
|
75
|
+
async def get(
|
|
76
|
+
self,
|
|
77
|
+
key: str,
|
|
78
|
+
callback: Callback[T],
|
|
79
|
+
/,
|
|
80
|
+
*,
|
|
81
|
+
beta: float | None = None,
|
|
82
|
+
metadata: Metadata | None = None,
|
|
83
|
+
) -> T: ...
|
|
84
|
+
|
|
85
|
+
async def delete(self, key: str, /) -> bool: ...
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
On a miss, `callback` is awaited with the item for `key`; what it returns is saved and returned.
|
|
89
|
+
On a hit, the stored value comes back as the type `callback` returns â keep one key for one type.
|
|
90
|
+
|
|
91
|
+
Checking for a value, computing it and saving it is three steps with a race between each. Handing
|
|
92
|
+
the cache the computation instead lets it decide when to run it, which is what makes stampede
|
|
93
|
+
protection possible: computing a missing key once for every caller waiting on it, or refreshing
|
|
94
|
+
a value just before it expires.
|
|
95
|
+
|
|
96
|
+
- **The callback** is any `async def` taking the item, or an object with an `async def __call__`.
|
|
97
|
+
It sets the value's lifetime and tags through the item. If it raises, nothing is stored and
|
|
98
|
+
the error reaches the caller unchanged.
|
|
99
|
+
- **`beta`** controls early recomputation. A hit whose metadata says when it expires and how long
|
|
100
|
+
it took to compute may be recomputed before it expires, with a chance that grows as expiry
|
|
101
|
+
nears and as `beta` grows. `0` disables it, `math.inf` forces recomputation now, and `None`
|
|
102
|
+
leaves the choice to the implementation (`1.0` in `CacheMixin`).
|
|
103
|
+
- **`metadata`** is a dict the cache fills: `expiry` (Unix timestamp), `ctime` (milliseconds the
|
|
104
|
+
value took to compute), `tags`, and `save_failed` when a computed value could not be stored.
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from xtr_cache_contracts import Metadata
|
|
108
|
+
|
|
109
|
+
metadata: Metadata = {}
|
|
110
|
+
report = await cache.get("report.daily", build_report, metadata=metadata)
|
|
111
|
+
if metadata.get("save_failed"):
|
|
112
|
+
...
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Items and pools
|
|
116
|
+
|
|
117
|
+
`CacheItemPoolInterface` is the level below: for code that needs a hit told apart from a miss,
|
|
118
|
+
several keys in one round trip, or writes batched until `commit()` â and the level a backend
|
|
119
|
+
implements.
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
item = await pool.get_item("rate.42")
|
|
123
|
+
if not item.is_hit():
|
|
124
|
+
await pool.save(item.set(0).expires_after(60))
|
|
125
|
+
|
|
126
|
+
items = await pool.get_items(["a", "b", "c"]) # every key, hit or miss, in the order asked
|
|
127
|
+
await pool.save_deferred(item) # queued ...
|
|
128
|
+
await pool.commit() # ... stored
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
An item is returned for every key, found or not, so `None` is a value like any other. Changing
|
|
132
|
+
an item reaches the backend only once it is saved. A pool refuses, with `False`, an item of a kind
|
|
133
|
+
it does not store.
|
|
134
|
+
|
|
135
|
+
The rules:
|
|
136
|
+
|
|
137
|
+
- **Keys** are non-empty strings without any of `{}()/\@:` (`RESERVED_CHARACTERS`). Letters,
|
|
138
|
+
digits, `_` and `.` up to 64 characters work everywhere; a pool may accept more. Tags follow
|
|
139
|
+
the same rules.
|
|
140
|
+
- **A backend failure never raises.** The call returns `False`, or reads as a miss, and the
|
|
141
|
+
implementation logs it: code that caches keeps working when the cache does not. What raises
|
|
142
|
+
is a mistake in the calling code â an invalid key, a tag on an item whose pool cannot store
|
|
143
|
+
tags.
|
|
144
|
+
- **Lifetimes** are set on the item: `expires_after(seconds or timedelta)` or
|
|
145
|
+
`expires_at(datetime)`. `None` falls back to the pool's default; a lifetime of zero or less
|
|
146
|
+
removes the key when the item is saved.
|
|
147
|
+
- **Deferred items** are committed by `commit()`, or before their key is read from the same pool.
|
|
148
|
+
Nothing commits them when the pool is discarded.
|
|
149
|
+
|
|
150
|
+
## Tags and namespaces
|
|
151
|
+
|
|
152
|
+
A tag-aware cache drops values by tag rather than by key. Tag a value when computing it:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
async def load_invoice(item: ItemInterface) -> Invoice:
|
|
156
|
+
item.tag([f"customer.{customer_id}", "invoices"])
|
|
157
|
+
return await fetch_invoice(invoice_id)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
await cache.get(f"invoice.{invoice_id}", load_invoice)
|
|
161
|
+
await cache.invalidate_tags([f"customer.{customer_id}"]) # every invoice of that customer
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
A namespaced pool hands out a view of itself whose keys live under a sub-namespace, so a group of
|
|
165
|
+
keys can be cleared together: `pool.with_sub_namespace("tenant42")`. The original pool is left as
|
|
166
|
+
it was. Tags ignore sub-namespaces.
|
|
167
|
+
|
|
168
|
+
## Implementing a pool
|
|
169
|
+
|
|
170
|
+
Implement `CacheItemPoolInterface` and derive from `CacheMixin` to get `get()` and `delete()`
|
|
171
|
+
built on your `get_item()`, `save()` and `delete_item()`:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
from xtr_cache_contracts import CacheItemPoolInterface, CacheMixin
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class MyPool(CacheItemPoolInterface, CacheMixin): ...
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`CacheMixin` computes a miss, saves it, reports a failed save in `metadata`, and recomputes a hit
|
|
181
|
+
early when its metadata allows. It reads the time from the clock in force (`xtr_clock.now()`),
|
|
182
|
+
so `mock_time()` or a `MockClock` installed by a test freezes it too. It does not make concurrent misses on one key compute once â that
|
|
183
|
+
needs a lock or a shared in-flight computation, and is the implementation's to add by overriding
|
|
184
|
+
`get()`.
|
|
185
|
+
|
|
186
|
+
## What is not here
|
|
187
|
+
|
|
188
|
+
Everything that stores or acts on values: the item class, adapters for memory, files and Redis,
|
|
189
|
+
serialisation, stampede locking, chaining, and the bundle. All of that is
|
|
190
|
+
[xtr-cache](https://github.com/xterr/python-xtr-cache).
|
|
191
|
+
|
|
192
|
+
There is no simple key-value interface (`get(key, default)` / `set(key, value, ttl)`): it cannot
|
|
193
|
+
tell a cached `None` from a miss, and checking then reading is a race. Fetch-or-compute covers the
|
|
194
|
+
common case, and the item pool covers the rest.
|
|
195
|
+
|
|
196
|
+
## Errors
|
|
197
|
+
|
|
198
|
+
| Error | Raised when |
|
|
199
|
+
| --- | --- |
|
|
200
|
+
| `CacheError` | Never directly â the base every caching error derives from, `xtr-cache`'s included, such as the one `tag()` raises on an item whose pool cannot store tags |
|
|
201
|
+
| `InvalidArgumentError` | A key, a tag, a namespace or `beta` is invalid (also a `ValueError`); what is wrong is in `reason` |
|
|
202
|
+
|
|
203
|
+
## Development
|
|
204
|
+
|
|
205
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
206
|
+
`packages/xtr-cache-contracts`; run the commands below from there. The
|
|
207
|
+
`python-xtr-cache-contracts` repository is a read-only copy, so send issues and pull requests to
|
|
208
|
+
the monorepo.
|
|
209
|
+
|
|
210
|
+
```sh
|
|
211
|
+
uv sync
|
|
212
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## License
|
|
216
|
+
|
|
217
|
+
MIT â see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "xtr-cache-contracts"
|
|
3
|
+
version = "1.2.0"
|
|
4
|
+
description = "The caching contract: compute-once reads, item pools, tags and namespaces, with no backend attached."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"cache",
|
|
11
|
+
"contracts",
|
|
12
|
+
"interface",
|
|
13
|
+
"protocol",
|
|
14
|
+
"asyncio",
|
|
15
|
+
"stampede",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Framework :: AsyncIO",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"typing-extensions>=4.4",
|
|
29
|
+
"xtr-clock>=1.0,<2",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[[project.authors]]
|
|
33
|
+
name = "Razvan Ceana"
|
|
34
|
+
email = "razvan@ceana.ro"
|
|
35
|
+
|
|
36
|
+
[dependency-groups]
|
|
37
|
+
dev = [
|
|
38
|
+
"basedpyright>=1.21",
|
|
39
|
+
"ruff>=0.8",
|
|
40
|
+
"pytest>=8",
|
|
41
|
+
"pytest-cov>=5",
|
|
42
|
+
"anyio>=4.0",
|
|
43
|
+
"ty>=0.0.83",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[build-system]
|
|
47
|
+
requires = ["uv_build>=0.9.18,<0.10.0"]
|
|
48
|
+
build-backend = "uv_build"
|
|
49
|
+
|
|
50
|
+
[tool.basedpyright]
|
|
51
|
+
typeCheckingMode = "all"
|
|
52
|
+
pythonVersion = "3.11"
|
|
53
|
+
pythonPlatform = "All"
|
|
54
|
+
include = [
|
|
55
|
+
"src",
|
|
56
|
+
"tests",
|
|
57
|
+
]
|
|
58
|
+
exclude = [
|
|
59
|
+
"**/__pycache__",
|
|
60
|
+
"**/.venv",
|
|
61
|
+
"**/build",
|
|
62
|
+
"**/dist",
|
|
63
|
+
".tmp",
|
|
64
|
+
]
|
|
65
|
+
reportUnusedCallResult = "warning"
|
|
66
|
+
reportUnnecessaryTypeIgnoreComment = "error"
|
|
67
|
+
reportUnusedVariable = "error"
|
|
68
|
+
reportMissingParameterType = "error"
|
|
69
|
+
reportPrivateUsage = "error"
|
|
70
|
+
|
|
71
|
+
[tool.ruff]
|
|
72
|
+
target-version = "py311"
|
|
73
|
+
line-length = 100
|
|
74
|
+
src = [
|
|
75
|
+
"src",
|
|
76
|
+
"tests",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
[tool.ruff.lint]
|
|
80
|
+
select = ["ALL"]
|
|
81
|
+
ignore = [
|
|
82
|
+
"COM812",
|
|
83
|
+
"ISC001",
|
|
84
|
+
"D203",
|
|
85
|
+
"D213",
|
|
86
|
+
"CPY001",
|
|
87
|
+
"FBT001",
|
|
88
|
+
"FBT002",
|
|
89
|
+
"TD002",
|
|
90
|
+
"TD003",
|
|
91
|
+
"FIX002",
|
|
92
|
+
"TRY003",
|
|
93
|
+
"EM101",
|
|
94
|
+
"EM102",
|
|
95
|
+
]
|
|
96
|
+
fixable = ["ALL"]
|
|
97
|
+
unfixable = []
|
|
98
|
+
|
|
99
|
+
[tool.ruff.lint.per-file-ignores]
|
|
100
|
+
"tests/**/*.py" = [
|
|
101
|
+
"S101",
|
|
102
|
+
"ARG",
|
|
103
|
+
"PLR2004",
|
|
104
|
+
"SLF001",
|
|
105
|
+
"D",
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
[tool.ruff.lint.pydocstyle]
|
|
109
|
+
convention = "google"
|
|
110
|
+
|
|
111
|
+
[tool.ruff.format]
|
|
112
|
+
quote-style = "double"
|
|
113
|
+
indent-style = "space"
|
|
114
|
+
docstring-code-format = true
|
|
115
|
+
docstring-code-line-length = "dynamic"
|
|
116
|
+
|
|
117
|
+
[tool.ty.src]
|
|
118
|
+
include = [
|
|
119
|
+
"src",
|
|
120
|
+
"tests",
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
[tool.pytest.ini_options]
|
|
124
|
+
minversion = "8.0"
|
|
125
|
+
testpaths = ["tests"]
|
|
126
|
+
addopts = [
|
|
127
|
+
"-ra",
|
|
128
|
+
"--strict-config",
|
|
129
|
+
"--strict-markers",
|
|
130
|
+
]
|
|
131
|
+
filterwarnings = ["error"]
|
|
132
|
+
|
|
133
|
+
[tool.coverage.run]
|
|
134
|
+
source = ["src"]
|
|
135
|
+
branch = true
|
|
136
|
+
|
|
137
|
+
[tool.coverage.report]
|
|
138
|
+
exclude_lines = [
|
|
139
|
+
"pragma: no cover",
|
|
140
|
+
"if TYPE_CHECKING:",
|
|
141
|
+
"raise NotImplementedError",
|
|
142
|
+
'^\s*\.\.\.$',
|
|
143
|
+
]
|