fastapi-cachex 0.3.4__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.4 → fastapi_cachex-0.3.6}/fastapi_cachex/__init__.py +19 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/base.py +92 -2
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/codec.py +12 -2
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/memcached.py +94 -6
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/memory.py +47 -12
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/redis.py +50 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/cache.py +232 -23
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/dependencies.py +21 -3
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/proxy.py +30 -2
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/session/config.py +49 -1
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/session/manager.py +1 -1
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/session/middleware.py +48 -46
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/session/security.py +9 -2
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/session/token_serializers.py +22 -4
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/state/manager.py +2 -2
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/types.py +12 -1
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/pyproject.toml +14 -4
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/pyproject.toml.orig +12 -4
- fastapi_cachex-0.3.4/PKG-INFO +0 -351
- fastapi_cachex-0.3.4/README.md +0 -313
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/__init__.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/config.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/directives.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/exceptions.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/manager.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/manager_proxy.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/py.typed +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/routes.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/session/__init__.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/session/dependencies.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/session/exceptions.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/session/models.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/session/proxy.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/state/__init__.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/state/dependencies.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/state/exceptions.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/state/models.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/state/proxy.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.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""FastAPI-CacheX: A powerful and flexible caching extension for FastAPI."""
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
+
from importlib.metadata import PackageNotFoundError
|
|
5
|
+
from importlib.metadata import version
|
|
4
6
|
|
|
5
7
|
from .cache import cache as cache
|
|
6
8
|
from .cache import default_key_builder as default_key_builder
|
|
@@ -41,6 +43,22 @@ from .state import StateManagerProxy as StateManagerProxy
|
|
|
41
43
|
from .state import get_state_manager as get_state_manager
|
|
42
44
|
from .types import CacheKeyBuilder as CacheKeyBuilder
|
|
43
45
|
|
|
46
|
+
|
|
47
|
+
def _read_version() -> str:
|
|
48
|
+
"""Return the installed distribution's version.
|
|
49
|
+
|
|
50
|
+
Importing from a source tree that was never installed leaves no metadata to
|
|
51
|
+
read; reporting a development version there is part of the contract, so this
|
|
52
|
+
lives in a function the tests can drive rather than behind a coverage pragma.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
return version("fastapi-cachex")
|
|
56
|
+
except PackageNotFoundError:
|
|
57
|
+
return "0.0.0.dev0"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__version__ = _read_version()
|
|
61
|
+
|
|
44
62
|
_package_logger = logging.getLogger("fastapi_cachex")
|
|
45
63
|
_package_logger.addHandler(
|
|
46
64
|
logging.NullHandler()
|
|
@@ -74,6 +92,7 @@ __all__ = [
|
|
|
74
92
|
"StateManager",
|
|
75
93
|
"StateManagerDep",
|
|
76
94
|
"StateManagerProxy",
|
|
95
|
+
"__version__",
|
|
77
96
|
"add_routes",
|
|
78
97
|
"cache",
|
|
79
98
|
"default_key_builder",
|
|
@@ -1,15 +1,40 @@
|
|
|
1
1
|
"""Base cache backend interface and abstract implementation."""
|
|
2
2
|
|
|
3
|
+
import warnings
|
|
3
4
|
from abc import ABC
|
|
4
5
|
from abc import abstractmethod
|
|
5
6
|
from collections.abc import Iterable
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
9
|
+
from fastapi_cachex.types import CACHE_KEY_SEPARATOR
|
|
8
10
|
from fastapi_cachex.types import CacheEntry
|
|
9
11
|
from fastapi_cachex.types import counter_entry
|
|
10
12
|
from fastapi_cachex.types import counter_value
|
|
11
13
|
|
|
12
14
|
|
|
15
|
+
def warn_if_path_shaped(pattern: str, cleared: int) -> None:
|
|
16
|
+
"""Warn when a ``clear_pattern`` that cleared nothing was written as a path.
|
|
17
|
+
|
|
18
|
+
Matching happens against the whole key, so a pattern like ``/users/*``
|
|
19
|
+
cannot match an HTTP cache entry and the call quietly reports zero cleared
|
|
20
|
+
— indistinguishable from a cache that was already empty, which is exactly
|
|
21
|
+
the outcome the caller was trying to avoid. Only the combination of "looks
|
|
22
|
+
like a bare path" and "matched nothing" warns, so keys that really are
|
|
23
|
+
paths (stored directly through ``set``) stay silent when they work.
|
|
24
|
+
"""
|
|
25
|
+
if cleared == 0 and pattern.startswith("/") and CACHE_KEY_SEPARATOR not in pattern:
|
|
26
|
+
warnings.warn(
|
|
27
|
+
f"clear_pattern({pattern!r}) cleared nothing. Patterns match whole "
|
|
28
|
+
f"cache keys, which look like 'method{CACHE_KEY_SEPARATOR}host"
|
|
29
|
+
f"{CACHE_KEY_SEPARATOR}path{CACHE_KEY_SEPARATOR}query', so a bare "
|
|
30
|
+
"path matches no HTTP cache entry. Use clear_path(path, "
|
|
31
|
+
"include_params=True) to clear by path, or write the whole key out "
|
|
32
|
+
f"as 'GET{CACHE_KEY_SEPARATOR}*{CACHE_KEY_SEPARATOR}{pattern}'.",
|
|
33
|
+
RuntimeWarning,
|
|
34
|
+
stacklevel=3,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
13
38
|
class BaseCacheBackend(ABC):
|
|
14
39
|
"""Base class for all cache backends."""
|
|
15
40
|
|
|
@@ -58,6 +83,58 @@ class BaseCacheBackend(ABC):
|
|
|
58
83
|
await self.delete(key)
|
|
59
84
|
return value
|
|
60
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
|
+
|
|
61
138
|
async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> int:
|
|
62
139
|
"""Atomically add ``delta`` to the integer counter stored at ``key``.
|
|
63
140
|
|
|
@@ -107,10 +184,23 @@ class BaseCacheBackend(ABC):
|
|
|
107
184
|
|
|
108
185
|
@abstractmethod
|
|
109
186
|
async def clear_pattern(self, pattern: str) -> int:
|
|
110
|
-
"""Clear cached
|
|
187
|
+
"""Clear cached entries whose key matches a glob pattern.
|
|
188
|
+
|
|
189
|
+
The pattern is matched against the whole logical key — the key as the
|
|
190
|
+
caller sees it, without whatever prefix the backend adds internally.
|
|
191
|
+
HTTP cache keys are ``method|||host|||path|||query``, so matching a
|
|
192
|
+
path means writing the other components out::
|
|
193
|
+
|
|
194
|
+
await backend.clear_pattern("GET|||*|||/users/*")
|
|
195
|
+
await backend.clear_pattern("cache:user:*") # a CacheManager key
|
|
196
|
+
|
|
197
|
+
To clear by path, prefer ``clear_path(path, include_params=...)``: it
|
|
198
|
+
is built for exactly that and needs no knowledge of the key layout.
|
|
199
|
+
Implementations report a path written here through
|
|
200
|
+
``warn_if_path_shaped`` rather than silently clearing nothing.
|
|
111
201
|
|
|
112
202
|
Args:
|
|
113
|
-
pattern: A glob pattern to match cache keys against
|
|
203
|
+
pattern: A glob pattern to match whole cache keys against
|
|
114
204
|
|
|
115
205
|
Returns:
|
|
116
206
|
Number of cache entries cleared
|
|
@@ -4,14 +4,17 @@ Both backends store a ``CacheEntry`` as a JSON document; ``orjson`` is used when
|
|
|
4
4
|
it is installed and the standard library ``json`` module otherwise.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
from fastapi_cachex.types import DEFAULT_STATUS_CODE
|
|
7
8
|
from fastapi_cachex.types import CacheEntry
|
|
8
9
|
from fastapi_cachex.types import counter_entry
|
|
9
10
|
|
|
10
11
|
try:
|
|
11
12
|
import orjson as json
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
# Resolved once at import time; whichever branch this interpreter takes, the
|
|
15
|
+
# other one cannot be reached again in the same process.
|
|
16
|
+
except ImportError: # pragma: no cover - import-time, environment dependent
|
|
17
|
+
import json # type: ignore[no-redef]
|
|
15
18
|
|
|
16
19
|
# ``json.loads`` (either implementation) raises ``ValueError`` subclasses for bad
|
|
17
20
|
# JSON; ``KeyError``/``TypeError``/``AttributeError`` cover documents whose shape
|
|
@@ -30,6 +33,8 @@ def encode_entry(entry: CacheEntry) -> bytes:
|
|
|
30
33
|
"fingerprint": entry.fingerprint,
|
|
31
34
|
"content": entry.content.decode("latin-1"),
|
|
32
35
|
"media_type": entry.media_type,
|
|
36
|
+
"status_code": entry.status_code,
|
|
37
|
+
"headers": entry.headers,
|
|
33
38
|
},
|
|
34
39
|
)
|
|
35
40
|
# orjson returns bytes, stdlib json returns str
|
|
@@ -52,6 +57,9 @@ def decode_entry(raw: str | bytes | None) -> CacheEntry | None:
|
|
|
52
57
|
written by ``encode_entry`` (corrupt JSON, missing fields, non-string
|
|
53
58
|
content) yields ``None``, so callers can treat every malformed value as a
|
|
54
59
|
cache miss.
|
|
60
|
+
|
|
61
|
+
Documents written before entries carried a status code and headers simply
|
|
62
|
+
lack those keys and decode to a plain ``200`` with no extra headers.
|
|
55
63
|
"""
|
|
56
64
|
if raw is None:
|
|
57
65
|
return None
|
|
@@ -64,6 +72,8 @@ def decode_entry(raw: str | bytes | None) -> CacheEntry | None:
|
|
|
64
72
|
fingerprint=data["fingerprint"],
|
|
65
73
|
content=data["content"].encode("latin-1"),
|
|
66
74
|
media_type=data.get("media_type"),
|
|
75
|
+
status_code=data.get("status_code", DEFAULT_STATUS_CODE),
|
|
76
|
+
headers=data.get("headers"),
|
|
67
77
|
)
|
|
68
78
|
except _DECODE_ERRORS:
|
|
69
79
|
return None
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""Memcached cache backend implementation."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import hashlib
|
|
4
5
|
import logging
|
|
6
|
+
import time
|
|
5
7
|
import warnings
|
|
6
8
|
|
|
7
9
|
from fastapi_cachex.backends.codec import decode_entry
|
|
@@ -16,6 +18,30 @@ logger = logging.getLogger(__name__)
|
|
|
16
18
|
# Default Memcached key prefix for fastapi-cachex
|
|
17
19
|
DEFAULT_MEMCACHE_PREFIX = "fastapi_cachex:"
|
|
18
20
|
|
|
21
|
+
# Memcached accepts keys of at most 250 bytes, and pymemcache rejects any key
|
|
22
|
+
# containing whitespace, control characters or non-ASCII bytes. What is left is
|
|
23
|
+
# the printable ASCII range with the space removed.
|
|
24
|
+
_MAX_KEY_BYTES = 250
|
|
25
|
+
_LEGAL_KEY_BYTES = frozenset(range(0x21, 0x7F))
|
|
26
|
+
|
|
27
|
+
# An exptime above 30 days is read by Memcached as an absolute Unix timestamp,
|
|
28
|
+
# not as a duration, so a longer TTL has to be converted before it is sent.
|
|
29
|
+
_MAX_RELATIVE_TTL = 30 * 24 * 60 * 60
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _expiry(ttl: int | None) -> int:
|
|
33
|
+
"""Convert a TTL in seconds to the exptime Memcached expects.
|
|
34
|
+
|
|
35
|
+
Anything past the 30-day boundary is sent as an absolute timestamp;
|
|
36
|
+
passing it through as a duration would have Memcached read it as a moment
|
|
37
|
+
in 1970 and expire the entry immediately. ``None`` means no expiry.
|
|
38
|
+
"""
|
|
39
|
+
if ttl is None:
|
|
40
|
+
return 0
|
|
41
|
+
if ttl > _MAX_RELATIVE_TTL:
|
|
42
|
+
return int(time.time()) + ttl
|
|
43
|
+
return ttl
|
|
44
|
+
|
|
19
45
|
|
|
20
46
|
class MemcachedBackend(BaseCacheBackend):
|
|
21
47
|
"""Memcached backend implementation.
|
|
@@ -67,8 +93,26 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
67
93
|
self.key_prefix = key_prefix
|
|
68
94
|
|
|
69
95
|
def _make_key(self, key: str) -> str:
|
|
70
|
-
"""
|
|
71
|
-
|
|
96
|
+
"""Namespace a cache key, hashing it when Memcached would refuse it.
|
|
97
|
+
|
|
98
|
+
pymemcache raises ``MemcacheIllegalInputError`` for a key over 250
|
|
99
|
+
bytes or carrying whitespace, control characters or non-ASCII bytes,
|
|
100
|
+
and nothing catches it on the way out — so ordinary traffic could turn
|
|
101
|
+
into a 500. ASGI percent-decodes the path, so `/foo%20bar` alone builds
|
|
102
|
+
a key with a literal space in it, and a long query string easily runs
|
|
103
|
+
past 250 bytes.
|
|
104
|
+
|
|
105
|
+
A key Memcached would accept is returned byte-for-byte, which keeps
|
|
106
|
+
entries written by earlier versions readable; only the rest collapse to
|
|
107
|
+
a SHA-256 digest of the whole namespaced key.
|
|
108
|
+
"""
|
|
109
|
+
prefixed = f"{self.key_prefix}{key}"
|
|
110
|
+
encoded = prefixed.encode("utf-8")
|
|
111
|
+
if len(encoded) <= _MAX_KEY_BYTES and _LEGAL_KEY_BYTES.issuperset(encoded):
|
|
112
|
+
return prefixed
|
|
113
|
+
digest = hashlib.sha256(encoded).hexdigest()
|
|
114
|
+
logger.debug("Memcached key hashed; key=%s digest=%s", key, digest)
|
|
115
|
+
return f"{self.key_prefix}{digest}"
|
|
72
116
|
|
|
73
117
|
async def get(self, key: str) -> CacheEntry | None:
|
|
74
118
|
"""Get value from cache.
|
|
@@ -97,9 +141,8 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
97
141
|
value: CacheEntry instance to store
|
|
98
142
|
ttl: Time to live in seconds
|
|
99
143
|
"""
|
|
100
|
-
expire = ttl if ttl is not None else 0
|
|
101
144
|
await asyncio.to_thread(
|
|
102
|
-
self.client.set, self._make_key(key), encode_entry(value),
|
|
145
|
+
self.client.set, self._make_key(key), encode_entry(value), _expiry(ttl)
|
|
103
146
|
)
|
|
104
147
|
logger.debug("Memcached SET; key=%s ttl=%s", key, ttl)
|
|
105
148
|
|
|
@@ -124,6 +167,51 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
124
167
|
logger.debug("Memcached GET_AND_DELETE HIT; key=%s", key)
|
|
125
168
|
return decode_entry(raw)
|
|
126
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
|
+
|
|
127
215
|
def _add_delta(self, prefixed_key: str, delta: int) -> int | None:
|
|
128
216
|
"""Apply ``delta`` with INCR/DECR; ``None`` when the key does not exist."""
|
|
129
217
|
if delta < 0:
|
|
@@ -147,13 +235,13 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
147
235
|
# No counter yet: ADD is atomic and a no-op when a concurrent
|
|
148
236
|
# call created it first, so the retry always finds a counter.
|
|
149
237
|
await asyncio.to_thread(
|
|
150
|
-
self.client.add, prefixed_key, b"0", ttl
|
|
238
|
+
self.client.add, prefixed_key, b"0", _expiry(ttl), noreply=False
|
|
151
239
|
)
|
|
152
240
|
value = await asyncio.to_thread(self._add_delta, prefixed_key, delta)
|
|
153
241
|
except MemcacheClientError as e:
|
|
154
242
|
msg = "Cache key holds a value that is not a counter"
|
|
155
243
|
raise CacheXError(msg) from e
|
|
156
|
-
if value is None:
|
|
244
|
+
if value is None:
|
|
157
245
|
msg = "Counter vanished between ADD and INCR"
|
|
158
246
|
raise CacheXError(msg)
|
|
159
247
|
logger.debug("Memcached INCREMENT; key=%s value=%s ttl=%s", key, value, ttl)
|