fastapi-cachex 0.3.5__tar.gz → 0.3.6__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.6/PKG-INFO +135 -0
- fastapi_cachex-0.3.6/README.md +97 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/base.py +52 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/memcached.py +45 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/memory.py +31 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/redis.py +48 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/cache.py +2 -7
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/dependencies.py +13 -7
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/proxy.py +28 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/session/config.py +6 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/session/token_serializers.py +18 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/state/manager.py +2 -2
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/pyproject.toml +7 -1
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/pyproject.toml.orig +8 -1
- 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.6}/fastapi_cachex/__init__.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/__init__.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/codec.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/config.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/directives.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/exceptions.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/manager.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/manager_proxy.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/py.typed +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/routes.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/session/__init__.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/session/dependencies.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/session/exceptions.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/session/manager.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/session/middleware.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/session/models.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/session/proxy.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/session/security.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/state/__init__.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/state/dependencies.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/state/exceptions.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/state/models.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/state/proxy.py +0 -0
- {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/types.py +0 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-cachex
|
|
3
|
+
Version: 0.3.6
|
|
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/docs/README.zh-TW.md)
|
|
54
|
+
|
|
55
|
+
A high-performance caching extension for FastAPI, providing comprehensive HTTP caching support 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()`.
|
|
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/docs/README.zh-TW.md)
|
|
16
|
+
|
|
17
|
+
A high-performance caching extension for FastAPI, providing comprehensive HTTP caching support 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()`.
|
|
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.
|
|
@@ -83,6 +83,58 @@ class BaseCacheBackend(ABC):
|
|
|
83
83
|
await self.delete(key)
|
|
84
84
|
return value
|
|
85
85
|
|
|
86
|
+
async def set_if_absent(
|
|
87
|
+
self, key: str, value: CacheEntry, ttl: int | None = None
|
|
88
|
+
) -> bool:
|
|
89
|
+
"""Store ``value`` only when ``key`` does not exist yet.
|
|
90
|
+
|
|
91
|
+
The building block for locks and slots: of several concurrent callers
|
|
92
|
+
exactly one stores its value and gets ``True``, every other caller
|
|
93
|
+
gets ``False`` and the stored value is left untouched. An expired key
|
|
94
|
+
counts as absent. Pair it with ``delete_if_equals`` to release only
|
|
95
|
+
what you still hold.
|
|
96
|
+
|
|
97
|
+
The base implementation is a best-effort, NON-atomic get-then-set
|
|
98
|
+
fallback for third-party backends; the built-in backends override it
|
|
99
|
+
with an atomic implementation.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
key: Cache key to claim
|
|
103
|
+
value: Entry to store, typically carrying a unique owner token
|
|
104
|
+
ttl: Time to live in seconds (``None`` = never expires)
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Whether ``value`` was stored
|
|
108
|
+
"""
|
|
109
|
+
if await self.get(key) is not None:
|
|
110
|
+
return False
|
|
111
|
+
await self.set(key, value, ttl=ttl)
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
|
|
115
|
+
"""Remove ``key`` only while it still holds ``expected``.
|
|
116
|
+
|
|
117
|
+
Releasing a lock with a plain ``delete`` is unsafe: if the holder's
|
|
118
|
+
entry expired and someone else claimed the key in the meantime, the
|
|
119
|
+
delete removes the new holder's entry. Comparing against the value the
|
|
120
|
+
caller stored makes the release a no-op in that case.
|
|
121
|
+
|
|
122
|
+
The base implementation is a best-effort, NON-atomic get-compare-delete
|
|
123
|
+
fallback for third-party backends; the built-in backends override it
|
|
124
|
+
with an atomic implementation.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
key: Cache key to release
|
|
128
|
+
expected: The entry the caller stored (compared with ``==``)
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Whether the entry was removed
|
|
132
|
+
"""
|
|
133
|
+
if await self.get(key) != expected:
|
|
134
|
+
return False
|
|
135
|
+
await self.delete(key)
|
|
136
|
+
return True
|
|
137
|
+
|
|
86
138
|
async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> int:
|
|
87
139
|
"""Atomically add ``delta`` to the integer counter stored at ``key``.
|
|
88
140
|
|
|
@@ -167,6 +167,51 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
167
167
|
logger.debug("Memcached GET_AND_DELETE HIT; key=%s", key)
|
|
168
168
|
return decode_entry(raw)
|
|
169
169
|
|
|
170
|
+
async def set_if_absent(
|
|
171
|
+
self, key: str, value: CacheEntry, ttl: int | None = None
|
|
172
|
+
) -> bool:
|
|
173
|
+
"""Atomically store ``value`` unless ``key`` exists (see base class).
|
|
174
|
+
|
|
175
|
+
Memcached's ``ADD`` is exactly this operation.
|
|
176
|
+
"""
|
|
177
|
+
stored = await asyncio.to_thread(
|
|
178
|
+
self.client.add,
|
|
179
|
+
self._make_key(key),
|
|
180
|
+
encode_entry(value),
|
|
181
|
+
_expiry(ttl),
|
|
182
|
+
noreply=False,
|
|
183
|
+
)
|
|
184
|
+
logger.debug(
|
|
185
|
+
"Memcached SET_IF_ABSENT %s; key=%s ttl=%s",
|
|
186
|
+
"STORED" if stored else "EXISTS",
|
|
187
|
+
key,
|
|
188
|
+
ttl,
|
|
189
|
+
)
|
|
190
|
+
return bool(stored)
|
|
191
|
+
|
|
192
|
+
async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
|
|
193
|
+
"""Atomically remove ``key`` while it holds ``expected`` (see base class).
|
|
194
|
+
|
|
195
|
+
The classic protocol's DELETE takes no CAS token, so the release is a
|
|
196
|
+
CAS write with a negative exptime, which Memcached treats as "expired
|
|
197
|
+
immediately": it succeeds only if nothing wrote the key since ``GETS``
|
|
198
|
+
read the value that was compared.
|
|
199
|
+
"""
|
|
200
|
+
prefixed_key = self._make_key(key)
|
|
201
|
+
raw, cas_token = await asyncio.to_thread(self.client.gets, prefixed_key)
|
|
202
|
+
if raw is None or decode_entry(raw) != expected:
|
|
203
|
+
logger.debug("Memcached DELETE_IF_EQUALS MISMATCH; key=%s", key)
|
|
204
|
+
return False
|
|
205
|
+
deleted = await asyncio.to_thread(
|
|
206
|
+
self.client.cas, prefixed_key, b"", cas_token, -1, noreply=False
|
|
207
|
+
)
|
|
208
|
+
logger.debug(
|
|
209
|
+
"Memcached DELETE_IF_EQUALS %s; key=%s",
|
|
210
|
+
"HIT" if deleted else "LOST RACE",
|
|
211
|
+
key,
|
|
212
|
+
)
|
|
213
|
+
return bool(deleted)
|
|
214
|
+
|
|
170
215
|
def _add_delta(self, prefixed_key: str, delta: int) -> int | None:
|
|
171
216
|
"""Apply ``delta`` with INCR/DECR; ``None`` when the key does not exist."""
|
|
172
217
|
if delta < 0:
|
|
@@ -157,6 +157,37 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
157
157
|
logger.debug("Memory cache GET_AND_DELETE HIT; key=%s", key)
|
|
158
158
|
return item.value
|
|
159
159
|
|
|
160
|
+
async def set_if_absent(
|
|
161
|
+
self, key: str, value: CacheEntry, ttl: int | None = None
|
|
162
|
+
) -> bool:
|
|
163
|
+
"""Atomically store ``value`` unless ``key`` exists (see base class)."""
|
|
164
|
+
self._ensure_cleanup_started()
|
|
165
|
+
|
|
166
|
+
async with self.lock:
|
|
167
|
+
now = time.time()
|
|
168
|
+
item = self.cache.get(key)
|
|
169
|
+
if item is not None and _is_live(item, now):
|
|
170
|
+
logger.debug("Memory cache SET_IF_ABSENT EXISTS; key=%s", key)
|
|
171
|
+
return False
|
|
172
|
+
expiry = now + ttl if ttl is not None else None
|
|
173
|
+
self.cache[key] = CacheItem(value=value, expiry=expiry)
|
|
174
|
+
logger.debug("Memory cache SET_IF_ABSENT STORED; key=%s ttl=%s", key, ttl)
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
|
|
178
|
+
"""Atomically remove ``key`` while it holds ``expected`` (see base class)."""
|
|
179
|
+
async with self.lock:
|
|
180
|
+
item = self.cache.get(key)
|
|
181
|
+
if item is None or not _is_live(item, time.time()):
|
|
182
|
+
logger.debug("Memory cache DELETE_IF_EQUALS MISS; key=%s", key)
|
|
183
|
+
return False
|
|
184
|
+
if item.value != expected:
|
|
185
|
+
logger.debug("Memory cache DELETE_IF_EQUALS MISMATCH; key=%s", key)
|
|
186
|
+
return False
|
|
187
|
+
del self.cache[key]
|
|
188
|
+
logger.debug("Memory cache DELETE_IF_EQUALS HIT; key=%s", key)
|
|
189
|
+
return True
|
|
190
|
+
|
|
160
191
|
async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> int:
|
|
161
192
|
"""Atomically add ``delta`` to the counter at ``key`` (see base class).
|
|
162
193
|
|
|
@@ -36,6 +36,15 @@ end
|
|
|
36
36
|
return value
|
|
37
37
|
"""
|
|
38
38
|
|
|
39
|
+
# DEL that only fires while the key still holds the exact bytes the caller read,
|
|
40
|
+
# so a value replaced in the meantime survives. KEYS[1] = key, ARGV[1] = bytes.
|
|
41
|
+
_DELETE_IF_EQUALS_SCRIPT = """
|
|
42
|
+
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
43
|
+
return redis.call('DEL', KEYS[1])
|
|
44
|
+
end
|
|
45
|
+
return 0
|
|
46
|
+
"""
|
|
47
|
+
|
|
39
48
|
|
|
40
49
|
class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
41
50
|
"""Async Redis cache backend implementation.
|
|
@@ -108,6 +117,9 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
108
117
|
# Registered once so every call is an EVALSHA (redis-py reloads the
|
|
109
118
|
# script transparently if the server has flushed it).
|
|
110
119
|
self._increment_script = self.client.register_script(_INCREMENT_SCRIPT)
|
|
120
|
+
self._delete_if_equals_script = self.client.register_script(
|
|
121
|
+
_DELETE_IF_EQUALS_SCRIPT
|
|
122
|
+
)
|
|
111
123
|
|
|
112
124
|
@staticmethod
|
|
113
125
|
def load_from_config(config: RedisConfig) -> "AsyncRedisCacheBackend":
|
|
@@ -189,6 +201,42 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
189
201
|
logger.debug("Redis GETDEL %s; key=%s", "HIT" if value else "MISS", key)
|
|
190
202
|
return value
|
|
191
203
|
|
|
204
|
+
async def set_if_absent(
|
|
205
|
+
self, key: str, value: CacheEntry, ttl: int | None = None
|
|
206
|
+
) -> bool:
|
|
207
|
+
"""Atomically store ``value`` unless ``key`` exists (see base class).
|
|
208
|
+
|
|
209
|
+
A single ``SET ... NX EX``.
|
|
210
|
+
"""
|
|
211
|
+
stored = await self.client.set(
|
|
212
|
+
self._make_key(key), encode_entry(value), ex=ttl, nx=True
|
|
213
|
+
)
|
|
214
|
+
logger.debug(
|
|
215
|
+
"Redis SET_IF_ABSENT %s; key=%s ttl=%s",
|
|
216
|
+
"STORED" if stored else "EXISTS",
|
|
217
|
+
key,
|
|
218
|
+
ttl,
|
|
219
|
+
)
|
|
220
|
+
return bool(stored)
|
|
221
|
+
|
|
222
|
+
async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
|
|
223
|
+
"""Atomically remove ``key`` while it holds ``expected`` (see base class).
|
|
224
|
+
|
|
225
|
+
The stored value is decoded and compared here, then a Lua script
|
|
226
|
+
deletes the key only if it still holds the bytes that were compared,
|
|
227
|
+
so a value written in between is never removed.
|
|
228
|
+
"""
|
|
229
|
+
prefixed_key = self._make_key(key)
|
|
230
|
+
raw = await self.client.get(prefixed_key)
|
|
231
|
+
if raw is None or decode_entry(raw) != expected:
|
|
232
|
+
logger.debug("Redis DELETE_IF_EQUALS MISMATCH; key=%s", key)
|
|
233
|
+
return False
|
|
234
|
+
deleted = await self._delete_if_equals_script(keys=[prefixed_key], args=[raw])
|
|
235
|
+
logger.debug(
|
|
236
|
+
"Redis DELETE_IF_EQUALS %s; key=%s", "HIT" if deleted else "LOST RACE", key
|
|
237
|
+
)
|
|
238
|
+
return bool(deleted)
|
|
239
|
+
|
|
192
240
|
async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> int:
|
|
193
241
|
"""Atomically add ``delta`` to the counter at ``key`` (see base class).
|
|
194
242
|
|
|
@@ -26,12 +26,12 @@ from starlette.status import HTTP_206_PARTIAL_CONTENT
|
|
|
26
26
|
from starlette.status import HTTP_300_MULTIPLE_CHOICES
|
|
27
27
|
from starlette.status import HTTP_304_NOT_MODIFIED
|
|
28
28
|
|
|
29
|
-
from .backends import MemoryBackend
|
|
30
29
|
from .directives import DirectiveType
|
|
31
30
|
from .exceptions import BackendNotFoundError
|
|
32
31
|
from .exceptions import CacheXError
|
|
33
32
|
from .exceptions import RequestNotFoundError
|
|
34
33
|
from .proxy import BackendProxy
|
|
34
|
+
from .proxy import get_backend_or_fallback
|
|
35
35
|
from .types import CACHE_KEY_SEPARATOR
|
|
36
36
|
from .types import CacheEntry
|
|
37
37
|
from .types import CacheKeyBuilder
|
|
@@ -470,12 +470,7 @@ def cache(
|
|
|
470
470
|
@wraps(func)
|
|
471
471
|
async def wrapper(*args: Any, **kwargs: Any) -> Response:
|
|
472
472
|
# Resolve backend on every request to support lifespan-configured backends
|
|
473
|
-
|
|
474
|
-
cache_backend = BackendProxy.get()
|
|
475
|
-
except BackendNotFoundError:
|
|
476
|
-
cache_backend = MemoryBackend()
|
|
477
|
-
BackendProxy.set(cache_backend)
|
|
478
|
-
logger.debug("No backend configured; using MemoryBackend fallback")
|
|
473
|
+
cache_backend = get_backend_or_fallback()
|
|
479
474
|
|
|
480
475
|
if found_request:
|
|
481
476
|
req: Request | None = kwargs.get(request_name)
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
"""FastAPI dependency injection utilities for cache control."""
|
|
2
2
|
|
|
3
|
+
import threading
|
|
3
4
|
from typing import Annotated
|
|
4
5
|
|
|
5
6
|
from fastapi import Depends
|
|
6
7
|
|
|
7
|
-
from .backends import MemoryBackend
|
|
8
8
|
from .backends.base import BaseCacheBackend
|
|
9
9
|
from .exceptions import BackendNotFoundError
|
|
10
10
|
from .manager import CacheManager
|
|
11
11
|
from .manager_proxy import CacheManagerProxy
|
|
12
12
|
from .proxy import BackendProxy
|
|
13
|
+
from .proxy import get_backend_or_fallback
|
|
14
|
+
|
|
15
|
+
_manager_lock = threading.Lock()
|
|
13
16
|
|
|
14
17
|
|
|
15
18
|
def get_cache_backend() -> BaseCacheBackend:
|
|
@@ -36,14 +39,17 @@ def get_app_cache() -> CacheManager:
|
|
|
36
39
|
try:
|
|
37
40
|
return CacheManagerProxy.get()
|
|
38
41
|
except BackendNotFoundError:
|
|
42
|
+
pass
|
|
43
|
+
# Checked again under the lock: FastAPI runs this sync dependency in a
|
|
44
|
+
# worker thread, so concurrent first requests would otherwise each build
|
|
45
|
+
# and register their own manager (and fallback backend).
|
|
46
|
+
with _manager_lock:
|
|
39
47
|
try:
|
|
40
|
-
|
|
48
|
+
return CacheManagerProxy.get()
|
|
41
49
|
except BackendNotFoundError:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
CacheManagerProxy.set(manager)
|
|
46
|
-
return manager
|
|
50
|
+
manager = CacheManager(backend=get_backend_or_fallback())
|
|
51
|
+
CacheManagerProxy.set(manager)
|
|
52
|
+
return manager
|
|
47
53
|
|
|
48
54
|
|
|
49
55
|
AppCache = Annotated[CacheManager, Depends(get_app_cache)]
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import threading
|
|
5
6
|
import warnings
|
|
6
7
|
from logging import getLogger
|
|
7
8
|
from typing import Generic
|
|
@@ -9,12 +10,17 @@ from typing import NoReturn
|
|
|
9
10
|
from typing import TypeVar
|
|
10
11
|
|
|
11
12
|
from .backends import BaseCacheBackend
|
|
13
|
+
from .backends import MemoryBackend
|
|
12
14
|
from .exceptions import BackendNotFoundError
|
|
13
15
|
|
|
14
16
|
ProxyInstance = TypeVar("ProxyInstance")
|
|
15
17
|
|
|
16
18
|
logger = getLogger(__name__)
|
|
17
19
|
|
|
20
|
+
# Serialises the lazy fallback below. `get_app_cache` is a sync dependency that
|
|
21
|
+
# FastAPI runs in a worker thread, so two first requests can reach it at once.
|
|
22
|
+
_fallback_lock = threading.Lock()
|
|
23
|
+
|
|
18
24
|
|
|
19
25
|
class ProxyMeta(type):
|
|
20
26
|
"""Metaclass for BackendProxy to prevent instantiation."""
|
|
@@ -94,3 +100,25 @@ class BackendProxy(ProxyBase[BaseCacheBackend]):
|
|
|
94
100
|
stacklevel=2,
|
|
95
101
|
)
|
|
96
102
|
BackendProxy.set(backend)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_backend_or_fallback() -> BaseCacheBackend:
|
|
106
|
+
"""Return the configured backend, registering a `MemoryBackend` if none is.
|
|
107
|
+
|
|
108
|
+
Used by `@cache` and the `AppCache` dependency. The check and the
|
|
109
|
+
registration happen under one lock, so concurrent first callers — including
|
|
110
|
+
ones on worker threads — all end up with the same fallback instead of each
|
|
111
|
+
installing its own and overwriting the others.
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
return BackendProxy.get()
|
|
115
|
+
except BackendNotFoundError:
|
|
116
|
+
pass
|
|
117
|
+
with _fallback_lock:
|
|
118
|
+
try:
|
|
119
|
+
return BackendProxy.get()
|
|
120
|
+
except BackendNotFoundError:
|
|
121
|
+
backend = MemoryBackend()
|
|
122
|
+
BackendProxy.set(backend)
|
|
123
|
+
logger.debug("No backend configured; using MemoryBackend fallback")
|
|
124
|
+
return backend
|
|
@@ -30,6 +30,12 @@ JWT_ALGORITHMS = frozenset(
|
|
|
30
30
|
}
|
|
31
31
|
)
|
|
32
32
|
|
|
33
|
+
# The algorithms the built-in `JWTTokenSerializer` can use: it signs and
|
|
34
|
+
# verifies with the single `secret_key` string. The asymmetric ones above need
|
|
35
|
+
# a private key to sign and a public key to verify, so they only work with a
|
|
36
|
+
# custom `token_serializer`.
|
|
37
|
+
JWT_HMAC_ALGORITHMS = frozenset({"HS256", "HS384", "HS512"})
|
|
38
|
+
|
|
33
39
|
|
|
34
40
|
class SessionConfig(BaseModel):
|
|
35
41
|
"""Session configuration settings."""
|
|
@@ -18,6 +18,7 @@ from typing import TYPE_CHECKING
|
|
|
18
18
|
from typing import Any
|
|
19
19
|
from typing import Protocol
|
|
20
20
|
|
|
21
|
+
from .config import JWT_HMAC_ALGORITHMS
|
|
21
22
|
from .models import SessionToken
|
|
22
23
|
|
|
23
24
|
if TYPE_CHECKING: # Import for typing only to avoid circular import concerns
|
|
@@ -109,7 +110,24 @@ class JWTTokenSerializer:
|
|
|
109
110
|
config: Session configuration instance.
|
|
110
111
|
jwt_module: Optional JWT-compatible module providing ``encode`` and
|
|
111
112
|
``decode``; defaults to importing ``jwt`` (PyJWT).
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
ValueError: If ``config.jwt_algorithm`` is asymmetric. This
|
|
116
|
+
serializer signs and verifies with the ``secret_key`` string,
|
|
117
|
+
which only the HMAC algorithms can use; an asymmetric
|
|
118
|
+
algorithm needs a custom ``token_serializer`` that holds the
|
|
119
|
+
key pair.
|
|
112
120
|
"""
|
|
121
|
+
if config.jwt_algorithm not in JWT_HMAC_ALGORITHMS:
|
|
122
|
+
supported = ", ".join(sorted(JWT_HMAC_ALGORITHMS))
|
|
123
|
+
msg = (
|
|
124
|
+
f"jwt_algorithm {config.jwt_algorithm!r} needs a private/public "
|
|
125
|
+
f"key pair, but the built-in JWT serializer signs with secret_key; "
|
|
126
|
+
f"use one of {supported}, or pass a custom token_serializer to "
|
|
127
|
+
f"SessionManager"
|
|
128
|
+
)
|
|
129
|
+
raise ValueError(msg)
|
|
130
|
+
|
|
113
131
|
if jwt_module is not None:
|
|
114
132
|
self.jwt_encoder = jwt_module
|
|
115
133
|
else:
|
|
@@ -118,8 +118,8 @@ class StateManager:
|
|
|
118
118
|
Returns:
|
|
119
119
|
The generated state string
|
|
120
120
|
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
Backend errors (for example a Redis connection error) propagate
|
|
122
|
+
unchanged; they are not wrapped in ``StateDataError``.
|
|
123
123
|
"""
|
|
124
124
|
# Generate a random state string (32 bytes = 256 bits of entropy)
|
|
125
125
|
state = secrets.token_urlsafe(32)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-cachex"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.6"
|
|
4
4
|
description = "A caching library for FastAPI with support for Cache-Control, ETag, and multiple backends."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -43,6 +43,7 @@ email = "s96016641@gmail.com"
|
|
|
43
43
|
Homepage = "https://github.com/allen0099/FastAPI-CacheX"
|
|
44
44
|
Repository = "https://github.com/allen0099/FastAPI-CacheX.git"
|
|
45
45
|
Issues = "https://github.com/allen0099/FastAPI-CacheX/issues"
|
|
46
|
+
Documentation = "https://fastapi-cachex.readthedocs.io/"
|
|
46
47
|
|
|
47
48
|
[project.optional-dependencies]
|
|
48
49
|
memcache = ["pymemcache"]
|
|
@@ -72,6 +73,11 @@ dev = [
|
|
|
72
73
|
"types-orjson>=3.6.2",
|
|
73
74
|
"types-redis>=4.6.0.20241004",
|
|
74
75
|
]
|
|
76
|
+
docs = [
|
|
77
|
+
"zensical>=0.0.65,<0.1",
|
|
78
|
+
"mkdocstrings-python>=2.0",
|
|
79
|
+
"markdown-callouts>=0.4",
|
|
80
|
+
]
|
|
75
81
|
|
|
76
82
|
[build-system]
|
|
77
83
|
requires = ["uv_build>=0.12,<0.13"]
|