fastapi-cachex 0.3.5__tar.gz → 0.3.7__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.
- fastapi_cachex-0.3.7/PKG-INFO +135 -0
- fastapi_cachex-0.3.7/README.md +97 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/base.py +80 -3
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/memcached.py +63 -10
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/memory.py +35 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/redis.py +105 -17
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/cache.py +127 -25
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/dependencies.py +13 -7
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/manager.py +62 -14
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/proxy.py +28 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/routes.py +35 -17
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/session/__init__.py +4 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/session/config.py +72 -3
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/session/dependencies.py +46 -5
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/session/manager.py +30 -12
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/session/middleware.py +56 -15
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/session/token_serializers.py +18 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/state/manager.py +55 -16
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/pyproject.toml +8 -2
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/pyproject.toml.orig +9 -2
- fastapi_cachex-0.3.5/PKG-INFO +0 -552
- fastapi_cachex-0.3.5/README.md +0 -515
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/__init__.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/__init__.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/codec.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/config.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/directives.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/exceptions.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/manager_proxy.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/py.typed +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/session/exceptions.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/session/models.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/session/proxy.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/session/security.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/state/__init__.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/state/dependencies.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/state/exceptions.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/state/models.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/state/proxy.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/types.py +0 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-cachex
|
|
3
|
+
Version: 0.3.7
|
|
4
|
+
Summary: A caching library for FastAPI with support for Cache-Control, ETag, and multiple backends.
|
|
5
|
+
Keywords: fastapi,cache,etag,cache-control,redis,memcached,in-memory
|
|
6
|
+
Author: allen0099
|
|
7
|
+
Author-email: allen0099 <s96016641@gmail.com>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Framework :: FastAPI
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
22
|
+
Requires-Dist: fastapi
|
|
23
|
+
Requires-Dist: itsdangerous
|
|
24
|
+
Requires-Dist: pydantic
|
|
25
|
+
Requires-Dist: pyjwt>=2.9.0 ; extra == 'jwt'
|
|
26
|
+
Requires-Dist: pymemcache ; extra == 'memcache'
|
|
27
|
+
Requires-Dist: redis[hiredis]>=5.3.0 ; extra == 'redis'
|
|
28
|
+
Requires-Dist: orjson ; extra == 'redis'
|
|
29
|
+
Requires-Python: >=3.10
|
|
30
|
+
Project-URL: Homepage, https://github.com/allen0099/FastAPI-CacheX
|
|
31
|
+
Project-URL: Repository, https://github.com/allen0099/FastAPI-CacheX.git
|
|
32
|
+
Project-URL: Issues, https://github.com/allen0099/FastAPI-CacheX/issues
|
|
33
|
+
Project-URL: Documentation, https://fastapi-cachex.readthedocs.io/
|
|
34
|
+
Provides-Extra: jwt
|
|
35
|
+
Provides-Extra: memcache
|
|
36
|
+
Provides-Extra: redis
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# FastAPI-Cache X
|
|
40
|
+
|
|
41
|
+
[](https://github.com/astral-sh/uv)
|
|
42
|
+
[](https://github.com/astral-sh/ruff)
|
|
43
|
+
[](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml)
|
|
44
|
+
[](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/coverage.yml)
|
|
45
|
+
|
|
46
|
+
[](https://pepy.tech/project/fastapi-cachex)
|
|
47
|
+
[](https://pepy.tech/project/fastapi-cachex)
|
|
48
|
+
[](https://pepy.tech/project/fastapi-cachex)
|
|
49
|
+
|
|
50
|
+
[](https://pypi.org/project/fastapi-cachex)
|
|
51
|
+
[](https://pypi.org/project/fastapi-cachex/)
|
|
52
|
+
|
|
53
|
+
[English](https://github.com/allen0099/FastAPI-CacheX/blob/master/README.md) | [繁體中文](https://github.com/allen0099/FastAPI-CacheX/blob/master/i18n/zh-TW/docs/index.md)
|
|
54
|
+
|
|
55
|
+
A high-performance caching extension for FastAPI: a server-side response cache with `Cache-Control` and `ETag` support, application-level caching, and optional session management.
|
|
56
|
+
|
|
57
|
+
**Documentation:** <https://fastapi-cachex.readthedocs.io/en/latest/> — guides and the full API reference.
|
|
58
|
+
|
|
59
|
+
## Features
|
|
60
|
+
|
|
61
|
+
- **HTTP caching** — a `@cache` decorator for GET routes with `Cache-Control`,
|
|
62
|
+
`ETag` / `If-None-Match` (304) and per-route invalidation.
|
|
63
|
+
- **Application cache** — `CacheManager` for caching arbitrary JSON values in
|
|
64
|
+
your own code, with compute-on-miss `get_or_set()` and atomic store-if-absent `add()`.
|
|
65
|
+
- **Backends** — in-memory, Redis and Memcached, with atomic counters,
|
|
66
|
+
one-shot values and locks.
|
|
67
|
+
- **Sessions (optional)** — HMAC-signed or JWT session tokens over headers,
|
|
68
|
+
bearer tokens or cookies, with sliding expiration and IP/User-Agent binding.
|
|
69
|
+
- **OAuth state** — one-time state tokens for CSRF protection in OAuth/OIDC flows.
|
|
70
|
+
|
|
71
|
+
## Installation
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
uv add fastapi-cachex
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Everything in the core package works with the in-memory backend. The other
|
|
78
|
+
backends and the optional session transports ship as extras:
|
|
79
|
+
|
|
80
|
+
| Extra | Install | Pulls in | Needed for |
|
|
81
|
+
|-------|---------|----------|------------|
|
|
82
|
+
| `redis` | `uv add "fastapi-cachex[redis]"` | `redis[hiredis]`, `orjson` | `AsyncRedisCacheBackend` |
|
|
83
|
+
| `memcache` | `uv add "fastapi-cachex[memcache]"` | `pymemcache` | `MemcachedBackend` (note: `memcache`, not `memcached`) |
|
|
84
|
+
| `jwt` | `uv add "fastapi-cachex[jwt]"` | `PyJWT` | `SessionConfig(token_format="jwt")` |
|
|
85
|
+
|
|
86
|
+
Extras combine: `uv add "fastapi-cachex[redis,jwt]"`.
|
|
87
|
+
|
|
88
|
+
## Quick Start
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from fastapi import FastAPI
|
|
92
|
+
|
|
93
|
+
from fastapi_cachex import AppCache, BackendProxy, cache
|
|
94
|
+
from fastapi_cachex.backends import MemoryBackend
|
|
95
|
+
|
|
96
|
+
app = FastAPI()
|
|
97
|
+
BackendProxy.set(MemoryBackend()) # or AsyncRedisCacheBackend / MemcachedBackend
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.get("/items/{item_id}")
|
|
101
|
+
@cache(ttl=60) # served from the cache for 60 seconds, with ETag revalidation
|
|
102
|
+
async def read_item(item_id: int):
|
|
103
|
+
return {"item_id": item_id}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def build_report() -> dict:
|
|
107
|
+
return {"total": 42} # stands in for something slow
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.get("/report")
|
|
111
|
+
async def report(cache: AppCache):
|
|
112
|
+
# Cache any JSON value in your own code.
|
|
113
|
+
return await cache.get_or_set("report", build_report, ttl=300)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
> [!WARNING]
|
|
117
|
+
> The default cache key carries no user identity. Cache authenticated endpoints
|
|
118
|
+
> with `private=True` or a per-user key builder — see
|
|
119
|
+
> [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#authenticated-endpoints).
|
|
120
|
+
|
|
121
|
+
## Documentation
|
|
122
|
+
|
|
123
|
+
- [HTTP caching](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/) — the `@cache` decorator, Cache-Control directives, cache keys, invalidation and monitoring routes
|
|
124
|
+
- [Cache flow](https://fastapi-cachex.readthedocs.io/en/latest/CACHE_FLOW/) — what happens inside a cached request
|
|
125
|
+
- [Application cache](https://fastapi-cachex.readthedocs.io/en/latest/APP_CACHE/) — `CacheManager`
|
|
126
|
+
- [Backends](https://fastapi-cachex.readthedocs.io/en/latest/BACKENDS/) — choosing and configuring a backend, atomic primitives
|
|
127
|
+
- [Session management](https://fastapi-cachex.readthedocs.io/en/latest/SESSION/) and [JWT claims](https://fastapi-cachex.readthedocs.io/en/latest/JWT_CLAIMS/)
|
|
128
|
+
- [OAuth state](https://fastapi-cachex.readthedocs.io/en/latest/STATE/) — one-shot OAuth/CSRF state tokens
|
|
129
|
+
- [API reference](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/)
|
|
130
|
+
- [Development guide](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/) and [contributing](https://fastapi-cachex.readthedocs.io/en/latest/CONTRIBUTING/)
|
|
131
|
+
- [Changelog](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md) · [Known limitations and planned work](https://github.com/allen0099/FastAPI-CacheX/issues)
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
This project is licensed under the Apache License 2.0 - see the [LICENSE](https://github.com/allen0099/FastAPI-CacheX/blob/master/LICENSE) file for details.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# FastAPI-Cache X
|
|
2
|
+
|
|
3
|
+
[](https://github.com/astral-sh/uv)
|
|
4
|
+
[](https://github.com/astral-sh/ruff)
|
|
5
|
+
[](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml)
|
|
6
|
+
[](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/coverage.yml)
|
|
7
|
+
|
|
8
|
+
[](https://pepy.tech/project/fastapi-cachex)
|
|
9
|
+
[](https://pepy.tech/project/fastapi-cachex)
|
|
10
|
+
[](https://pepy.tech/project/fastapi-cachex)
|
|
11
|
+
|
|
12
|
+
[](https://pypi.org/project/fastapi-cachex)
|
|
13
|
+
[](https://pypi.org/project/fastapi-cachex/)
|
|
14
|
+
|
|
15
|
+
[English](https://github.com/allen0099/FastAPI-CacheX/blob/master/README.md) | [繁體中文](https://github.com/allen0099/FastAPI-CacheX/blob/master/i18n/zh-TW/docs/index.md)
|
|
16
|
+
|
|
17
|
+
A high-performance caching extension for FastAPI: a server-side response cache with `Cache-Control` and `ETag` support, application-level caching, and optional session management.
|
|
18
|
+
|
|
19
|
+
**Documentation:** <https://fastapi-cachex.readthedocs.io/en/latest/> — guides and the full API reference.
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
- **HTTP caching** — a `@cache` decorator for GET routes with `Cache-Control`,
|
|
24
|
+
`ETag` / `If-None-Match` (304) and per-route invalidation.
|
|
25
|
+
- **Application cache** — `CacheManager` for caching arbitrary JSON values in
|
|
26
|
+
your own code, with compute-on-miss `get_or_set()` and atomic store-if-absent `add()`.
|
|
27
|
+
- **Backends** — in-memory, Redis and Memcached, with atomic counters,
|
|
28
|
+
one-shot values and locks.
|
|
29
|
+
- **Sessions (optional)** — HMAC-signed or JWT session tokens over headers,
|
|
30
|
+
bearer tokens or cookies, with sliding expiration and IP/User-Agent binding.
|
|
31
|
+
- **OAuth state** — one-time state tokens for CSRF protection in OAuth/OIDC flows.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv add fastapi-cachex
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Everything in the core package works with the in-memory backend. The other
|
|
40
|
+
backends and the optional session transports ship as extras:
|
|
41
|
+
|
|
42
|
+
| Extra | Install | Pulls in | Needed for |
|
|
43
|
+
|-------|---------|----------|------------|
|
|
44
|
+
| `redis` | `uv add "fastapi-cachex[redis]"` | `redis[hiredis]`, `orjson` | `AsyncRedisCacheBackend` |
|
|
45
|
+
| `memcache` | `uv add "fastapi-cachex[memcache]"` | `pymemcache` | `MemcachedBackend` (note: `memcache`, not `memcached`) |
|
|
46
|
+
| `jwt` | `uv add "fastapi-cachex[jwt]"` | `PyJWT` | `SessionConfig(token_format="jwt")` |
|
|
47
|
+
|
|
48
|
+
Extras combine: `uv add "fastapi-cachex[redis,jwt]"`.
|
|
49
|
+
|
|
50
|
+
## Quick Start
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from fastapi import FastAPI
|
|
54
|
+
|
|
55
|
+
from fastapi_cachex import AppCache, BackendProxy, cache
|
|
56
|
+
from fastapi_cachex.backends import MemoryBackend
|
|
57
|
+
|
|
58
|
+
app = FastAPI()
|
|
59
|
+
BackendProxy.set(MemoryBackend()) # or AsyncRedisCacheBackend / MemcachedBackend
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.get("/items/{item_id}")
|
|
63
|
+
@cache(ttl=60) # served from the cache for 60 seconds, with ETag revalidation
|
|
64
|
+
async def read_item(item_id: int):
|
|
65
|
+
return {"item_id": item_id}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_report() -> dict:
|
|
69
|
+
return {"total": 42} # stands in for something slow
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.get("/report")
|
|
73
|
+
async def report(cache: AppCache):
|
|
74
|
+
# Cache any JSON value in your own code.
|
|
75
|
+
return await cache.get_or_set("report", build_report, ttl=300)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
> [!WARNING]
|
|
79
|
+
> The default cache key carries no user identity. Cache authenticated endpoints
|
|
80
|
+
> with `private=True` or a per-user key builder — see
|
|
81
|
+
> [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#authenticated-endpoints).
|
|
82
|
+
|
|
83
|
+
## Documentation
|
|
84
|
+
|
|
85
|
+
- [HTTP caching](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/) — the `@cache` decorator, Cache-Control directives, cache keys, invalidation and monitoring routes
|
|
86
|
+
- [Cache flow](https://fastapi-cachex.readthedocs.io/en/latest/CACHE_FLOW/) — what happens inside a cached request
|
|
87
|
+
- [Application cache](https://fastapi-cachex.readthedocs.io/en/latest/APP_CACHE/) — `CacheManager`
|
|
88
|
+
- [Backends](https://fastapi-cachex.readthedocs.io/en/latest/BACKENDS/) — choosing and configuring a backend, atomic primitives
|
|
89
|
+
- [Session management](https://fastapi-cachex.readthedocs.io/en/latest/SESSION/) and [JWT claims](https://fastapi-cachex.readthedocs.io/en/latest/JWT_CLAIMS/)
|
|
90
|
+
- [OAuth state](https://fastapi-cachex.readthedocs.io/en/latest/STATE/) — one-shot OAuth/CSRF state tokens
|
|
91
|
+
- [API reference](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/)
|
|
92
|
+
- [Development guide](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/) and [contributing](https://fastapi-cachex.readthedocs.io/en/latest/CONTRIBUTING/)
|
|
93
|
+
- [Changelog](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md) · [Known limitations and planned work](https://github.com/allen0099/FastAPI-CacheX/issues)
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
This project is licensed under the Apache License 2.0 - see the [LICENSE](https://github.com/allen0099/FastAPI-CacheX/blob/master/LICENSE) file for details.
|
|
@@ -35,6 +35,23 @@ def warn_if_path_shaped(pattern: str, cleared: int) -> None:
|
|
|
35
35
|
)
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
def validate_ttl(ttl: int | None) -> int | None:
|
|
39
|
+
"""Return ``ttl`` if it is ``None`` or a positive number of seconds.
|
|
40
|
+
|
|
41
|
+
Every backend reads ``0`` or a negative TTL differently (Memcached treats
|
|
42
|
+
``0`` as "never expires", Redis rejects it, the memory backend expires the
|
|
43
|
+
entry at once), so the library refuses them instead of letting the
|
|
44
|
+
meaning depend on the backend. ``None`` is the way to say "no expiry".
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
ValueError: If ``ttl`` is zero or negative
|
|
48
|
+
"""
|
|
49
|
+
if ttl is not None and ttl <= 0:
|
|
50
|
+
msg = f"ttl must be a positive number of seconds or None, got {ttl!r}"
|
|
51
|
+
raise ValueError(msg)
|
|
52
|
+
return ttl
|
|
53
|
+
|
|
54
|
+
|
|
38
55
|
class BaseCacheBackend(ABC):
|
|
39
56
|
"""Base class for all cache backends."""
|
|
40
57
|
|
|
@@ -44,7 +61,12 @@ class BaseCacheBackend(ABC):
|
|
|
44
61
|
|
|
45
62
|
@abstractmethod
|
|
46
63
|
async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None:
|
|
47
|
-
"""Store a response in the cache.
|
|
64
|
+
"""Store a response in the cache.
|
|
65
|
+
|
|
66
|
+
``ttl`` is ``None`` (never expires) or a positive number of seconds;
|
|
67
|
+
implementations should pass it through ``validate_ttl`` so zero and
|
|
68
|
+
negative values are rejected the same way on every backend.
|
|
69
|
+
"""
|
|
48
70
|
|
|
49
71
|
@abstractmethod
|
|
50
72
|
async def delete(self, key: str) -> None:
|
|
@@ -55,8 +77,9 @@ class BaseCacheBackend(ABC):
|
|
|
55
77
|
|
|
56
78
|
The base implementation deletes one key at a time and reports how
|
|
57
79
|
many were attempted, since ``delete`` does not say whether the key
|
|
58
|
-
existed. The
|
|
59
|
-
operation that counts what was actually removed
|
|
80
|
+
existed. The memory and Redis backends override it with a single
|
|
81
|
+
batched operation that counts what was actually removed; Memcached
|
|
82
|
+
keeps this per-key loop.
|
|
60
83
|
"""
|
|
61
84
|
count = 0
|
|
62
85
|
for key in keys:
|
|
@@ -83,6 +106,59 @@ class BaseCacheBackend(ABC):
|
|
|
83
106
|
await self.delete(key)
|
|
84
107
|
return value
|
|
85
108
|
|
|
109
|
+
async def set_if_absent(
|
|
110
|
+
self, key: str, value: CacheEntry, ttl: int | None = None
|
|
111
|
+
) -> bool:
|
|
112
|
+
"""Store ``value`` only when ``key`` does not exist yet.
|
|
113
|
+
|
|
114
|
+
The building block for locks and slots: of several concurrent callers
|
|
115
|
+
exactly one stores its value and gets ``True``, every other caller
|
|
116
|
+
gets ``False`` and the stored value is left untouched. An expired key
|
|
117
|
+
counts as absent. Pair it with ``delete_if_equals`` to release only
|
|
118
|
+
what you still hold.
|
|
119
|
+
|
|
120
|
+
The base implementation is a best-effort, NON-atomic get-then-set
|
|
121
|
+
fallback for third-party backends; the built-in backends override it
|
|
122
|
+
with an atomic implementation.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
key: Cache key to claim
|
|
126
|
+
value: Entry to store, typically carrying a unique owner token
|
|
127
|
+
ttl: Time to live in seconds (``None`` = never expires)
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Whether ``value`` was stored
|
|
131
|
+
"""
|
|
132
|
+
validate_ttl(ttl)
|
|
133
|
+
if await self.get(key) is not None:
|
|
134
|
+
return False
|
|
135
|
+
await self.set(key, value, ttl=ttl)
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
|
|
139
|
+
"""Remove ``key`` only while it still holds ``expected``.
|
|
140
|
+
|
|
141
|
+
Releasing a lock with a plain ``delete`` is unsafe: if the holder's
|
|
142
|
+
entry expired and someone else claimed the key in the meantime, the
|
|
143
|
+
delete removes the new holder's entry. Comparing against the value the
|
|
144
|
+
caller stored makes the release a no-op in that case.
|
|
145
|
+
|
|
146
|
+
The base implementation is a best-effort, NON-atomic get-compare-delete
|
|
147
|
+
fallback for third-party backends; the built-in backends override it
|
|
148
|
+
with an atomic implementation.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
key: Cache key to release
|
|
152
|
+
expected: The entry the caller stored (compared with ``==``)
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Whether the entry was removed
|
|
156
|
+
"""
|
|
157
|
+
if await self.get(key) != expected:
|
|
158
|
+
return False
|
|
159
|
+
await self.delete(key)
|
|
160
|
+
return True
|
|
161
|
+
|
|
86
162
|
async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> int:
|
|
87
163
|
"""Atomically add ``delta`` to the integer counter stored at ``key``.
|
|
88
164
|
|
|
@@ -109,6 +185,7 @@ class BaseCacheBackend(ABC):
|
|
|
109
185
|
Raises:
|
|
110
186
|
CacheXError: If ``key`` holds a cached response instead of a counter
|
|
111
187
|
"""
|
|
188
|
+
validate_ttl(ttl)
|
|
112
189
|
current = await self.get(key)
|
|
113
190
|
value = delta if current is None else counter_value(current) + delta
|
|
114
191
|
await self.set(key, counter_entry(value), ttl=ttl)
|
|
@@ -12,6 +12,7 @@ from fastapi_cachex.exceptions import CacheXError
|
|
|
12
12
|
from fastapi_cachex.types import CacheEntry
|
|
13
13
|
|
|
14
14
|
from .base import BaseCacheBackend
|
|
15
|
+
from .base import validate_ttl
|
|
15
16
|
|
|
16
17
|
logger = logging.getLogger(__name__)
|
|
17
18
|
|
|
@@ -141,6 +142,7 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
141
142
|
value: CacheEntry instance to store
|
|
142
143
|
ttl: Time to live in seconds
|
|
143
144
|
"""
|
|
145
|
+
validate_ttl(ttl)
|
|
144
146
|
await asyncio.to_thread(
|
|
145
147
|
self.client.set, self._make_key(key), encode_entry(value), _expiry(ttl)
|
|
146
148
|
)
|
|
@@ -167,6 +169,52 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
167
169
|
logger.debug("Memcached GET_AND_DELETE HIT; key=%s", key)
|
|
168
170
|
return decode_entry(raw)
|
|
169
171
|
|
|
172
|
+
async def set_if_absent(
|
|
173
|
+
self, key: str, value: CacheEntry, ttl: int | None = None
|
|
174
|
+
) -> bool:
|
|
175
|
+
"""Atomically store ``value`` unless ``key`` exists (see base class).
|
|
176
|
+
|
|
177
|
+
Memcached's ``ADD`` is exactly this operation.
|
|
178
|
+
"""
|
|
179
|
+
validate_ttl(ttl)
|
|
180
|
+
stored = await asyncio.to_thread(
|
|
181
|
+
self.client.add,
|
|
182
|
+
self._make_key(key),
|
|
183
|
+
encode_entry(value),
|
|
184
|
+
_expiry(ttl),
|
|
185
|
+
noreply=False,
|
|
186
|
+
)
|
|
187
|
+
logger.debug(
|
|
188
|
+
"Memcached SET_IF_ABSENT %s; key=%s ttl=%s",
|
|
189
|
+
"STORED" if stored else "EXISTS",
|
|
190
|
+
key,
|
|
191
|
+
ttl,
|
|
192
|
+
)
|
|
193
|
+
return bool(stored)
|
|
194
|
+
|
|
195
|
+
async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
|
|
196
|
+
"""Atomically remove ``key`` while it holds ``expected`` (see base class).
|
|
197
|
+
|
|
198
|
+
The classic protocol's DELETE takes no CAS token, so the release is a
|
|
199
|
+
CAS write with a negative exptime, which Memcached treats as "expired
|
|
200
|
+
immediately": it succeeds only if nothing wrote the key since ``GETS``
|
|
201
|
+
read the value that was compared.
|
|
202
|
+
"""
|
|
203
|
+
prefixed_key = self._make_key(key)
|
|
204
|
+
raw, cas_token = await asyncio.to_thread(self.client.gets, prefixed_key)
|
|
205
|
+
if raw is None or decode_entry(raw) != expected:
|
|
206
|
+
logger.debug("Memcached DELETE_IF_EQUALS MISMATCH; key=%s", key)
|
|
207
|
+
return False
|
|
208
|
+
deleted = await asyncio.to_thread(
|
|
209
|
+
self.client.cas, prefixed_key, b"", cas_token, -1, noreply=False
|
|
210
|
+
)
|
|
211
|
+
logger.debug(
|
|
212
|
+
"Memcached DELETE_IF_EQUALS %s; key=%s",
|
|
213
|
+
"HIT" if deleted else "LOST RACE",
|
|
214
|
+
key,
|
|
215
|
+
)
|
|
216
|
+
return bool(deleted)
|
|
217
|
+
|
|
170
218
|
def _add_delta(self, prefixed_key: str, delta: int) -> int | None:
|
|
171
219
|
"""Apply ``delta`` with INCR/DECR; ``None`` when the key does not exist."""
|
|
172
220
|
if delta < 0:
|
|
@@ -181,6 +229,7 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
181
229
|
Memcached counters are unsigned, so a negative ``delta`` uses DECR,
|
|
182
230
|
which stops at 0 instead of going negative.
|
|
183
231
|
"""
|
|
232
|
+
validate_ttl(ttl)
|
|
184
233
|
from pymemcache.exceptions import MemcacheClientError
|
|
185
234
|
|
|
186
235
|
prefixed_key = self._make_key(key)
|
|
@@ -215,13 +264,16 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
215
264
|
async def clear(self) -> None:
|
|
216
265
|
"""Clear all values from cache.
|
|
217
266
|
|
|
218
|
-
Note: Memcached's flush_all affects the entire server
|
|
219
|
-
|
|
267
|
+
Note: Memcached's flush_all affects the entire server, including
|
|
268
|
+
other applications' keys. Memcached cannot enumerate keys, so there
|
|
269
|
+
is no way to clear only this namespace; delete keys you know by name
|
|
270
|
+
with ``delete()``/``delete_many()`` instead.
|
|
220
271
|
"""
|
|
221
272
|
warnings.warn(
|
|
222
273
|
"Memcached.clear() flushes ALL cached data from the server, "
|
|
223
|
-
"affecting other applications.
|
|
224
|
-
"
|
|
274
|
+
"affecting other applications. Memcached cannot enumerate keys, so "
|
|
275
|
+
"this namespace cannot be cleared on its own; delete known keys "
|
|
276
|
+
"with delete() or delete_many() instead.",
|
|
225
277
|
RuntimeWarning,
|
|
226
278
|
stacklevel=2,
|
|
227
279
|
)
|
|
@@ -231,14 +283,15 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
231
283
|
async def clear_path(self, path: str, include_params: bool = False) -> int:
|
|
232
284
|
"""Clear cached responses for a specific path.
|
|
233
285
|
|
|
234
|
-
Note: Memcached does not support pattern-based queries
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
286
|
+
Note: Memcached does not support pattern-based queries, so this
|
|
287
|
+
only deletes the key that is exactly ``path``. HTTP route keys
|
|
288
|
+
(``method|||host|||path|||query``) are not matched. For path-based
|
|
289
|
+
clearing, use the Redis or memory backend.
|
|
238
290
|
|
|
239
291
|
Args:
|
|
240
|
-
path: The
|
|
241
|
-
include_params:
|
|
292
|
+
path: The exact key to delete
|
|
293
|
+
include_params: Unsupported; emits a ``RuntimeWarning`` and is
|
|
294
|
+
otherwise ignored
|
|
242
295
|
|
|
243
296
|
Returns:
|
|
244
297
|
Number of cache entries cleared (0 or 1 for exact match only)
|
|
@@ -14,6 +14,7 @@ from fastapi_cachex.types import counter_entry
|
|
|
14
14
|
from fastapi_cachex.types import counter_value
|
|
15
15
|
|
|
16
16
|
from .base import BaseCacheBackend
|
|
17
|
+
from .base import validate_ttl
|
|
17
18
|
from .base import warn_if_path_shaped
|
|
18
19
|
|
|
19
20
|
logger = logging.getLogger(__name__)
|
|
@@ -123,6 +124,7 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
123
124
|
value: Content to cache
|
|
124
125
|
ttl: Time to live in seconds (None = never expires)
|
|
125
126
|
"""
|
|
127
|
+
validate_ttl(ttl)
|
|
126
128
|
self._ensure_cleanup_started()
|
|
127
129
|
|
|
128
130
|
async with self.lock:
|
|
@@ -157,12 +159,45 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
157
159
|
logger.debug("Memory cache GET_AND_DELETE HIT; key=%s", key)
|
|
158
160
|
return item.value
|
|
159
161
|
|
|
162
|
+
async def set_if_absent(
|
|
163
|
+
self, key: str, value: CacheEntry, ttl: int | None = None
|
|
164
|
+
) -> bool:
|
|
165
|
+
"""Atomically store ``value`` unless ``key`` exists (see base class)."""
|
|
166
|
+
validate_ttl(ttl)
|
|
167
|
+
self._ensure_cleanup_started()
|
|
168
|
+
|
|
169
|
+
async with self.lock:
|
|
170
|
+
now = time.time()
|
|
171
|
+
item = self.cache.get(key)
|
|
172
|
+
if item is not None and _is_live(item, now):
|
|
173
|
+
logger.debug("Memory cache SET_IF_ABSENT EXISTS; key=%s", key)
|
|
174
|
+
return False
|
|
175
|
+
expiry = now + ttl if ttl is not None else None
|
|
176
|
+
self.cache[key] = CacheItem(value=value, expiry=expiry)
|
|
177
|
+
logger.debug("Memory cache SET_IF_ABSENT STORED; key=%s ttl=%s", key, ttl)
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
|
|
181
|
+
"""Atomically remove ``key`` while it holds ``expected`` (see base class)."""
|
|
182
|
+
async with self.lock:
|
|
183
|
+
item = self.cache.get(key)
|
|
184
|
+
if item is None or not _is_live(item, time.time()):
|
|
185
|
+
logger.debug("Memory cache DELETE_IF_EQUALS MISS; key=%s", key)
|
|
186
|
+
return False
|
|
187
|
+
if item.value != expected:
|
|
188
|
+
logger.debug("Memory cache DELETE_IF_EQUALS MISMATCH; key=%s", key)
|
|
189
|
+
return False
|
|
190
|
+
del self.cache[key]
|
|
191
|
+
logger.debug("Memory cache DELETE_IF_EQUALS HIT; key=%s", key)
|
|
192
|
+
return True
|
|
193
|
+
|
|
160
194
|
async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> int:
|
|
161
195
|
"""Atomically add ``delta`` to the counter at ``key`` (see base class).
|
|
162
196
|
|
|
163
197
|
The read-modify-write happens under the backend lock, so concurrent
|
|
164
198
|
callers on the same event loop never lose an increment.
|
|
165
199
|
"""
|
|
200
|
+
validate_ttl(ttl)
|
|
166
201
|
self._ensure_cleanup_started()
|
|
167
202
|
|
|
168
203
|
async with self.lock:
|