fastapi-cachex 0.2.12__tar.gz → 0.3.1__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.2.12 → fastapi_cachex-0.3.1}/PKG-INFO +43 -1
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/README.md +40 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/__init__.py +18 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/base.py +3 -3
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/memcached.py +15 -24
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/memory.py +10 -5
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/redis.py +21 -21
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/cache.py +65 -20
- fastapi_cachex-0.3.1/fastapi_cachex/dependencies.py +37 -0
- fastapi_cachex-0.3.1/fastapi_cachex/manager.py +211 -0
- fastapi_cachex-0.3.1/fastapi_cachex/manager_proxy.py +8 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/routes.py +11 -7
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/session/__init__.py +2 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/session/config.py +35 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/session/manager.py +35 -16
- fastapi_cachex-0.3.1/fastapi_cachex/session/middleware.py +485 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/session/security.py +2 -3
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/session/token_serializers.py +1 -1
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/state/__init__.py +3 -0
- fastapi_cachex-0.3.1/fastapi_cachex/state/dependencies.py +28 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/state/manager.py +20 -20
- fastapi_cachex-0.3.1/fastapi_cachex/state/proxy.py +9 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/types.py +5 -5
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/pyproject.toml +6 -2
- fastapi_cachex-0.2.12/fastapi_cachex/dependencies.py +0 -16
- fastapi_cachex-0.2.12/fastapi_cachex/session/middleware.py +0 -159
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/__init__.py +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/config.py +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/directives.py +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/exceptions.py +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/proxy.py +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/py.typed +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/session/dependencies.py +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/session/exceptions.py +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/session/models.py +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/session/proxy.py +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/state/exceptions.py +0 -0
- {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/state/models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-cachex
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
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
|
|
@@ -25,6 +25,7 @@ Requires-Dist: pyjwt>=2.9.0 ; extra == 'jwt'
|
|
|
25
25
|
Requires-Dist: pymemcache ; extra == 'memcache'
|
|
26
26
|
Requires-Dist: redis[hiredis]>=5.3.0 ; extra == 'redis'
|
|
27
27
|
Requires-Dist: orjson ; extra == 'redis'
|
|
28
|
+
Requires-Dist: itsdangerous ; extra == 'starlette'
|
|
28
29
|
Requires-Python: >=3.10
|
|
29
30
|
Project-URL: Homepage, https://github.com/allen0099/FastAPI-CacheX
|
|
30
31
|
Project-URL: Repository, https://github.com/allen0099/FastAPI-CacheX.git
|
|
@@ -32,6 +33,7 @@ Project-URL: Issues, https://github.com/allen0099/FastAPI-CacheX/issues
|
|
|
32
33
|
Provides-Extra: jwt
|
|
33
34
|
Provides-Extra: memcache
|
|
34
35
|
Provides-Extra: redis
|
|
36
|
+
Provides-Extra: starlette
|
|
35
37
|
Description-Content-Type: text/markdown
|
|
36
38
|
|
|
37
39
|
# FastAPI-Cache X
|
|
@@ -146,6 +148,44 @@ async def remove_cache(cache: CacheBackend):
|
|
|
146
148
|
await cache.clear_pattern("/path/to/clear/*") # Clear cache for a specific pattern
|
|
147
149
|
```
|
|
148
150
|
|
|
151
|
+
### Application-Level Caching (Manual Get/Set)
|
|
152
|
+
|
|
153
|
+
Beyond HTTP response caching via `@cache`, you can cache arbitrary JSON-serializable
|
|
154
|
+
Python values directly in your business logic using `CacheManager`. It's a thin,
|
|
155
|
+
namespaced wrapper around whichever backend is configured via `BackendProxy`.
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
from fastapi_cachex import AppCache, CacheManager
|
|
159
|
+
|
|
160
|
+
@app.get("/expensive")
|
|
161
|
+
async def expensive_operation(cache: AppCache):
|
|
162
|
+
result = await cache.get("expensive:result")
|
|
163
|
+
if result is None:
|
|
164
|
+
result = perform_expensive_calculation()
|
|
165
|
+
await cache.set("expensive:result", result, ttl=300)
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# Or instantiate directly, e.g. outside of a request:
|
|
170
|
+
manager = CacheManager(key_prefix="myapp:", default_ttl=60)
|
|
171
|
+
await manager.set("user:42", {"name": "Alice"})
|
|
172
|
+
user = await manager.get("user:42") # {"name": "Alice"}
|
|
173
|
+
await manager.delete("user:42")
|
|
174
|
+
await manager.clear_prefix() # clear everything under "myapp:"
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`CacheManager.get()` returns `None` (or a supplied `default=`) on a cache miss —
|
|
178
|
+
it never raises for missing or corrupted entries. `CacheManager` keys live under
|
|
179
|
+
their own `cache:`-prefixed namespace by default, separate from the HTTP route
|
|
180
|
+
cache and OAuth state, so `clear()`/`clear_prefix()` never touch unrelated cache
|
|
181
|
+
entries.
|
|
182
|
+
|
|
183
|
+
**Note**: `clear()`/`clear_prefix()` are implemented via the backend's
|
|
184
|
+
`get_all_keys()`. Since Memcached doesn't support key enumeration (see
|
|
185
|
+
[Memcached limitations](#memcached)), these two methods are no-ops on a
|
|
186
|
+
Memcached backend — `get()`/`set()`/`delete()`/`has()` work normally. Use
|
|
187
|
+
Redis or the in-memory backend if you need bulk clearing.
|
|
188
|
+
|
|
149
189
|
## Backend Configuration
|
|
150
190
|
|
|
151
191
|
FastAPI-CacheX supports multiple caching backends. You can easily switch between them using the `BackendProxy`.
|
|
@@ -166,6 +206,8 @@ This ensures that:
|
|
|
166
206
|
|
|
167
207
|
All backends automatically namespace keys with a prefix (e.g., `fastapi_cachex:`) to avoid conflicts with other applications.
|
|
168
208
|
|
|
209
|
+
`CacheManager` (see [Application-Level Caching](#application-level-caching-manual-getset)) uses a separate, simpler `cache:`-prefixed key namespace instead of this `|||`-separated format, since its keys aren't tied to HTTP requests.
|
|
210
|
+
|
|
169
211
|
### Cache Hit Behavior
|
|
170
212
|
|
|
171
213
|
When a cached entry is valid (within TTL):
|
|
@@ -110,6 +110,44 @@ async def remove_cache(cache: CacheBackend):
|
|
|
110
110
|
await cache.clear_pattern("/path/to/clear/*") # Clear cache for a specific pattern
|
|
111
111
|
```
|
|
112
112
|
|
|
113
|
+
### Application-Level Caching (Manual Get/Set)
|
|
114
|
+
|
|
115
|
+
Beyond HTTP response caching via `@cache`, you can cache arbitrary JSON-serializable
|
|
116
|
+
Python values directly in your business logic using `CacheManager`. It's a thin,
|
|
117
|
+
namespaced wrapper around whichever backend is configured via `BackendProxy`.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from fastapi_cachex import AppCache, CacheManager
|
|
121
|
+
|
|
122
|
+
@app.get("/expensive")
|
|
123
|
+
async def expensive_operation(cache: AppCache):
|
|
124
|
+
result = await cache.get("expensive:result")
|
|
125
|
+
if result is None:
|
|
126
|
+
result = perform_expensive_calculation()
|
|
127
|
+
await cache.set("expensive:result", result, ttl=300)
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# Or instantiate directly, e.g. outside of a request:
|
|
132
|
+
manager = CacheManager(key_prefix="myapp:", default_ttl=60)
|
|
133
|
+
await manager.set("user:42", {"name": "Alice"})
|
|
134
|
+
user = await manager.get("user:42") # {"name": "Alice"}
|
|
135
|
+
await manager.delete("user:42")
|
|
136
|
+
await manager.clear_prefix() # clear everything under "myapp:"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`CacheManager.get()` returns `None` (or a supplied `default=`) on a cache miss —
|
|
140
|
+
it never raises for missing or corrupted entries. `CacheManager` keys live under
|
|
141
|
+
their own `cache:`-prefixed namespace by default, separate from the HTTP route
|
|
142
|
+
cache and OAuth state, so `clear()`/`clear_prefix()` never touch unrelated cache
|
|
143
|
+
entries.
|
|
144
|
+
|
|
145
|
+
**Note**: `clear()`/`clear_prefix()` are implemented via the backend's
|
|
146
|
+
`get_all_keys()`. Since Memcached doesn't support key enumeration (see
|
|
147
|
+
[Memcached limitations](#memcached)), these two methods are no-ops on a
|
|
148
|
+
Memcached backend — `get()`/`set()`/`delete()`/`has()` work normally. Use
|
|
149
|
+
Redis or the in-memory backend if you need bulk clearing.
|
|
150
|
+
|
|
113
151
|
## Backend Configuration
|
|
114
152
|
|
|
115
153
|
FastAPI-CacheX supports multiple caching backends. You can easily switch between them using the `BackendProxy`.
|
|
@@ -130,6 +168,8 @@ This ensures that:
|
|
|
130
168
|
|
|
131
169
|
All backends automatically namespace keys with a prefix (e.g., `fastapi_cachex:`) to avoid conflicts with other applications.
|
|
132
170
|
|
|
171
|
+
`CacheManager` (see [Application-Level Caching](#application-level-caching-manual-getset)) uses a separate, simpler `cache:`-prefixed key namespace instead of this `|||`-separated format, since its keys aren't tied to HTTP requests.
|
|
172
|
+
|
|
133
173
|
### Cache Hit Behavior
|
|
134
174
|
|
|
135
175
|
When a cached entry is valid (within TTL):
|
|
@@ -4,10 +4,16 @@ import logging
|
|
|
4
4
|
|
|
5
5
|
from .cache import cache as cache
|
|
6
6
|
from .cache import default_key_builder as default_key_builder
|
|
7
|
+
from .cache import invalidate as invalidate
|
|
8
|
+
from .dependencies import AppCache as AppCache
|
|
7
9
|
from .dependencies import CacheBackend as CacheBackend
|
|
10
|
+
from .dependencies import get_app_cache as get_app_cache
|
|
8
11
|
from .dependencies import get_cache_backend as get_cache_backend
|
|
12
|
+
from .manager import CacheManager as CacheManager
|
|
13
|
+
from .manager_proxy import CacheManagerProxy as CacheManagerProxy
|
|
9
14
|
from .proxy import BackendProxy as BackendProxy
|
|
10
15
|
from .routes import add_routes as add_routes
|
|
16
|
+
from .session import FastAPICacheXSessionMiddleware as FastAPICacheXSessionMiddleware
|
|
11
17
|
from .session import Session as Session
|
|
12
18
|
from .session import SessionConfig as SessionConfig
|
|
13
19
|
from .session import SessionManager as SessionManager
|
|
@@ -30,6 +36,9 @@ from .state import StateDataError as StateDataError
|
|
|
30
36
|
from .state import StateError as StateError
|
|
31
37
|
from .state import StateExpiredError as StateExpiredError
|
|
32
38
|
from .state import StateManager as StateManager
|
|
39
|
+
from .state import StateManagerDep as StateManagerDep
|
|
40
|
+
from .state import StateManagerProxy as StateManagerProxy
|
|
41
|
+
from .state import get_state_manager as get_state_manager
|
|
33
42
|
from .types import CacheKeyBuilder as CacheKeyBuilder
|
|
34
43
|
|
|
35
44
|
_package_logger = logging.getLogger("fastapi_cachex")
|
|
@@ -38,9 +47,13 @@ _package_logger.addHandler(
|
|
|
38
47
|
) # Attach a NullHandler to avoid "No handler found" warnings in user applications.
|
|
39
48
|
|
|
40
49
|
__all__ = [
|
|
50
|
+
"AppCache",
|
|
41
51
|
"BackendProxy",
|
|
42
52
|
"CacheBackend",
|
|
43
53
|
"CacheKeyBuilder",
|
|
54
|
+
"CacheManager",
|
|
55
|
+
"CacheManagerProxy",
|
|
56
|
+
"FastAPICacheXSessionMiddleware",
|
|
44
57
|
"InvalidStateError",
|
|
45
58
|
"Session",
|
|
46
59
|
"SessionConfig",
|
|
@@ -59,12 +72,17 @@ __all__ = [
|
|
|
59
72
|
"StateError",
|
|
60
73
|
"StateExpiredError",
|
|
61
74
|
"StateManager",
|
|
75
|
+
"StateManagerDep",
|
|
76
|
+
"StateManagerProxy",
|
|
62
77
|
"add_routes",
|
|
63
78
|
"cache",
|
|
64
79
|
"default_key_builder",
|
|
80
|
+
"get_app_cache",
|
|
65
81
|
"get_cache_backend",
|
|
66
82
|
"get_optional_session",
|
|
67
83
|
"get_session",
|
|
68
84
|
"get_session_manager",
|
|
85
|
+
"get_state_manager",
|
|
86
|
+
"invalidate",
|
|
69
87
|
"require_session",
|
|
70
88
|
]
|
|
@@ -4,18 +4,18 @@ from abc import ABC
|
|
|
4
4
|
from abc import abstractmethod
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
|
-
from fastapi_cachex.types import
|
|
7
|
+
from fastapi_cachex.types import CacheEntry
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class BaseCacheBackend(ABC):
|
|
11
11
|
"""Base class for all cache backends."""
|
|
12
12
|
|
|
13
13
|
@abstractmethod
|
|
14
|
-
async def get(self, key: str) ->
|
|
14
|
+
async def get(self, key: str) -> CacheEntry | None:
|
|
15
15
|
"""Retrieve a cached response."""
|
|
16
16
|
|
|
17
17
|
@abstractmethod
|
|
18
|
-
async def set(self, key: str, value:
|
|
18
|
+
async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None:
|
|
19
19
|
"""Store a response in the cache."""
|
|
20
20
|
|
|
21
21
|
@abstractmethod
|
|
@@ -5,7 +5,7 @@ import logging
|
|
|
5
5
|
import warnings
|
|
6
6
|
|
|
7
7
|
from fastapi_cachex.exceptions import CacheXError
|
|
8
|
-
from fastapi_cachex.types import
|
|
8
|
+
from fastapi_cachex.types import CacheEntry
|
|
9
9
|
|
|
10
10
|
from .base import BaseCacheBackend
|
|
11
11
|
|
|
@@ -63,18 +63,17 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
63
63
|
"""Add prefix to cache key."""
|
|
64
64
|
return f"{self.key_prefix}{key}"
|
|
65
65
|
|
|
66
|
-
async def get(self, key: str) ->
|
|
66
|
+
async def get(self, key: str) -> CacheEntry | None:
|
|
67
67
|
"""Get value from cache.
|
|
68
68
|
|
|
69
69
|
Args:
|
|
70
70
|
key: Cache key to retrieve
|
|
71
71
|
|
|
72
72
|
Returns:
|
|
73
|
-
|
|
73
|
+
Cached entry if found, None otherwise
|
|
74
74
|
"""
|
|
75
75
|
prefixed_key = self._make_key(key)
|
|
76
|
-
|
|
77
|
-
value = await loop.run_in_executor(None, self.client.get, prefixed_key)
|
|
76
|
+
value = await asyncio.to_thread(self.client.get, prefixed_key)
|
|
78
77
|
if value is None:
|
|
79
78
|
logger.debug("Memcached MISS; key=%s", key)
|
|
80
79
|
return None
|
|
@@ -83,8 +82,8 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
83
82
|
try:
|
|
84
83
|
data = json.loads(value)
|
|
85
84
|
logger.debug("Memcached HIT; key=%s", key)
|
|
86
|
-
return
|
|
87
|
-
|
|
85
|
+
return CacheEntry(
|
|
86
|
+
fingerprint=data["fingerprint"],
|
|
88
87
|
content=data["content"].encode("latin-1"),
|
|
89
88
|
media_type=data.get("media_type"),
|
|
90
89
|
)
|
|
@@ -92,12 +91,12 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
92
91
|
logger.debug("Memcached DESERIALIZE ERROR; key=%s", key)
|
|
93
92
|
return None
|
|
94
93
|
|
|
95
|
-
async def set(self, key: str, value:
|
|
94
|
+
async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None:
|
|
96
95
|
"""Set value in cache.
|
|
97
96
|
|
|
98
97
|
Args:
|
|
99
98
|
key: Cache key
|
|
100
|
-
value:
|
|
99
|
+
value: CacheEntry instance to store
|
|
101
100
|
ttl: Time to live in seconds
|
|
102
101
|
"""
|
|
103
102
|
prefixed_key = self._make_key(key)
|
|
@@ -107,7 +106,7 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
107
106
|
|
|
108
107
|
serialized_data: str | bytes = json.dumps(
|
|
109
108
|
{
|
|
110
|
-
"
|
|
109
|
+
"fingerprint": value.fingerprint,
|
|
111
110
|
"content": content,
|
|
112
111
|
"media_type": value.media_type,
|
|
113
112
|
},
|
|
@@ -121,11 +120,7 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
121
120
|
)
|
|
122
121
|
|
|
123
122
|
expire = ttl if ttl is not None else 0
|
|
124
|
-
|
|
125
|
-
await loop.run_in_executor(
|
|
126
|
-
None,
|
|
127
|
-
lambda: self.client.set(prefixed_key, serialized_bytes, expire=expire),
|
|
128
|
-
)
|
|
123
|
+
await asyncio.to_thread(self.client.set, prefixed_key, serialized_bytes, expire)
|
|
129
124
|
logger.debug("Memcached SET; key=%s ttl=%s", key, ttl)
|
|
130
125
|
|
|
131
126
|
async def delete(self, key: str) -> None:
|
|
@@ -135,8 +130,7 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
135
130
|
key: Cache key to delete
|
|
136
131
|
"""
|
|
137
132
|
prefixed = self._make_key(key)
|
|
138
|
-
|
|
139
|
-
await loop.run_in_executor(None, self.client.delete, prefixed)
|
|
133
|
+
await asyncio.to_thread(self.client.delete, prefixed)
|
|
140
134
|
logger.debug("Memcached DELETE; key=%s", key)
|
|
141
135
|
|
|
142
136
|
async def clear(self) -> None:
|
|
@@ -152,8 +146,7 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
152
146
|
RuntimeWarning,
|
|
153
147
|
stacklevel=2,
|
|
154
148
|
)
|
|
155
|
-
|
|
156
|
-
await loop.run_in_executor(None, self.client.flush_all)
|
|
149
|
+
await asyncio.to_thread(self.client.flush_all)
|
|
157
150
|
logger.debug("Memcached CLEAR; flush_all issued")
|
|
158
151
|
|
|
159
152
|
async def clear_path(self, path: str, include_params: bool = False) -> int:
|
|
@@ -183,11 +176,9 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
183
176
|
|
|
184
177
|
# Try to delete the prefixed key (exact match only)
|
|
185
178
|
prefixed_key = self._make_key(path)
|
|
186
|
-
loop = asyncio.get_running_loop()
|
|
187
179
|
try:
|
|
188
|
-
result = await
|
|
189
|
-
|
|
190
|
-
lambda: self.client.delete(prefixed_key, noreply=False),
|
|
180
|
+
result = await asyncio.to_thread(
|
|
181
|
+
self.client.delete, prefixed_key, noreply=False
|
|
191
182
|
)
|
|
192
183
|
except Exception: # noqa: BLE001
|
|
193
184
|
return 0
|
|
@@ -245,7 +236,7 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
245
236
|
logger.debug("Memcached GET_ALL_KEYS unsupported; returning empty list")
|
|
246
237
|
return []
|
|
247
238
|
|
|
248
|
-
async def get_cache_data(self) -> dict[str, tuple[
|
|
239
|
+
async def get_cache_data(self) -> dict[str, tuple[CacheEntry, float | None]]:
|
|
249
240
|
"""Get all cache data with expiry information.
|
|
250
241
|
|
|
251
242
|
Note: Memcached does not support key enumeration or pattern matching.
|
|
@@ -6,8 +6,8 @@ import logging
|
|
|
6
6
|
import time
|
|
7
7
|
|
|
8
8
|
from fastapi_cachex.types import CACHE_KEY_SEPARATOR
|
|
9
|
+
from fastapi_cachex.types import CacheEntry
|
|
9
10
|
from fastapi_cachex.types import CacheItem
|
|
10
|
-
from fastapi_cachex.types import ETagContent
|
|
11
11
|
|
|
12
12
|
from .base import BaseCacheBackend
|
|
13
13
|
|
|
@@ -68,7 +68,7 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
68
68
|
self._cleanup_task = None
|
|
69
69
|
logger.debug("Stopped memory backend cleanup task")
|
|
70
70
|
|
|
71
|
-
async def get(self, key: str) ->
|
|
71
|
+
async def get(self, key: str) -> CacheEntry | None:
|
|
72
72
|
"""Retrieve a cached response.
|
|
73
73
|
|
|
74
74
|
Expired entries are skipped and return None.
|
|
@@ -89,7 +89,7 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
89
89
|
logger.debug("Memory cache MISS; key=%s", key)
|
|
90
90
|
return None
|
|
91
91
|
|
|
92
|
-
async def set(self, key: str, value:
|
|
92
|
+
async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None:
|
|
93
93
|
"""Store a response in the cache.
|
|
94
94
|
|
|
95
95
|
Args:
|
|
@@ -183,6 +183,11 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
183
183
|
if fnmatch.fnmatch(cache_path, pattern):
|
|
184
184
|
keys_to_delete.append(key)
|
|
185
185
|
cleared_count += 1
|
|
186
|
+
elif fnmatch.fnmatch(key, pattern):
|
|
187
|
+
# Non-HTTP-cache key (no separators, e.g. CacheManager/
|
|
188
|
+
# StateManager keys) - match against the raw key.
|
|
189
|
+
keys_to_delete.append(key)
|
|
190
|
+
cleared_count += 1
|
|
186
191
|
|
|
187
192
|
for key in keys_to_delete:
|
|
188
193
|
del self.cache[key]
|
|
@@ -201,11 +206,11 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
201
206
|
async with self.lock:
|
|
202
207
|
return list(self.cache.keys())
|
|
203
208
|
|
|
204
|
-
async def get_cache_data(self) -> dict[str, tuple[
|
|
209
|
+
async def get_cache_data(self) -> dict[str, tuple[CacheEntry, float | None]]:
|
|
205
210
|
"""Get all cache data with expiry information.
|
|
206
211
|
|
|
207
212
|
Returns:
|
|
208
|
-
Dictionary mapping cache keys to (
|
|
213
|
+
Dictionary mapping cache keys to (CacheEntry, expiry) tuples
|
|
209
214
|
"""
|
|
210
215
|
async with self.lock:
|
|
211
216
|
return {key: (item.value, item.expiry) for key, item in self.cache.items()}
|
|
@@ -11,7 +11,7 @@ from fastapi_cachex.backends.config import (
|
|
|
11
11
|
from fastapi_cachex.backends.config import RedisConfig
|
|
12
12
|
from fastapi_cachex.exceptions import CacheXError
|
|
13
13
|
from fastapi_cachex.types import CACHE_KEY_SEPARATOR
|
|
14
|
-
from fastapi_cachex.types import
|
|
14
|
+
from fastapi_cachex.types import CacheEntry
|
|
15
15
|
|
|
16
16
|
from .base import BaseCacheBackend
|
|
17
17
|
|
|
@@ -125,14 +125,14 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
125
125
|
"""Add prefix to cache key."""
|
|
126
126
|
return f"{self.key_prefix}{key}"
|
|
127
127
|
|
|
128
|
-
def _serialize(self, value:
|
|
129
|
-
"""Serialize
|
|
128
|
+
def _serialize(self, value: CacheEntry) -> str:
|
|
129
|
+
"""Serialize CacheEntry to JSON string."""
|
|
130
130
|
# Use latin-1 to round-trip arbitrary bytes through JSON/UTF-8 Redis storage
|
|
131
131
|
content = value.content.decode("latin-1")
|
|
132
132
|
|
|
133
133
|
serialized: str | bytes = json.dumps(
|
|
134
134
|
{
|
|
135
|
-
"
|
|
135
|
+
"fingerprint": value.fingerprint,
|
|
136
136
|
"content": content,
|
|
137
137
|
"media_type": value.media_type,
|
|
138
138
|
},
|
|
@@ -141,8 +141,8 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
141
141
|
# orjson returns bytes, stdlib json returns str
|
|
142
142
|
return serialized.decode() if isinstance(serialized, bytes) else serialized
|
|
143
143
|
|
|
144
|
-
def _deserialize(self, value: str | None) ->
|
|
145
|
-
"""Deserialize JSON string to
|
|
144
|
+
def _deserialize(self, value: str | None) -> CacheEntry | None:
|
|
145
|
+
"""Deserialize JSON string to CacheEntry.
|
|
146
146
|
|
|
147
147
|
Converts string content back to bytes to maintain consistency with
|
|
148
148
|
other backends and standard Response.body type (bytes).
|
|
@@ -152,22 +152,22 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
152
152
|
try:
|
|
153
153
|
data = json.loads(value)
|
|
154
154
|
logger.debug("Content type in JSON: %s", type(data["content"]))
|
|
155
|
-
return
|
|
156
|
-
|
|
155
|
+
return CacheEntry(
|
|
156
|
+
fingerprint=data["fingerprint"],
|
|
157
157
|
content=data["content"].encode("latin-1"),
|
|
158
158
|
media_type=data.get("media_type"),
|
|
159
159
|
)
|
|
160
160
|
except (json.JSONDecodeError, KeyError, AttributeError):
|
|
161
161
|
return None
|
|
162
162
|
|
|
163
|
-
async def get(self, key: str) ->
|
|
163
|
+
async def get(self, key: str) -> CacheEntry | None:
|
|
164
164
|
"""Retrieve a cached response."""
|
|
165
165
|
result = await self.client.get(self._make_key(key))
|
|
166
166
|
value = self._deserialize(result)
|
|
167
167
|
logger.debug("Redis %s; key=%s", "HIT" if value else "MISS", key)
|
|
168
168
|
return value
|
|
169
169
|
|
|
170
|
-
async def set(self, key: str, value:
|
|
170
|
+
async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None:
|
|
171
171
|
"""Store a response in the cache."""
|
|
172
172
|
serialized = self._serialize(value)
|
|
173
173
|
prefixed_key = self._make_key(key)
|
|
@@ -327,7 +327,7 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
327
327
|
"""Get all cache keys in the backend.
|
|
328
328
|
|
|
329
329
|
Returns:
|
|
330
|
-
List of
|
|
330
|
+
List of logical cache keys (without the backend key prefix)
|
|
331
331
|
"""
|
|
332
332
|
pattern = f"{self.key_prefix}*"
|
|
333
333
|
cursor = 0
|
|
@@ -346,34 +346,34 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
346
346
|
if cursor == 0:
|
|
347
347
|
break
|
|
348
348
|
|
|
349
|
-
|
|
350
|
-
|
|
349
|
+
logical_keys = [k.removeprefix(self.key_prefix) for k in all_keys]
|
|
350
|
+
logger.debug("Redis GET_ALL_KEYS; count=%s", len(logical_keys))
|
|
351
|
+
return logical_keys
|
|
351
352
|
|
|
352
|
-
async def get_cache_data(self) -> dict[str, tuple[
|
|
353
|
+
async def get_cache_data(self) -> dict[str, tuple[CacheEntry, float | None]]:
|
|
353
354
|
"""Get all cache data with expiry information.
|
|
354
355
|
|
|
355
356
|
Returns:
|
|
356
|
-
Dictionary mapping cache keys to (
|
|
357
|
+
Dictionary mapping cache keys to (CacheEntry, expiry) tuples.
|
|
357
358
|
Note: Redis stores TTL but not absolute expiry time, so this
|
|
358
359
|
returns None for expiry (no expiry tracking in Redis backend).
|
|
359
360
|
"""
|
|
360
361
|
all_keys = await self.get_all_keys()
|
|
361
|
-
cache_data: dict[str, tuple[
|
|
362
|
+
cache_data: dict[str, tuple[CacheEntry, float | None]] = {}
|
|
362
363
|
|
|
363
364
|
if not all_keys:
|
|
364
365
|
return cache_data
|
|
365
366
|
|
|
366
367
|
# Fetch all values in a single pipeline round-trip instead of N+1 GETs
|
|
367
368
|
pipe = self.client.pipeline()
|
|
368
|
-
for
|
|
369
|
-
pipe.get(
|
|
369
|
+
for key in all_keys:
|
|
370
|
+
pipe.get(self._make_key(key))
|
|
370
371
|
raw_values: list[str | None] = await pipe.execute()
|
|
371
372
|
|
|
372
|
-
for
|
|
373
|
-
original_key = prefixed_key.removeprefix(self.key_prefix)
|
|
373
|
+
for key, raw in zip(all_keys, raw_values, strict=False):
|
|
374
374
|
value = self._deserialize(raw)
|
|
375
375
|
if value is not None:
|
|
376
|
-
cache_data[
|
|
376
|
+
cache_data[key] = (value, None)
|
|
377
377
|
|
|
378
378
|
logger.debug("Redis GET_CACHE_DATA; keys=%s", len(cache_data))
|
|
379
379
|
return cache_data
|
|
@@ -25,8 +25,8 @@ from .exceptions import CacheXError
|
|
|
25
25
|
from .exceptions import RequestNotFoundError
|
|
26
26
|
from .proxy import BackendProxy
|
|
27
27
|
from .types import CACHE_KEY_SEPARATOR
|
|
28
|
+
from .types import CacheEntry
|
|
28
29
|
from .types import CacheKeyBuilder
|
|
29
|
-
from .types import ETagContent
|
|
30
30
|
|
|
31
31
|
if TYPE_CHECKING:
|
|
32
32
|
from fastapi.routing import APIRoute
|
|
@@ -61,6 +61,44 @@ def default_key_builder(request: Request) -> str:
|
|
|
61
61
|
return key
|
|
62
62
|
|
|
63
63
|
|
|
64
|
+
async def invalidate(
|
|
65
|
+
request: Request,
|
|
66
|
+
key_builder: CacheKeyBuilder | None = None,
|
|
67
|
+
) -> bool:
|
|
68
|
+
"""Invalidate the cache entry a ``@cache``-decorated route would use.
|
|
69
|
+
|
|
70
|
+
Builds the same cache key the ``@cache`` decorator would build for
|
|
71
|
+
``request`` (via ``key_builder`` or ``default_key_builder``) and deletes
|
|
72
|
+
it from the configured backend. Use this after a mutation to bust the
|
|
73
|
+
cache for a specific cached route response.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
request: The request whose cache key should be invalidated. Typically
|
|
77
|
+
a request to the same route/method as the cached one (e.g. build
|
|
78
|
+
it via ``request.app.url_path_for(...)`` for a GET route).
|
|
79
|
+
key_builder: Custom key builder used by the target route's ``@cache``
|
|
80
|
+
decorator, if any. If None, uses ``default_key_builder``.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
True if a cache entry existed and was deleted, False otherwise.
|
|
84
|
+
"""
|
|
85
|
+
builder = key_builder or default_key_builder
|
|
86
|
+
cache_key = builder(request)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
cache_backend = BackendProxy.get()
|
|
90
|
+
except BackendNotFoundError:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
existing = await cache_backend.get(cache_key)
|
|
94
|
+
if existing is None:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
await cache_backend.delete(cache_key)
|
|
98
|
+
logger.debug("Cache INVALIDATE; key=%s", cache_key)
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
|
|
64
102
|
class CacheControl:
|
|
65
103
|
"""Manages Cache-Control header directives."""
|
|
66
104
|
|
|
@@ -85,6 +123,11 @@ class CacheControl:
|
|
|
85
123
|
return ", ".join(self.directives)
|
|
86
124
|
|
|
87
125
|
|
|
126
|
+
def _get_response_body(response: Response) -> bytes | None:
|
|
127
|
+
"""Return response body bytes, or None for streaming/file responses."""
|
|
128
|
+
return getattr(response, "body", None)
|
|
129
|
+
|
|
130
|
+
|
|
88
131
|
async def get_response(
|
|
89
132
|
__func: HandlerCallable,
|
|
90
133
|
__request: Request,
|
|
@@ -151,10 +194,16 @@ def cache(
|
|
|
151
194
|
"""
|
|
152
195
|
|
|
153
196
|
def decorator(func: HandlerCallable) -> AsyncResponseCallable:
|
|
154
|
-
# Validate
|
|
197
|
+
# Validate parameters eagerly at decoration time
|
|
155
198
|
if stale is not None and stale_ttl is None:
|
|
156
199
|
msg = "stale_ttl must be set if stale is used"
|
|
157
200
|
raise CacheXError(msg)
|
|
201
|
+
if stale_ttl is not None and stale is None:
|
|
202
|
+
msg = "stale must be set if stale_ttl is used"
|
|
203
|
+
raise CacheXError(msg)
|
|
204
|
+
if public and private:
|
|
205
|
+
msg = "public and private are mutually exclusive"
|
|
206
|
+
raise CacheXError(msg)
|
|
158
207
|
|
|
159
208
|
# Analyze the original function's signature
|
|
160
209
|
sig: Signature = inspect.signature(func)
|
|
@@ -266,14 +315,12 @@ def cache(
|
|
|
266
315
|
if no_cache:
|
|
267
316
|
# Get fresh response first if using no-cache
|
|
268
317
|
current_response = await get_response(func, req, *args, **kwargs)
|
|
269
|
-
current_body =
|
|
318
|
+
current_body = _get_response_body(current_response)
|
|
270
319
|
if current_body is None:
|
|
271
320
|
# StreamingResponse/FileResponse — cannot compute ETag; serve as-is
|
|
272
321
|
current_response.headers["Cache-Control"] = cache_control
|
|
273
322
|
return current_response
|
|
274
|
-
current_etag = (
|
|
275
|
-
f'W/"{hashlib.md5(current_body).hexdigest()}"' # noqa: S324
|
|
276
|
-
)
|
|
323
|
+
current_etag = f'W/"{hashlib.md5(current_body).hexdigest()}"' # noqa: S324
|
|
277
324
|
|
|
278
325
|
if client_etag == current_etag:
|
|
279
326
|
# For no-cache, compare fresh data with client's ETag
|
|
@@ -287,9 +334,7 @@ def cache(
|
|
|
287
334
|
)
|
|
288
335
|
|
|
289
336
|
# Compare with cached ETag - if match, return 304
|
|
290
|
-
elif
|
|
291
|
-
cached_data and client_etag == cached_data.etag
|
|
292
|
-
): # pragma: no branch
|
|
337
|
+
elif cached_data and client_etag == cached_data.fingerprint:
|
|
293
338
|
# Cache hit with matching ETag: return 304 Not Modified
|
|
294
339
|
logger.debug(
|
|
295
340
|
"304 Not Modified (cached ETag match); key=%s", cache_key
|
|
@@ -297,7 +342,7 @@ def cache(
|
|
|
297
342
|
return Response(
|
|
298
343
|
status_code=HTTP_304_NOT_MODIFIED,
|
|
299
344
|
headers={
|
|
300
|
-
"ETag": cached_data.
|
|
345
|
+
"ETag": cached_data.fingerprint,
|
|
301
346
|
"Cache-Control": cache_control,
|
|
302
347
|
},
|
|
303
348
|
)
|
|
@@ -313,7 +358,7 @@ def cache(
|
|
|
313
358
|
status_code=200,
|
|
314
359
|
media_type=cached_data.media_type,
|
|
315
360
|
headers={
|
|
316
|
-
"ETag": cached_data.
|
|
361
|
+
"ETag": cached_data.fingerprint,
|
|
317
362
|
"Cache-Control": cache_control,
|
|
318
363
|
},
|
|
319
364
|
)
|
|
@@ -321,7 +366,7 @@ def cache(
|
|
|
321
366
|
if not current_response or not current_etag:
|
|
322
367
|
# Retrieve the current response if not already done
|
|
323
368
|
current_response = await get_response(func, req, *args, **kwargs)
|
|
324
|
-
current_body =
|
|
369
|
+
current_body = _get_response_body(current_response)
|
|
325
370
|
if current_body is None:
|
|
326
371
|
# StreamingResponse/FileResponse — cannot compute ETag; serve as-is
|
|
327
372
|
current_response.headers["Cache-Control"] = cache_control
|
|
@@ -333,17 +378,17 @@ def cache(
|
|
|
333
378
|
current_response.headers["ETag"] = current_etag
|
|
334
379
|
|
|
335
380
|
# Update cache if needed
|
|
336
|
-
if not cached_data or cached_data.
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
381
|
+
if not cached_data or cached_data.fingerprint != current_etag:
|
|
382
|
+
assert (
|
|
383
|
+
current_body is not None
|
|
384
|
+
) # guaranteed by early-return guards above
|
|
340
385
|
# Store in cache if data changed
|
|
341
386
|
await cache_backend.set(
|
|
342
387
|
cache_key,
|
|
343
|
-
|
|
344
|
-
current_etag,
|
|
345
|
-
current_body,
|
|
346
|
-
current_response.media_type,
|
|
388
|
+
CacheEntry(
|
|
389
|
+
fingerprint=current_etag,
|
|
390
|
+
content=current_body,
|
|
391
|
+
media_type=current_response.media_type,
|
|
347
392
|
),
|
|
348
393
|
ttl=ttl,
|
|
349
394
|
)
|