fastapi-cachex 0.4.1__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/PKG-INFO +17 -15
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/README.md +14 -9
- fastapi_cachex-0.5.0/fastapi_cachex/__init__.py +75 -0
- fastapi_cachex-0.5.0/fastapi_cachex/_cache_control.py +150 -0
- fastapi_cachex-0.5.0/fastapi_cachex/_callables.py +17 -0
- fastapi_cachex-0.5.0/fastapi_cachex/_coalesce.py +70 -0
- fastapi_cachex-0.5.0/fastapi_cachex/_key_builders.py +175 -0
- fastapi_cachex-0.5.0/fastapi_cachex/_rendering.py +279 -0
- fastapi_cachex-0.5.0/fastapi_cachex/_stored_response.py +214 -0
- fastapi_cachex-0.5.0/fastapi_cachex/_vary.py +79 -0
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/fastapi_cachex/backends/base.py +73 -54
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/fastapi_cachex/backends/memcached.py +17 -10
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/fastapi_cachex/backends/memory.py +82 -20
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/fastapi_cachex/backends/redis.py +17 -10
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/fastapi_cachex/cache.py +245 -892
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/fastapi_cachex/cache_key.py +1 -1
- fastapi_cachex-0.5.0/fastapi_cachex/cached.py +261 -0
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/fastapi_cachex/exceptions.py +3 -3
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/fastapi_cachex/headers.py +1 -1
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/fastapi_cachex/lock.py +17 -9
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/fastapi_cachex/manager.py +49 -40
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/fastapi_cachex/types.py +2 -3
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/pyproject.toml +11 -20
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/pyproject.toml.orig +23 -29
- fastapi_cachex-0.4.1/fastapi_cachex/__init__.py +0 -149
- fastapi_cachex-0.4.1/fastapi_cachex/_deprecation.py +0 -68
- fastapi_cachex-0.4.1/fastapi_cachex/session/__init__.py +0 -46
- fastapi_cachex-0.4.1/fastapi_cachex/session/config.py +0 -349
- fastapi_cachex-0.4.1/fastapi_cachex/session/dependencies.py +0 -435
- fastapi_cachex-0.4.1/fastapi_cachex/session/exceptions.py +0 -31
- fastapi_cachex-0.4.1/fastapi_cachex/session/manager.py +0 -667
- fastapi_cachex-0.4.1/fastapi_cachex/session/middleware.py +0 -618
- fastapi_cachex-0.4.1/fastapi_cachex/session/models.py +0 -195
- fastapi_cachex-0.4.1/fastapi_cachex/session/proxy.py +0 -9
- fastapi_cachex-0.4.1/fastapi_cachex/session/security.py +0 -118
- fastapi_cachex-0.4.1/fastapi_cachex/session/token_serializers.py +0 -268
- fastapi_cachex-0.4.1/fastapi_cachex/state/__init__.py +0 -20
- fastapi_cachex-0.4.1/fastapi_cachex/state/dependencies.py +0 -27
- fastapi_cachex-0.4.1/fastapi_cachex/state/exceptions.py +0 -19
- fastapi_cachex-0.4.1/fastapi_cachex/state/manager.py +0 -315
- fastapi_cachex-0.4.1/fastapi_cachex/state/models.py +0 -38
- fastapi_cachex-0.4.1/fastapi_cachex/state/proxy.py +0 -9
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/LICENSE +0 -0
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/fastapi_cachex/_warnings.py +0 -0
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/fastapi_cachex/backends/__init__.py +0 -0
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/fastapi_cachex/backends/codec.py +0 -0
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/fastapi_cachex/backends/config.py +0 -0
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/fastapi_cachex/dependencies.py +0 -0
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/fastapi_cachex/directives.py +0 -0
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/fastapi_cachex/manager_proxy.py +0 -0
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/fastapi_cachex/proxy.py +0 -0
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/fastapi_cachex/py.typed +0 -0
- {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.0}/fastapi_cachex/routes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-cachex
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: A caching library for FastAPI with support for Cache-Control, ETag, and multiple backends.
|
|
5
5
|
Keywords: fastapi,cache,etag,cache-control,redis,memcached,in-memory
|
|
6
6
|
Author: allen0099
|
|
@@ -16,16 +16,14 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
19
20
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
21
|
Classifier: Framework :: FastAPI
|
|
21
22
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
23
|
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
23
24
|
Classifier: Typing :: Typed
|
|
24
|
-
Requires-Dist: fastapi>=0.
|
|
25
|
-
Requires-Dist: itsdangerous>=1.1.0
|
|
25
|
+
Requires-Dist: fastapi>=0.128.2
|
|
26
26
|
Requires-Dist: pydantic>=2.7.0
|
|
27
|
-
Requires-Dist: starlette>=1.0.0
|
|
28
|
-
Requires-Dist: pyjwt>=2.9.0 ; extra == 'jwt'
|
|
29
27
|
Requires-Dist: pymemcache>=4.0.0 ; extra == 'memcached'
|
|
30
28
|
Requires-Dist: redis[hiredis]>=5.3.0 ; extra == 'redis'
|
|
31
29
|
Requires-Dist: orjson>=3.4.7 ; extra == 'redis'
|
|
@@ -34,7 +32,6 @@ Project-URL: Homepage, https://github.com/allen0099/FastAPI-CacheX
|
|
|
34
32
|
Project-URL: Repository, https://github.com/allen0099/FastAPI-CacheX.git
|
|
35
33
|
Project-URL: Issues, https://github.com/allen0099/FastAPI-CacheX/issues
|
|
36
34
|
Project-URL: Documentation, https://fastapi-cachex.readthedocs.io/
|
|
37
|
-
Provides-Extra: jwt
|
|
38
35
|
Provides-Extra: memcached
|
|
39
36
|
Provides-Extra: redis
|
|
40
37
|
Description-Content-Type: text/markdown
|
|
@@ -64,12 +61,10 @@ A high-performance caching extension for FastAPI: a server-side response cache w
|
|
|
64
61
|
- **HTTP caching** — a `@cache` decorator for GET routes with `Cache-Control`,
|
|
65
62
|
`ETag` / `If-None-Match` (304) and per-route invalidation.
|
|
66
63
|
- **Application cache** — `CacheManager` for caching arbitrary JSON values in
|
|
67
|
-
your own code, with compute-on-miss `get_or_set()` and atomic store-if-absent `add()
|
|
64
|
+
your own code, with compute-on-miss `get_or_set()` and atomic store-if-absent `add()`,
|
|
65
|
+
and `@cached` for a plain function, keyed on its arguments.
|
|
68
66
|
- **Backends** — in-memory, Redis and Memcached, with atomic counters,
|
|
69
67
|
one-shot values and locks.
|
|
70
|
-
- **Sessions and OAuth state (deprecated)**: signed session tokens and one-time
|
|
71
|
-
OAuth state tokens. Both are deprecated in 0.4.0 and removed in 0.5.0; see
|
|
72
|
-
[where to move](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_4/#session-state-deprecated).
|
|
73
68
|
|
|
74
69
|
## Installation
|
|
75
70
|
|
|
@@ -78,15 +73,14 @@ uv add fastapi-cachex
|
|
|
78
73
|
```
|
|
79
74
|
|
|
80
75
|
Everything in the core package works with the in-memory backend. The other
|
|
81
|
-
backends
|
|
76
|
+
backends ship as extras:
|
|
82
77
|
|
|
83
78
|
| Extra | Install | Pulls in | Needed for |
|
|
84
79
|
|-------|---------|----------|------------|
|
|
85
80
|
| `redis` | `uv add "fastapi-cachex[redis]"` | `redis[hiredis]`, `orjson` | `AsyncRedisCacheBackend` |
|
|
86
81
|
| `memcached` | `uv add "fastapi-cachex[memcached]"` | `pymemcache` | `MemcachedBackend` |
|
|
87
|
-
| `jwt` | `uv add "fastapi-cachex[jwt]"` | `PyJWT` | `SessionConfig(token_format="jwt")` |
|
|
88
82
|
|
|
89
|
-
Extras combine: `uv add "fastapi-cachex[redis,
|
|
83
|
+
Extras combine: `uv add "fastapi-cachex[redis,memcached]"`.
|
|
90
84
|
|
|
91
85
|
## Quick Start
|
|
92
86
|
|
|
@@ -136,17 +130,25 @@ async def report(cache: AppCache):
|
|
|
136
130
|
> [!WARNING]
|
|
137
131
|
> The default cache key carries no user identity. Cache authenticated endpoints
|
|
138
132
|
> with `private=True` or a per-user key builder plus `cache_authorized=True`
|
|
139
|
-
> (requests with `Authorization` or
|
|
133
|
+
> (requests with `Authorization` or non-empty `request.session` data otherwise
|
|
134
|
+
> bypass the backend) — see
|
|
140
135
|
> [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#authenticated-endpoints).
|
|
141
136
|
|
|
142
137
|
## Documentation
|
|
143
138
|
|
|
139
|
+
The guides are built from `docs/` into the site linked below. Read them there:
|
|
140
|
+
a code block that includes one of the runnable examples renders on the site,
|
|
141
|
+
while GitHub shows only its `--8<--` include line (the sentence before each
|
|
142
|
+
block links the example file).
|
|
143
|
+
|
|
144
|
+
- [Migrating to 0.5.0](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_5/) — what 0.5.0 removes and how to upgrade from 0.4.x
|
|
144
145
|
- [Migrating to 0.4.0](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_4/) — what 0.4.0 changes and how to upgrade from 0.3.x
|
|
146
|
+
- [Migrating from fastapi-cache2](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_FROM_FASTAPI_CACHE2/) — its API mapped onto this one, and the behaviour that differs
|
|
147
|
+
- [When to use it](https://fastapi-cachex.readthedocs.io/en/latest/COMPARISON/) — how it compares with fastapi-cache2, cashews, aiocache and a CDN, and when another one fits better
|
|
145
148
|
- [HTTP caching](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/) — the `@cache` decorator, Cache-Control directives, cache keys, invalidation and monitoring routes
|
|
146
149
|
- [Application cache](https://fastapi-cachex.readthedocs.io/en/latest/APP_CACHE/) — `CacheManager`
|
|
147
150
|
- [Backends](https://fastapi-cachex.readthedocs.io/en/latest/BACKENDS/) — choosing and configuring a backend, atomic primitives
|
|
148
151
|
- [Distributed lock](https://fastapi-cachex.readthedocs.io/en/latest/LOCK/) — `CacheLock` for multi-process mutual exclusion
|
|
149
|
-
- Deprecated, removed in 0.5.0: [Session management](https://fastapi-cachex.readthedocs.io/en/latest/SESSION/), [OAuth state](https://fastapi-cachex.readthedocs.io/en/latest/STATE/) (one-shot OAuth/CSRF state tokens) and [JWT claims](https://fastapi-cachex.readthedocs.io/en/latest/JWT_CLAIMS/)
|
|
150
152
|
- [Cache flow](https://fastapi-cachex.readthedocs.io/en/latest/CACHE_FLOW/) — what happens inside a cached request
|
|
151
153
|
- [Runnable examples](https://github.com/allen0099/FastAPI-CacheX/tree/master/examples) — one complete app per feature, each covered by the test suite
|
|
152
154
|
- [API reference](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/)
|
|
@@ -23,12 +23,10 @@ A high-performance caching extension for FastAPI: a server-side response cache w
|
|
|
23
23
|
- **HTTP caching** — a `@cache` decorator for GET routes with `Cache-Control`,
|
|
24
24
|
`ETag` / `If-None-Match` (304) and per-route invalidation.
|
|
25
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()
|
|
26
|
+
your own code, with compute-on-miss `get_or_set()` and atomic store-if-absent `add()`,
|
|
27
|
+
and `@cached` for a plain function, keyed on its arguments.
|
|
27
28
|
- **Backends** — in-memory, Redis and Memcached, with atomic counters,
|
|
28
29
|
one-shot values and locks.
|
|
29
|
-
- **Sessions and OAuth state (deprecated)**: signed session tokens and one-time
|
|
30
|
-
OAuth state tokens. Both are deprecated in 0.4.0 and removed in 0.5.0; see
|
|
31
|
-
[where to move](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_4/#session-state-deprecated).
|
|
32
30
|
|
|
33
31
|
## Installation
|
|
34
32
|
|
|
@@ -37,15 +35,14 @@ uv add fastapi-cachex
|
|
|
37
35
|
```
|
|
38
36
|
|
|
39
37
|
Everything in the core package works with the in-memory backend. The other
|
|
40
|
-
backends
|
|
38
|
+
backends ship as extras:
|
|
41
39
|
|
|
42
40
|
| Extra | Install | Pulls in | Needed for |
|
|
43
41
|
|-------|---------|----------|------------|
|
|
44
42
|
| `redis` | `uv add "fastapi-cachex[redis]"` | `redis[hiredis]`, `orjson` | `AsyncRedisCacheBackend` |
|
|
45
43
|
| `memcached` | `uv add "fastapi-cachex[memcached]"` | `pymemcache` | `MemcachedBackend` |
|
|
46
|
-
| `jwt` | `uv add "fastapi-cachex[jwt]"` | `PyJWT` | `SessionConfig(token_format="jwt")` |
|
|
47
44
|
|
|
48
|
-
Extras combine: `uv add "fastapi-cachex[redis,
|
|
45
|
+
Extras combine: `uv add "fastapi-cachex[redis,memcached]"`.
|
|
49
46
|
|
|
50
47
|
## Quick Start
|
|
51
48
|
|
|
@@ -95,17 +92,25 @@ async def report(cache: AppCache):
|
|
|
95
92
|
> [!WARNING]
|
|
96
93
|
> The default cache key carries no user identity. Cache authenticated endpoints
|
|
97
94
|
> with `private=True` or a per-user key builder plus `cache_authorized=True`
|
|
98
|
-
> (requests with `Authorization` or
|
|
95
|
+
> (requests with `Authorization` or non-empty `request.session` data otherwise
|
|
96
|
+
> bypass the backend) — see
|
|
99
97
|
> [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#authenticated-endpoints).
|
|
100
98
|
|
|
101
99
|
## Documentation
|
|
102
100
|
|
|
101
|
+
The guides are built from `docs/` into the site linked below. Read them there:
|
|
102
|
+
a code block that includes one of the runnable examples renders on the site,
|
|
103
|
+
while GitHub shows only its `--8<--` include line (the sentence before each
|
|
104
|
+
block links the example file).
|
|
105
|
+
|
|
106
|
+
- [Migrating to 0.5.0](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_5/) — what 0.5.0 removes and how to upgrade from 0.4.x
|
|
103
107
|
- [Migrating to 0.4.0](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_4/) — what 0.4.0 changes and how to upgrade from 0.3.x
|
|
108
|
+
- [Migrating from fastapi-cache2](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_FROM_FASTAPI_CACHE2/) — its API mapped onto this one, and the behaviour that differs
|
|
109
|
+
- [When to use it](https://fastapi-cachex.readthedocs.io/en/latest/COMPARISON/) — how it compares with fastapi-cache2, cashews, aiocache and a CDN, and when another one fits better
|
|
104
110
|
- [HTTP caching](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/) — the `@cache` decorator, Cache-Control directives, cache keys, invalidation and monitoring routes
|
|
105
111
|
- [Application cache](https://fastapi-cachex.readthedocs.io/en/latest/APP_CACHE/) — `CacheManager`
|
|
106
112
|
- [Backends](https://fastapi-cachex.readthedocs.io/en/latest/BACKENDS/) — choosing and configuring a backend, atomic primitives
|
|
107
113
|
- [Distributed lock](https://fastapi-cachex.readthedocs.io/en/latest/LOCK/) — `CacheLock` for multi-process mutual exclusion
|
|
108
|
-
- Deprecated, removed in 0.5.0: [Session management](https://fastapi-cachex.readthedocs.io/en/latest/SESSION/), [OAuth state](https://fastapi-cachex.readthedocs.io/en/latest/STATE/) (one-shot OAuth/CSRF state tokens) and [JWT claims](https://fastapi-cachex.readthedocs.io/en/latest/JWT_CLAIMS/)
|
|
109
114
|
- [Cache flow](https://fastapi-cachex.readthedocs.io/en/latest/CACHE_FLOW/) — what happens inside a cached request
|
|
110
115
|
- [Runnable examples](https://github.com/allen0099/FastAPI-CacheX/tree/master/examples) — one complete app per feature, each covered by the test suite
|
|
111
116
|
- [API reference](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""FastAPI-CacheX: A powerful and flexible caching extension for FastAPI."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from importlib.metadata import PackageNotFoundError
|
|
5
|
+
from importlib.metadata import version
|
|
6
|
+
|
|
7
|
+
from .cache import build_cache_key as build_cache_key
|
|
8
|
+
from .cache import cache as cache
|
|
9
|
+
from .cache import default_key_builder as default_key_builder
|
|
10
|
+
from .cache import invalidate as invalidate
|
|
11
|
+
from .cache_key import CacheKey as CacheKey
|
|
12
|
+
from .cached import CachedFunction as CachedFunction
|
|
13
|
+
from .cached import cached as cached
|
|
14
|
+
from .dependencies import AppCache as AppCache
|
|
15
|
+
from .dependencies import CacheBackend as CacheBackend
|
|
16
|
+
from .dependencies import get_app_cache as get_app_cache
|
|
17
|
+
from .dependencies import get_cache_backend as get_cache_backend
|
|
18
|
+
from .exceptions import BackendNotFoundError as BackendNotFoundError
|
|
19
|
+
from .exceptions import CacheXError as CacheXError
|
|
20
|
+
from .exceptions import LockTimeoutError as LockTimeoutError
|
|
21
|
+
from .exceptions import ProxyNotSetError as ProxyNotSetError
|
|
22
|
+
from .exceptions import RequestNotFoundError as RequestNotFoundError
|
|
23
|
+
from .lock import CacheLock as CacheLock
|
|
24
|
+
from .manager import CacheManager as CacheManager
|
|
25
|
+
from .manager_proxy import CacheManagerProxy as CacheManagerProxy
|
|
26
|
+
from .proxy import BackendProxy as BackendProxy
|
|
27
|
+
from .routes import add_routes as add_routes
|
|
28
|
+
from .types import CacheKeyBuilder as CacheKeyBuilder
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _read_version() -> str:
|
|
32
|
+
"""Return the installed distribution's version.
|
|
33
|
+
|
|
34
|
+
Importing from a source tree that was never installed leaves no metadata to
|
|
35
|
+
read; reporting a development version there is part of the contract, so this
|
|
36
|
+
lives in a function the tests can drive rather than behind a coverage pragma.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
return version("fastapi-cachex")
|
|
40
|
+
except PackageNotFoundError:
|
|
41
|
+
return "0.0.0.dev0"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
__version__ = _read_version()
|
|
45
|
+
|
|
46
|
+
_package_logger = logging.getLogger("fastapi_cachex")
|
|
47
|
+
_package_logger.addHandler(
|
|
48
|
+
logging.NullHandler()
|
|
49
|
+
) # Attach a NullHandler to avoid "No handler found" warnings in user applications.
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"AppCache",
|
|
53
|
+
"BackendNotFoundError",
|
|
54
|
+
"BackendProxy",
|
|
55
|
+
"CacheBackend",
|
|
56
|
+
"CacheKey",
|
|
57
|
+
"CacheKeyBuilder",
|
|
58
|
+
"CacheLock",
|
|
59
|
+
"CacheManager",
|
|
60
|
+
"CacheManagerProxy",
|
|
61
|
+
"CacheXError",
|
|
62
|
+
"CachedFunction",
|
|
63
|
+
"LockTimeoutError",
|
|
64
|
+
"ProxyNotSetError",
|
|
65
|
+
"RequestNotFoundError",
|
|
66
|
+
"__version__",
|
|
67
|
+
"add_routes",
|
|
68
|
+
"build_cache_key",
|
|
69
|
+
"cache",
|
|
70
|
+
"cached",
|
|
71
|
+
"default_key_builder",
|
|
72
|
+
"get_app_cache",
|
|
73
|
+
"get_cache_backend",
|
|
74
|
+
"invalidate",
|
|
75
|
+
]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""The `Cache-Control` header `@cache` sends, and which responses stay private."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from fastapi import Response
|
|
7
|
+
|
|
8
|
+
from .directives import DirectiveType
|
|
9
|
+
|
|
10
|
+
_NO_STORE = DirectiveType.NO_STORE.value
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CacheControl:
|
|
14
|
+
"""Manages Cache-Control header directives."""
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
"""Initialize an empty CacheControl instance."""
|
|
18
|
+
self.directives: list[str] = []
|
|
19
|
+
|
|
20
|
+
def add(self, directive: DirectiveType, value: int | None = None) -> None:
|
|
21
|
+
"""Add a Cache-Control directive.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
directive: The directive type to add
|
|
25
|
+
value: Optional value for the directive
|
|
26
|
+
"""
|
|
27
|
+
if value is not None:
|
|
28
|
+
self.directives.append(f"{directive.value}={value}")
|
|
29
|
+
else:
|
|
30
|
+
self.directives.append(directive.value)
|
|
31
|
+
|
|
32
|
+
def __str__(self) -> str:
|
|
33
|
+
"""Return the Cache-Control header value as a string."""
|
|
34
|
+
return ", ".join(self.directives)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _build_cache_control(
|
|
38
|
+
*,
|
|
39
|
+
ttl: int | None,
|
|
40
|
+
stale: Literal["error", "revalidate"] | None,
|
|
41
|
+
stale_ttl: int | None,
|
|
42
|
+
no_cache: bool,
|
|
43
|
+
public: bool,
|
|
44
|
+
private: bool,
|
|
45
|
+
immutable: bool,
|
|
46
|
+
must_revalidate: bool,
|
|
47
|
+
) -> str:
|
|
48
|
+
"""The ``Cache-Control`` value for a ``@cache`` route's arguments.
|
|
49
|
+
|
|
50
|
+
``no_cache`` sends only ``no-cache`` (plus ``must-revalidate``); otherwise
|
|
51
|
+
the directives follow in a fixed order: scope, ``max-age``,
|
|
52
|
+
``must-revalidate``, the stale directive, ``immutable``.
|
|
53
|
+
"""
|
|
54
|
+
cache_control = CacheControl()
|
|
55
|
+
if no_cache:
|
|
56
|
+
cache_control.add(DirectiveType.NO_CACHE)
|
|
57
|
+
if must_revalidate:
|
|
58
|
+
cache_control.add(DirectiveType.MUST_REVALIDATE)
|
|
59
|
+
return str(cache_control)
|
|
60
|
+
|
|
61
|
+
# 1. Access scope (public/private)
|
|
62
|
+
if public:
|
|
63
|
+
cache_control.add(DirectiveType.PUBLIC)
|
|
64
|
+
elif private:
|
|
65
|
+
cache_control.add(DirectiveType.PRIVATE)
|
|
66
|
+
|
|
67
|
+
# 2. Cache time settings
|
|
68
|
+
if ttl is not None:
|
|
69
|
+
cache_control.add(DirectiveType.MAX_AGE, ttl)
|
|
70
|
+
|
|
71
|
+
# 3. Validation related
|
|
72
|
+
if must_revalidate:
|
|
73
|
+
cache_control.add(DirectiveType.MUST_REVALIDATE)
|
|
74
|
+
|
|
75
|
+
# 4. Stale response handling (stale_ttl is validated at decoration time)
|
|
76
|
+
if stale == "revalidate":
|
|
77
|
+
cache_control.add(DirectiveType.STALE_WHILE_REVALIDATE, stale_ttl)
|
|
78
|
+
elif stale == "error":
|
|
79
|
+
cache_control.add(DirectiveType.STALE_IF_ERROR, stale_ttl)
|
|
80
|
+
|
|
81
|
+
# 5. Special flags
|
|
82
|
+
if immutable:
|
|
83
|
+
cache_control.add(DirectiveType.IMMUTABLE)
|
|
84
|
+
|
|
85
|
+
return str(cache_control)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Response directives by which the handler says its response belongs to one
|
|
89
|
+
# caller (``private``) or must not be kept at all (``no-store``).
|
|
90
|
+
_UNSHAREABLE_DIRECTIVES = frozenset(
|
|
91
|
+
{DirectiveType.PRIVATE.value, DirectiveType.NO_STORE.value}
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _marked_unshareable(response: Response) -> bool:
|
|
96
|
+
"""Whether the handler's own ``Cache-Control`` has ``private`` or ``no-store``.
|
|
97
|
+
|
|
98
|
+
Directive names are matched as whole tokens, case-insensitively, across
|
|
99
|
+
every ``Cache-Control`` field the response carries.
|
|
100
|
+
"""
|
|
101
|
+
return _has_unshareable_directive(response.headers.getlist("cache-control"))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _has_unshareable_directive(cache_control: Iterable[str]) -> bool:
|
|
105
|
+
"""Whether any of these ``Cache-Control`` values has ``private`` or ``no-store``."""
|
|
106
|
+
return any(
|
|
107
|
+
directive.split("=", 1)[0].strip().lower() in _UNSHAREABLE_DIRECTIVES
|
|
108
|
+
for value in cache_control
|
|
109
|
+
for directive in value.split(",")
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _unshareable_reason(response: Response) -> str | None:
|
|
114
|
+
"""Why a rendered response must not be stored, or None when it may be."""
|
|
115
|
+
if _marked_unshareable(response):
|
|
116
|
+
return "response Cache-Control is private or no-store"
|
|
117
|
+
if "set-cookie" in response.headers:
|
|
118
|
+
return "response sets a cookie"
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _cache_control_for(
|
|
123
|
+
response: Response, cache_control: str, private_cache_control: str
|
|
124
|
+
) -> str:
|
|
125
|
+
"""The ``Cache-Control`` to send for a response the handler just rendered.
|
|
126
|
+
|
|
127
|
+
A handler that marked its response ``private`` or ``no-store`` keeps its own
|
|
128
|
+
header; the decorator's would widen what the handler allowed. A response
|
|
129
|
+
that sets a cookie gets ``private_cache_control``, so a shared cache in
|
|
130
|
+
front of the app does not store it either. When the decorator has no
|
|
131
|
+
directive to send (a bare ``@cache()``), the handler's own header is kept,
|
|
132
|
+
or none is sent (#363); a cookie still gets ``private_cache_control``.
|
|
133
|
+
"""
|
|
134
|
+
if _marked_unshareable(response):
|
|
135
|
+
return ", ".join(response.headers.getlist("cache-control"))
|
|
136
|
+
if "set-cookie" in response.headers:
|
|
137
|
+
return private_cache_control
|
|
138
|
+
if not cache_control:
|
|
139
|
+
return ", ".join(response.headers.getlist("cache-control"))
|
|
140
|
+
return cache_control
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _with_cache_control(
|
|
144
|
+
response: Response, cache_control: str, private_cache_control: str
|
|
145
|
+
) -> Response:
|
|
146
|
+
if not _marked_unshareable(response):
|
|
147
|
+
value = _cache_control_for(response, cache_control, private_cache_control)
|
|
148
|
+
if value:
|
|
149
|
+
response.headers["Cache-Control"] = value
|
|
150
|
+
return response
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Telling whether a callable returns a coroutine, for handlers and key builders."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _is_coroutine_callable(func: Callable[..., object]) -> bool:
|
|
8
|
+
"""Report whether calling `func` returns a coroutine.
|
|
9
|
+
|
|
10
|
+
`inspect.iscoroutinefunction` already sees through `functools.partial`;
|
|
11
|
+
an instance with an ``async def __call__`` needs its method checked. The
|
|
12
|
+
method is looked up on the type, as the call itself does, so a class
|
|
13
|
+
(whose type is ``type``) counts as sync.
|
|
14
|
+
"""
|
|
15
|
+
return inspect.iscoroutinefunction(func) or inspect.iscoroutinefunction(
|
|
16
|
+
type(func).__call__
|
|
17
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""In-process coalescing of concurrent misses for `@cache(coalesce=True)` (#252).
|
|
2
|
+
|
|
3
|
+
The first request to miss a key leads: it runs the handler as usual. Requests
|
|
4
|
+
that miss the same key while it runs follow: they wait until the leader has
|
|
5
|
+
answered, then read the backend again. A follower that still finds no entry
|
|
6
|
+
(the leader's response was not stored, or the handler failed) renders its own
|
|
7
|
+
response without waiting again, so followers never queue behind each other.
|
|
8
|
+
|
|
9
|
+
Only requests in this process are coalesced. The registry is keyed by event
|
|
10
|
+
loop and backend as well as the cache key, so two apps or loops never wait on
|
|
11
|
+
each other.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
|
|
16
|
+
# One future per key being rendered by a leader; done once it has answered.
|
|
17
|
+
_IN_FLIGHT: dict[tuple[int, int, str], asyncio.Future[None]] = {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _Flight:
|
|
21
|
+
"""One request's part in coalescing: leader, follower or neither.
|
|
22
|
+
|
|
23
|
+
The wrapper creates one per request and calls ``finish()`` once the
|
|
24
|
+
request has been answered, however it ended, so a leader always releases
|
|
25
|
+
its followers and leaves nothing behind in the registry.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
self._key: tuple[int, int, str] | None = None
|
|
30
|
+
self._future: asyncio.Future[None] | None = None
|
|
31
|
+
|
|
32
|
+
def join(
|
|
33
|
+
self, backend: object, cache_key: str, *, may_lead: bool
|
|
34
|
+
) -> asyncio.Future[None] | None:
|
|
35
|
+
"""Return the leader's future to wait on, or ``None`` to render.
|
|
36
|
+
|
|
37
|
+
With ``may_lead`` and no leader for the key, this request becomes the
|
|
38
|
+
leader. Without it (HEAD, whose response is never stored), it renders
|
|
39
|
+
without blocking anyone.
|
|
40
|
+
"""
|
|
41
|
+
loop = asyncio.get_running_loop()
|
|
42
|
+
key = (id(loop), id(backend), cache_key)
|
|
43
|
+
running = _IN_FLIGHT.get(key)
|
|
44
|
+
if running is not None or not may_lead:
|
|
45
|
+
return running
|
|
46
|
+
self._key = key
|
|
47
|
+
self._future = loop.create_future()
|
|
48
|
+
_IN_FLIGHT[key] = self._future
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
def finish(self) -> None:
|
|
52
|
+
"""Release the followers, if this request led."""
|
|
53
|
+
if self._key is None or self._future is None:
|
|
54
|
+
return
|
|
55
|
+
# Only the leader registers or removes its key, and followers wait
|
|
56
|
+
# through a shield, so the future is still pending. `pop` because a
|
|
57
|
+
# test teardown may have cleared the registry already.
|
|
58
|
+
_IN_FLIGHT.pop(self._key, None)
|
|
59
|
+
self._future.set_result(None)
|
|
60
|
+
self._key = None
|
|
61
|
+
self._future = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _wait_for(leader: asyncio.Future[None]) -> None:
|
|
65
|
+
"""Wait until the leader has answered.
|
|
66
|
+
|
|
67
|
+
Shielded: a follower that is cancelled stops waiting without cancelling
|
|
68
|
+
the leader's future for the other followers.
|
|
69
|
+
"""
|
|
70
|
+
await asyncio.shield(leader)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Cache keys for `@cache`: the default key builder and custom ones."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from functools import partial
|
|
8
|
+
|
|
9
|
+
from fastapi import Request
|
|
10
|
+
|
|
11
|
+
from ._callables import _is_coroutine_callable
|
|
12
|
+
from .cache_key import CacheKey
|
|
13
|
+
from .exceptions import CacheXError
|
|
14
|
+
from .types import CACHE_KEY_SEPARATOR
|
|
15
|
+
from .types import CacheKeyBuilder
|
|
16
|
+
from .types import escape_key_component
|
|
17
|
+
|
|
18
|
+
# The documented logger: every part of `@cache` logs under this name.
|
|
19
|
+
logger = logging.getLogger("fastapi_cachex.cache")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_cache_key(
|
|
23
|
+
request: Request, *components: str | int, sort_query: bool = True
|
|
24
|
+
) -> str:
|
|
25
|
+
"""Build the default cache key for ``request``, plus extra components.
|
|
26
|
+
|
|
27
|
+
With no ``components`` the key is ``http:v2|method|host|path|query``,
|
|
28
|
+
exactly what ``@cache`` uses by default. Each extra component is appended
|
|
29
|
+
after another separator, so a custom ``key_builder`` can add a dimension
|
|
30
|
+
(user ID, tenant, locale) without rebuilding the default key by hand::
|
|
31
|
+
|
|
32
|
+
def per_user_key(request: Request) -> str:
|
|
33
|
+
return build_cache_key(request, request.state.user_id)
|
|
34
|
+
|
|
35
|
+
The host is the ``Host`` header lower-cased, without an empty or default
|
|
36
|
+
port (``:80`` on http, ``:443`` on https), or ``unknown`` when there is
|
|
37
|
+
none. ``|`` and ``%`` in the host, the path and every extra component are
|
|
38
|
+
percent-encoded (see ``escape_key_component``), so none of them can
|
|
39
|
+
contain the separator and make one request's key equal another's. The
|
|
40
|
+
query string is already URL-encoded and never contains ``|``.
|
|
41
|
+
|
|
42
|
+
Keys built this way keep the tag, method, host and path in front, so
|
|
43
|
+
``clear_path()`` still finds them and the monitoring routes still show
|
|
44
|
+
their method, host, path and query. This is
|
|
45
|
+
``CacheKey.from_request(...).to_str()``; ``CacheKey.parse()`` decodes the
|
|
46
|
+
key again.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
request: The FastAPI Request object
|
|
50
|
+
*components: Extra key components, appended in order. A ``str`` is
|
|
51
|
+
used as is and an ``int`` is written in decimal, so ``1`` and
|
|
52
|
+
``"1"`` give the same key. An empty string is a component of its
|
|
53
|
+
own: ``build_cache_key(request, "")`` differs from
|
|
54
|
+
``build_cache_key(request)``.
|
|
55
|
+
sort_query: Order the query parameters by name (a stable sort, so
|
|
56
|
+
``?tag=b&tag=a`` stays distinct from ``?tag=a&tag=b``), so that
|
|
57
|
+
``?a=1&b=2`` and ``?b=2&a=1`` give the same key. On by default,
|
|
58
|
+
as in ``@cache``; ``False`` keeps the order the client sent, as
|
|
59
|
+
``@cache(sort_query=False)`` does.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Generated cache key string
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
TypeError: If a component is not a ``str`` or ``int`` (``bool`` is
|
|
66
|
+
rejected too), e.g. ``None`` from a missing user ID, which would
|
|
67
|
+
otherwise put every such caller under one ``"None"`` key.
|
|
68
|
+
"""
|
|
69
|
+
key = CacheKey.from_request(request, *components, sort_query=sort_query).to_str()
|
|
70
|
+
logger.debug("Built cache key: %s", key)
|
|
71
|
+
return key
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _append_key_components(key: str, components: Sequence[str]) -> str:
|
|
75
|
+
"""Append each component to ``key``, escaped, after another separator."""
|
|
76
|
+
return CACHE_KEY_SEPARATOR.join(
|
|
77
|
+
[key, *(escape_key_component(component) for component in components)]
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def default_key_builder(request: Request) -> str:
|
|
82
|
+
"""Default cache key builder function: ``build_cache_key(request)``.
|
|
83
|
+
|
|
84
|
+
Generates cache key in format: http:v2|method|host|path|query, with the
|
|
85
|
+
query parameters sorted by name.
|
|
86
|
+
|
|
87
|
+
Kept as the name ``@cache`` and ``invalidate()`` fall back to. To add
|
|
88
|
+
components to the default key, call ``build_cache_key`` instead.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
request: The FastAPI Request object
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Generated cache key string
|
|
95
|
+
"""
|
|
96
|
+
return build_cache_key(request)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _unsorted_query_key_builder(request: Request) -> str:
|
|
100
|
+
"""The key builder of ``@cache(sort_query=False)``."""
|
|
101
|
+
return build_cache_key(request, sort_query=False)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_SORT_QUERY_WITH_KEY_BUILDER_MSG = (
|
|
105
|
+
"sort_query only applies to the default key builder; a custom key_builder "
|
|
106
|
+
"builds its own key, so pass sort_query to build_cache_key() in it instead "
|
|
107
|
+
"(it sorts unless told otherwise)"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _resolve_key_builder(
|
|
112
|
+
key_builder: CacheKeyBuilder | None, sort_query: object
|
|
113
|
+
) -> CacheKeyBuilder:
|
|
114
|
+
"""Pick the key builder for ``key_builder`` and ``sort_query``.
|
|
115
|
+
|
|
116
|
+
``sort_query=None`` means it was not passed: the default key builder
|
|
117
|
+
sorts, and a custom one is used as is.
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
CacheXError: If ``sort_query`` is not a ``bool`` or ``None``, if it
|
|
121
|
+
is passed with a custom ``key_builder`` (the flag would silently
|
|
122
|
+
do nothing), or if ``key_builder`` is an ``async`` callable.
|
|
123
|
+
"""
|
|
124
|
+
if sort_query is not None and not isinstance(sort_query, bool):
|
|
125
|
+
msg = f"sort_query must be a bool, got {type(sort_query).__name__}"
|
|
126
|
+
raise CacheXError(msg)
|
|
127
|
+
if key_builder is not None:
|
|
128
|
+
if sort_query is not None:
|
|
129
|
+
raise CacheXError(_SORT_QUERY_WITH_KEY_BUILDER_MSG)
|
|
130
|
+
_validate_key_builder(key_builder)
|
|
131
|
+
return key_builder
|
|
132
|
+
return _unsorted_query_key_builder if sort_query is False else default_key_builder
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
_ASYNC_KEY_BUILDER_MSG = (
|
|
136
|
+
"key_builder must be a sync function returning str; async key builders "
|
|
137
|
+
"are not supported (the key is built without awaiting)"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _validate_key_builder(builder: Callable[..., object]) -> None:
|
|
142
|
+
"""Reject a key builder whose call returns a coroutine.
|
|
143
|
+
|
|
144
|
+
``functools.partial`` layers are unwrapped first, so a partial of an async
|
|
145
|
+
callable object is caught as well. A builder this cannot see through
|
|
146
|
+
(say a sync wrapper returning a coroutine) is caught by ``_build_key``.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
CacheXError: If calling ``builder`` would return a coroutine.
|
|
150
|
+
"""
|
|
151
|
+
target = builder
|
|
152
|
+
while isinstance(target, partial):
|
|
153
|
+
target = target.func
|
|
154
|
+
if _is_coroutine_callable(target):
|
|
155
|
+
raise CacheXError(_ASYNC_KEY_BUILDER_MSG)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _build_key(builder: CacheKeyBuilder, request: Request) -> str:
|
|
159
|
+
"""Call ``builder`` and check that it returned a ``str``.
|
|
160
|
+
|
|
161
|
+
A returned coroutine is closed, so it does not also trigger a "never
|
|
162
|
+
awaited" ``RuntimeWarning``. ``fail_open`` does not cover this: it is a
|
|
163
|
+
programming error in the route, not a backend failure.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
CacheXError: If ``builder`` returns anything but a ``str``.
|
|
167
|
+
"""
|
|
168
|
+
key: object = builder(request)
|
|
169
|
+
if isinstance(key, str):
|
|
170
|
+
return key
|
|
171
|
+
if inspect.iscoroutine(key):
|
|
172
|
+
key.close()
|
|
173
|
+
raise CacheXError(_ASYNC_KEY_BUILDER_MSG)
|
|
174
|
+
msg = f"key_builder must return a str, got {type(key).__name__}"
|
|
175
|
+
raise CacheXError(msg)
|