fastapi-cachex 0.2.11__tar.gz → 0.3.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.2.11 → fastapi_cachex-0.3.0}/PKG-INFO +42 -2
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/README.md +40 -0
- fastapi_cachex-0.3.0/fastapi_cachex/__init__.py +78 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/backends/base.py +3 -3
- fastapi_cachex-0.3.0/fastapi_cachex/backends/config.py +40 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/backends/memcached.py +23 -27
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/backends/memory.py +15 -13
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/backends/redis.py +52 -36
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/cache.py +60 -33
- fastapi_cachex-0.3.0/fastapi_cachex/dependencies.py +37 -0
- fastapi_cachex-0.3.0/fastapi_cachex/manager.py +147 -0
- fastapi_cachex-0.3.0/fastapi_cachex/manager_proxy.py +8 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/proxy.py +2 -1
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/routes.py +30 -13
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/session/config.py +0 -4
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/session/manager.py +59 -19
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/session/middleware.py +6 -1
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/session/models.py +1 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/session/security.py +2 -2
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/session/token_serializers.py +6 -2
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/state/manager.py +75 -91
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/types.py +7 -7
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/pyproject.toml +5 -5
- fastapi_cachex-0.2.11/fastapi_cachex/__init__.py +0 -27
- fastapi_cachex-0.2.11/fastapi_cachex/backends/config.py +0 -15
- fastapi_cachex-0.2.11/fastapi_cachex/dependencies.py +0 -16
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/backends/__init__.py +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/directives.py +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/exceptions.py +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/py.typed +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/session/__init__.py +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/session/dependencies.py +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/session/exceptions.py +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/session/proxy.py +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/state/__init__.py +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/state/exceptions.py +0 -0
- {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/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.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
|
|
@@ -23,7 +23,7 @@ Requires-Dist: fastapi
|
|
|
23
23
|
Requires-Dist: pydantic
|
|
24
24
|
Requires-Dist: pyjwt>=2.9.0 ; extra == 'jwt'
|
|
25
25
|
Requires-Dist: pymemcache ; extra == 'memcache'
|
|
26
|
-
Requires-Dist: redis[hiredis] ; extra == 'redis'
|
|
26
|
+
Requires-Dist: redis[hiredis]>=5.3.0 ; extra == 'redis'
|
|
27
27
|
Requires-Dist: orjson ; extra == 'redis'
|
|
28
28
|
Requires-Python: >=3.10
|
|
29
29
|
Project-URL: Homepage, https://github.com/allen0099/FastAPI-CacheX
|
|
@@ -146,6 +146,44 @@ async def remove_cache(cache: CacheBackend):
|
|
|
146
146
|
await cache.clear_pattern("/path/to/clear/*") # Clear cache for a specific pattern
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
+
### Application-Level Caching (Manual Get/Set)
|
|
150
|
+
|
|
151
|
+
Beyond HTTP response caching via `@cache`, you can cache arbitrary JSON-serializable
|
|
152
|
+
Python values directly in your business logic using `CacheManager`. It's a thin,
|
|
153
|
+
namespaced wrapper around whichever backend is configured via `BackendProxy`.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from fastapi_cachex import AppCache, CacheManager
|
|
157
|
+
|
|
158
|
+
@app.get("/expensive")
|
|
159
|
+
async def expensive_operation(cache: AppCache):
|
|
160
|
+
result = await cache.get("expensive:result")
|
|
161
|
+
if result is None:
|
|
162
|
+
result = perform_expensive_calculation()
|
|
163
|
+
await cache.set("expensive:result", result, ttl=300)
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# Or instantiate directly, e.g. outside of a request:
|
|
168
|
+
manager = CacheManager(key_prefix="myapp:", default_ttl=60)
|
|
169
|
+
await manager.set("user:42", {"name": "Alice"})
|
|
170
|
+
user = await manager.get("user:42") # {"name": "Alice"}
|
|
171
|
+
await manager.delete("user:42")
|
|
172
|
+
await manager.clear_prefix() # clear everything under "myapp:"
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`CacheManager.get()` returns `None` (or a supplied `default=`) on a cache miss —
|
|
176
|
+
it never raises for missing or corrupted entries. `CacheManager` keys live under
|
|
177
|
+
their own `cache:`-prefixed namespace by default, separate from the HTTP route
|
|
178
|
+
cache and OAuth state, so `clear()`/`clear_prefix()` never touch unrelated cache
|
|
179
|
+
entries.
|
|
180
|
+
|
|
181
|
+
**Note**: `clear()`/`clear_prefix()` are implemented via the backend's
|
|
182
|
+
`get_all_keys()`. Since Memcached doesn't support key enumeration (see
|
|
183
|
+
[Memcached limitations](#memcached)), these two methods are no-ops on a
|
|
184
|
+
Memcached backend — `get()`/`set()`/`delete()`/`has()` work normally. Use
|
|
185
|
+
Redis or the in-memory backend if you need bulk clearing.
|
|
186
|
+
|
|
149
187
|
## Backend Configuration
|
|
150
188
|
|
|
151
189
|
FastAPI-CacheX supports multiple caching backends. You can easily switch between them using the `BackendProxy`.
|
|
@@ -166,6 +204,8 @@ This ensures that:
|
|
|
166
204
|
|
|
167
205
|
All backends automatically namespace keys with a prefix (e.g., `fastapi_cachex:`) to avoid conflicts with other applications.
|
|
168
206
|
|
|
207
|
+
`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.
|
|
208
|
+
|
|
169
209
|
### Cache Hit Behavior
|
|
170
210
|
|
|
171
211
|
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):
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""FastAPI-CacheX: A powerful and flexible caching extension for FastAPI."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from .cache import cache as cache
|
|
6
|
+
from .cache import default_key_builder as default_key_builder
|
|
7
|
+
from .dependencies import AppCache as AppCache
|
|
8
|
+
from .dependencies import CacheBackend as CacheBackend
|
|
9
|
+
from .dependencies import get_app_cache as get_app_cache
|
|
10
|
+
from .dependencies import get_cache_backend as get_cache_backend
|
|
11
|
+
from .manager import CacheManager as CacheManager
|
|
12
|
+
from .manager_proxy import CacheManagerProxy as CacheManagerProxy
|
|
13
|
+
from .proxy import BackendProxy as BackendProxy
|
|
14
|
+
from .routes import add_routes as add_routes
|
|
15
|
+
from .session import Session as Session
|
|
16
|
+
from .session import SessionConfig as SessionConfig
|
|
17
|
+
from .session import SessionManager as SessionManager
|
|
18
|
+
from .session import SessionManagerProxy as SessionManagerProxy
|
|
19
|
+
from .session import SessionMiddleware as SessionMiddleware
|
|
20
|
+
from .session import SessionUser as SessionUser
|
|
21
|
+
from .session import get_optional_session as get_optional_session
|
|
22
|
+
from .session import get_session as get_session
|
|
23
|
+
from .session import get_session_manager as get_session_manager
|
|
24
|
+
from .session import require_session as require_session
|
|
25
|
+
from .session.exceptions import SessionError as SessionError
|
|
26
|
+
from .session.exceptions import SessionExpiredError as SessionExpiredError
|
|
27
|
+
from .session.exceptions import SessionInvalidError as SessionInvalidError
|
|
28
|
+
from .session.exceptions import SessionNotFoundError as SessionNotFoundError
|
|
29
|
+
from .session.exceptions import SessionSecurityError as SessionSecurityError
|
|
30
|
+
from .session.exceptions import SessionTokenError as SessionTokenError
|
|
31
|
+
from .state import InvalidStateError as InvalidStateError
|
|
32
|
+
from .state import StateData as StateData
|
|
33
|
+
from .state import StateDataError as StateDataError
|
|
34
|
+
from .state import StateError as StateError
|
|
35
|
+
from .state import StateExpiredError as StateExpiredError
|
|
36
|
+
from .state import StateManager as StateManager
|
|
37
|
+
from .types import CacheKeyBuilder as CacheKeyBuilder
|
|
38
|
+
|
|
39
|
+
_package_logger = logging.getLogger("fastapi_cachex")
|
|
40
|
+
_package_logger.addHandler(
|
|
41
|
+
logging.NullHandler()
|
|
42
|
+
) # Attach a NullHandler to avoid "No handler found" warnings in user applications.
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"AppCache",
|
|
46
|
+
"BackendProxy",
|
|
47
|
+
"CacheBackend",
|
|
48
|
+
"CacheKeyBuilder",
|
|
49
|
+
"CacheManager",
|
|
50
|
+
"CacheManagerProxy",
|
|
51
|
+
"InvalidStateError",
|
|
52
|
+
"Session",
|
|
53
|
+
"SessionConfig",
|
|
54
|
+
"SessionError",
|
|
55
|
+
"SessionExpiredError",
|
|
56
|
+
"SessionInvalidError",
|
|
57
|
+
"SessionManager",
|
|
58
|
+
"SessionManagerProxy",
|
|
59
|
+
"SessionMiddleware",
|
|
60
|
+
"SessionNotFoundError",
|
|
61
|
+
"SessionSecurityError",
|
|
62
|
+
"SessionTokenError",
|
|
63
|
+
"SessionUser",
|
|
64
|
+
"StateData",
|
|
65
|
+
"StateDataError",
|
|
66
|
+
"StateError",
|
|
67
|
+
"StateExpiredError",
|
|
68
|
+
"StateManager",
|
|
69
|
+
"add_routes",
|
|
70
|
+
"cache",
|
|
71
|
+
"default_key_builder",
|
|
72
|
+
"get_app_cache",
|
|
73
|
+
"get_cache_backend",
|
|
74
|
+
"get_optional_session",
|
|
75
|
+
"get_session",
|
|
76
|
+
"get_session_manager",
|
|
77
|
+
"require_session",
|
|
78
|
+
]
|
|
@@ -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
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Configuration models for cache backends."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
from pydantic import SecretStr
|
|
6
|
+
|
|
7
|
+
DEFAULT_REDIS_PREFIX = "fastapi_cachex:"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RedisConfig(BaseModel):
|
|
11
|
+
"""Configuration for Redis backend."""
|
|
12
|
+
|
|
13
|
+
host: str = Field(default="localhost", description="Redis server address")
|
|
14
|
+
port: int = Field(default=6379, ge=1, le=65535, description="Redis server port")
|
|
15
|
+
password: SecretStr | None = Field(
|
|
16
|
+
default=None, description="Redis server password"
|
|
17
|
+
)
|
|
18
|
+
db: int = Field(default=0, ge=0, description="Redis database number")
|
|
19
|
+
encoding: str = Field(default="utf-8", description="Character encoding to use")
|
|
20
|
+
socket_timeout: float = Field(
|
|
21
|
+
default=1.0, description="Timeout for socket operations in seconds"
|
|
22
|
+
)
|
|
23
|
+
socket_connect_timeout: float = Field(
|
|
24
|
+
default=1.0, description="Timeout for socket connection in seconds"
|
|
25
|
+
)
|
|
26
|
+
key_prefix: str = Field(
|
|
27
|
+
default=DEFAULT_REDIS_PREFIX,
|
|
28
|
+
description="Prefix applied to all cache keys",
|
|
29
|
+
)
|
|
30
|
+
protocol: int = Field(
|
|
31
|
+
default=2,
|
|
32
|
+
ge=2,
|
|
33
|
+
le=3,
|
|
34
|
+
description=(
|
|
35
|
+
"RESP protocol version (2 or 3). "
|
|
36
|
+
"Keep at 2 (default) unless you need RESP3 features and your hiredis "
|
|
37
|
+
"version supports it (hiredis >= 3.0 is required for RESP3). "
|
|
38
|
+
"Redis 8.0 supports RESP3 but older hiredis builds do not."
|
|
39
|
+
),
|
|
40
|
+
)
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"""Memcached cache backend implementation."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import logging
|
|
4
5
|
import warnings
|
|
5
6
|
|
|
6
7
|
from fastapi_cachex.exceptions import CacheXError
|
|
7
|
-
from fastapi_cachex.types import
|
|
8
|
+
from fastapi_cachex.types import CacheEntry
|
|
8
9
|
|
|
9
10
|
from .base import BaseCacheBackend
|
|
10
11
|
|
|
@@ -62,55 +63,52 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
62
63
|
"""Add prefix to cache key."""
|
|
63
64
|
return f"{self.key_prefix}{key}"
|
|
64
65
|
|
|
65
|
-
async def get(self, key: str) ->
|
|
66
|
+
async def get(self, key: str) -> CacheEntry | None:
|
|
66
67
|
"""Get value from cache.
|
|
67
68
|
|
|
68
69
|
Args:
|
|
69
70
|
key: Cache key to retrieve
|
|
70
71
|
|
|
71
72
|
Returns:
|
|
72
|
-
|
|
73
|
+
Cached entry if found, None otherwise
|
|
73
74
|
"""
|
|
74
75
|
prefixed_key = self._make_key(key)
|
|
75
|
-
value = self.client.get
|
|
76
|
+
value = await asyncio.to_thread(self.client.get, prefixed_key)
|
|
76
77
|
if value is None:
|
|
77
78
|
logger.debug("Memcached MISS; key=%s", key)
|
|
78
79
|
return None
|
|
79
80
|
|
|
80
81
|
# Memcached stores data as bytes; deserialize from JSON
|
|
81
82
|
try:
|
|
82
|
-
data = json.loads(value
|
|
83
|
+
data = json.loads(value)
|
|
83
84
|
logger.debug("Memcached HIT; key=%s", key)
|
|
84
|
-
return
|
|
85
|
-
|
|
86
|
-
content=data["content"].encode()
|
|
87
|
-
|
|
88
|
-
else data["content"],
|
|
85
|
+
return CacheEntry(
|
|
86
|
+
fingerprint=data["fingerprint"],
|
|
87
|
+
content=data["content"].encode("latin-1"),
|
|
88
|
+
media_type=data.get("media_type"),
|
|
89
89
|
)
|
|
90
90
|
except (json.JSONDecodeError, KeyError, ValueError):
|
|
91
91
|
logger.debug("Memcached DESERIALIZE ERROR; key=%s", key)
|
|
92
92
|
return None
|
|
93
93
|
|
|
94
|
-
async def set(self, key: str, value:
|
|
94
|
+
async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None:
|
|
95
95
|
"""Set value in cache.
|
|
96
96
|
|
|
97
97
|
Args:
|
|
98
98
|
key: Cache key
|
|
99
|
-
value:
|
|
99
|
+
value: CacheEntry instance to store
|
|
100
100
|
ttl: Time to live in seconds
|
|
101
101
|
"""
|
|
102
102
|
prefixed_key = self._make_key(key)
|
|
103
103
|
|
|
104
|
-
#
|
|
105
|
-
|
|
106
|
-
content = value.content.decode()
|
|
107
|
-
else:
|
|
108
|
-
content = value.content
|
|
104
|
+
# Use latin-1 to round-trip arbitrary bytes through JSON storage
|
|
105
|
+
content = value.content.decode("latin-1")
|
|
109
106
|
|
|
110
107
|
serialized_data: str | bytes = json.dumps(
|
|
111
108
|
{
|
|
112
|
-
"
|
|
109
|
+
"fingerprint": value.fingerprint,
|
|
113
110
|
"content": content,
|
|
111
|
+
"media_type": value.media_type,
|
|
114
112
|
},
|
|
115
113
|
)
|
|
116
114
|
|
|
@@ -121,11 +119,8 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
121
119
|
else serialized_data.encode("utf-8")
|
|
122
120
|
)
|
|
123
121
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
serialized_bytes,
|
|
127
|
-
expire=ttl if ttl is not None else 0,
|
|
128
|
-
)
|
|
122
|
+
expire = ttl if ttl is not None else 0
|
|
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:
|
|
@@ -134,7 +129,8 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
134
129
|
Args:
|
|
135
130
|
key: Cache key to delete
|
|
136
131
|
"""
|
|
137
|
-
self.
|
|
132
|
+
prefixed = self._make_key(key)
|
|
133
|
+
await asyncio.to_thread(self.client.delete, prefixed)
|
|
138
134
|
logger.debug("Memcached DELETE; key=%s", key)
|
|
139
135
|
|
|
140
136
|
async def clear(self) -> None:
|
|
@@ -150,7 +146,7 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
150
146
|
RuntimeWarning,
|
|
151
147
|
stacklevel=2,
|
|
152
148
|
)
|
|
153
|
-
self.client.flush_all
|
|
149
|
+
await asyncio.to_thread(self.client.flush_all)
|
|
154
150
|
logger.debug("Memcached CLEAR; flush_all issued")
|
|
155
151
|
|
|
156
152
|
async def clear_path(self, path: str, include_params: bool = False) -> int:
|
|
@@ -181,7 +177,7 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
181
177
|
# Try to delete the prefixed key (exact match only)
|
|
182
178
|
prefixed_key = self._make_key(path)
|
|
183
179
|
try:
|
|
184
|
-
result = self.client.delete
|
|
180
|
+
result = await asyncio.to_thread(self.client.delete, prefixed_key, False)
|
|
185
181
|
except Exception: # noqa: BLE001
|
|
186
182
|
return 0
|
|
187
183
|
else:
|
|
@@ -238,7 +234,7 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
238
234
|
logger.debug("Memcached GET_ALL_KEYS unsupported; returning empty list")
|
|
239
235
|
return []
|
|
240
236
|
|
|
241
|
-
async def get_cache_data(self) -> dict[str, tuple[
|
|
237
|
+
async def get_cache_data(self) -> dict[str, tuple[CacheEntry, float | None]]:
|
|
242
238
|
"""Get all cache data with expiry information.
|
|
243
239
|
|
|
244
240
|
Note: Memcached does not support key enumeration or pattern matching.
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
"""In-memory cache backend implementation."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
import contextlib
|
|
5
4
|
import fnmatch
|
|
6
5
|
import logging
|
|
7
6
|
import time
|
|
8
7
|
|
|
9
8
|
from fastapi_cachex.types import CACHE_KEY_SEPARATOR
|
|
9
|
+
from fastapi_cachex.types import CacheEntry
|
|
10
10
|
from fastapi_cachex.types import CacheItem
|
|
11
|
-
from fastapi_cachex.types import ETagContent
|
|
12
11
|
|
|
13
12
|
from .base import BaseCacheBackend
|
|
14
13
|
|
|
@@ -44,13 +43,16 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
44
43
|
def _ensure_cleanup_started(self) -> None:
|
|
45
44
|
"""Ensure cleanup task is started in proper async context."""
|
|
46
45
|
if self._cleanup_task is None or self._cleanup_task.done():
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
46
|
+
try:
|
|
47
|
+
loop = asyncio.get_running_loop()
|
|
48
|
+
except RuntimeError:
|
|
49
|
+
# No running event loop yet; defer until first real async call.
|
|
50
|
+
return
|
|
51
|
+
self._cleanup_task = loop.create_task(self._cleanup_task_impl())
|
|
52
|
+
logger.debug(
|
|
53
|
+
"Started memory backend cleanup task (interval=%s)",
|
|
54
|
+
self.cleanup_interval,
|
|
55
|
+
)
|
|
54
56
|
|
|
55
57
|
def start_cleanup(self) -> None:
|
|
56
58
|
"""Start the cleanup task if it's not already running.
|
|
@@ -66,7 +68,7 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
66
68
|
self._cleanup_task = None
|
|
67
69
|
logger.debug("Stopped memory backend cleanup task")
|
|
68
70
|
|
|
69
|
-
async def get(self, key: str) ->
|
|
71
|
+
async def get(self, key: str) -> CacheEntry | None:
|
|
70
72
|
"""Retrieve a cached response.
|
|
71
73
|
|
|
72
74
|
Expired entries are skipped and return None.
|
|
@@ -87,7 +89,7 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
87
89
|
logger.debug("Memory cache MISS; key=%s", key)
|
|
88
90
|
return None
|
|
89
91
|
|
|
90
|
-
async def set(self, key: str, value:
|
|
92
|
+
async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None:
|
|
91
93
|
"""Store a response in the cache.
|
|
92
94
|
|
|
93
95
|
Args:
|
|
@@ -199,11 +201,11 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
199
201
|
async with self.lock:
|
|
200
202
|
return list(self.cache.keys())
|
|
201
203
|
|
|
202
|
-
async def get_cache_data(self) -> dict[str, tuple[
|
|
204
|
+
async def get_cache_data(self) -> dict[str, tuple[CacheEntry, float | None]]:
|
|
203
205
|
"""Get all cache data with expiry information.
|
|
204
206
|
|
|
205
207
|
Returns:
|
|
206
|
-
Dictionary mapping cache keys to (
|
|
208
|
+
Dictionary mapping cache keys to (CacheEntry, expiry) tuples
|
|
207
209
|
"""
|
|
208
210
|
async with self.lock:
|
|
209
211
|
return {key: (item.value, item.expiry) for key, item in self.cache.items()}
|
|
@@ -5,10 +5,13 @@ from typing import TYPE_CHECKING
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
from typing import Literal
|
|
7
7
|
|
|
8
|
+
from fastapi_cachex.backends.config import (
|
|
9
|
+
DEFAULT_REDIS_PREFIX as DEFAULT_REDIS_PREFIX, # noqa: PLC0414
|
|
10
|
+
)
|
|
8
11
|
from fastapi_cachex.backends.config import RedisConfig
|
|
9
12
|
from fastapi_cachex.exceptions import CacheXError
|
|
10
13
|
from fastapi_cachex.types import CACHE_KEY_SEPARATOR
|
|
11
|
-
from fastapi_cachex.types import
|
|
14
|
+
from fastapi_cachex.types import CacheEntry
|
|
12
15
|
|
|
13
16
|
from .base import BaseCacheBackend
|
|
14
17
|
|
|
@@ -23,8 +26,7 @@ except ImportError: # pragma: no cover
|
|
|
23
26
|
|
|
24
27
|
logger = logging.getLogger(__name__)
|
|
25
28
|
|
|
26
|
-
# Default Redis key prefix for fastapi-cachex
|
|
27
|
-
DEFAULT_REDIS_PREFIX = "fastapi_cachex:"
|
|
29
|
+
# Default Redis key prefix for fastapi-cachex — re-exported from config for convenience
|
|
28
30
|
|
|
29
31
|
|
|
30
32
|
class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
@@ -48,6 +50,7 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
48
50
|
socket_timeout: float = 1.0,
|
|
49
51
|
socket_connect_timeout: float = 1.0,
|
|
50
52
|
key_prefix: str = DEFAULT_REDIS_PREFIX,
|
|
53
|
+
protocol: int = 2,
|
|
51
54
|
**kwargs: Any,
|
|
52
55
|
) -> None:
|
|
53
56
|
"""Initialize async Redis cache backend.
|
|
@@ -62,6 +65,9 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
62
65
|
socket_timeout: Timeout for socket operations (in seconds)
|
|
63
66
|
socket_connect_timeout: Timeout for socket connection (in seconds)
|
|
64
67
|
key_prefix: Prefix for all cache keys (default: 'fastapi_cachex:')
|
|
68
|
+
protocol: RESP protocol version (2 or 3). Defaults to 2 (RESP2) for
|
|
69
|
+
broadest compatibility. Use 3 only when hiredis >= 3.0 is installed
|
|
70
|
+
and Redis 8.0+ RESP3 features are required.
|
|
65
71
|
**kwargs: Additional arguments to pass to Redis client
|
|
66
72
|
"""
|
|
67
73
|
try:
|
|
@@ -76,6 +82,9 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
76
82
|
)
|
|
77
83
|
raise CacheXError(msg)
|
|
78
84
|
|
|
85
|
+
# `protocol` is not in the types-redis stubs (added in redis-py 5.x).
|
|
86
|
+
# Pass it via **kwargs so mypy doesn't complain about an unknown keyword.
|
|
87
|
+
kwargs.setdefault("protocol", protocol)
|
|
79
88
|
self.client = AsyncRedis(
|
|
80
89
|
host=host,
|
|
81
90
|
port=port,
|
|
@@ -104,31 +113,36 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
104
113
|
password=config.password.get_secret_value()
|
|
105
114
|
if config.password is not None
|
|
106
115
|
else None,
|
|
116
|
+
db=config.db,
|
|
117
|
+
encoding=config.encoding,
|
|
118
|
+
socket_timeout=config.socket_timeout,
|
|
119
|
+
socket_connect_timeout=config.socket_connect_timeout,
|
|
120
|
+
key_prefix=config.key_prefix,
|
|
121
|
+
protocol=config.protocol,
|
|
107
122
|
)
|
|
108
123
|
|
|
109
124
|
def _make_key(self, key: str) -> str:
|
|
110
125
|
"""Add prefix to cache key."""
|
|
111
126
|
return f"{self.key_prefix}{key}"
|
|
112
127
|
|
|
113
|
-
def _serialize(self, value:
|
|
114
|
-
"""Serialize
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
else:
|
|
118
|
-
content = value.content
|
|
128
|
+
def _serialize(self, value: CacheEntry) -> str:
|
|
129
|
+
"""Serialize CacheEntry to JSON string."""
|
|
130
|
+
# Use latin-1 to round-trip arbitrary bytes through JSON/UTF-8 Redis storage
|
|
131
|
+
content = value.content.decode("latin-1")
|
|
119
132
|
|
|
120
133
|
serialized: str | bytes = json.dumps(
|
|
121
134
|
{
|
|
122
|
-
"
|
|
135
|
+
"fingerprint": value.fingerprint,
|
|
123
136
|
"content": content,
|
|
137
|
+
"media_type": value.media_type,
|
|
124
138
|
},
|
|
125
139
|
)
|
|
126
140
|
|
|
127
141
|
# orjson returns bytes, stdlib json returns str
|
|
128
142
|
return serialized.decode() if isinstance(serialized, bytes) else serialized
|
|
129
143
|
|
|
130
|
-
def _deserialize(self, value: str | None) ->
|
|
131
|
-
"""Deserialize JSON string to
|
|
144
|
+
def _deserialize(self, value: str | None) -> CacheEntry | None:
|
|
145
|
+
"""Deserialize JSON string to CacheEntry.
|
|
132
146
|
|
|
133
147
|
Converts string content back to bytes to maintain consistency with
|
|
134
148
|
other backends and standard Response.body type (bytes).
|
|
@@ -138,30 +152,26 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
138
152
|
try:
|
|
139
153
|
data = json.loads(value)
|
|
140
154
|
logger.debug("Content type in JSON: %s", type(data["content"]))
|
|
141
|
-
return
|
|
142
|
-
|
|
143
|
-
content=data["content"].encode()
|
|
144
|
-
|
|
145
|
-
else data["content"],
|
|
155
|
+
return CacheEntry(
|
|
156
|
+
fingerprint=data["fingerprint"],
|
|
157
|
+
content=data["content"].encode("latin-1"),
|
|
158
|
+
media_type=data.get("media_type"),
|
|
146
159
|
)
|
|
147
|
-
except (json.JSONDecodeError, KeyError):
|
|
160
|
+
except (json.JSONDecodeError, KeyError, AttributeError):
|
|
148
161
|
return None
|
|
149
162
|
|
|
150
|
-
async def get(self, key: str) ->
|
|
163
|
+
async def get(self, key: str) -> CacheEntry | None:
|
|
151
164
|
"""Retrieve a cached response."""
|
|
152
165
|
result = await self.client.get(self._make_key(key))
|
|
153
166
|
value = self._deserialize(result)
|
|
154
167
|
logger.debug("Redis %s; key=%s", "HIT" if value else "MISS", key)
|
|
155
168
|
return value
|
|
156
169
|
|
|
157
|
-
async def set(self, key: str, value:
|
|
170
|
+
async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None:
|
|
158
171
|
"""Store a response in the cache."""
|
|
159
172
|
serialized = self._serialize(value)
|
|
160
173
|
prefixed_key = self._make_key(key)
|
|
161
|
-
|
|
162
|
-
await self.client.setex(prefixed_key, ttl, serialized)
|
|
163
|
-
else:
|
|
164
|
-
await self.client.set(prefixed_key, serialized)
|
|
174
|
+
await self.client.set(prefixed_key, serialized, ex=ttl)
|
|
165
175
|
logger.debug("Redis SET; key=%s ttl=%s", key, ttl)
|
|
166
176
|
|
|
167
177
|
async def delete(self, key: str) -> None:
|
|
@@ -317,7 +327,7 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
317
327
|
"""Get all cache keys in the backend.
|
|
318
328
|
|
|
319
329
|
Returns:
|
|
320
|
-
List of
|
|
330
|
+
List of logical cache keys (without the backend key prefix)
|
|
321
331
|
"""
|
|
322
332
|
pattern = f"{self.key_prefix}*"
|
|
323
333
|
cursor = 0
|
|
@@ -336,28 +346,34 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
336
346
|
if cursor == 0:
|
|
337
347
|
break
|
|
338
348
|
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
341
352
|
|
|
342
|
-
async def get_cache_data(self) -> dict[str, tuple[
|
|
353
|
+
async def get_cache_data(self) -> dict[str, tuple[CacheEntry, float | None]]:
|
|
343
354
|
"""Get all cache data with expiry information.
|
|
344
355
|
|
|
345
356
|
Returns:
|
|
346
|
-
Dictionary mapping cache keys to (
|
|
357
|
+
Dictionary mapping cache keys to (CacheEntry, expiry) tuples.
|
|
347
358
|
Note: Redis stores TTL but not absolute expiry time, so this
|
|
348
359
|
returns None for expiry (no expiry tracking in Redis backend).
|
|
349
360
|
"""
|
|
350
361
|
all_keys = await self.get_all_keys()
|
|
351
|
-
cache_data: dict[str, tuple[
|
|
362
|
+
cache_data: dict[str, tuple[CacheEntry, float | None]] = {}
|
|
363
|
+
|
|
364
|
+
if not all_keys:
|
|
365
|
+
return cache_data
|
|
352
366
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
367
|
+
# Fetch all values in a single pipeline round-trip instead of N+1 GETs
|
|
368
|
+
pipe = self.client.pipeline()
|
|
369
|
+
for key in all_keys:
|
|
370
|
+
pipe.get(self._make_key(key))
|
|
371
|
+
raw_values: list[str | None] = await pipe.execute()
|
|
356
372
|
|
|
357
|
-
|
|
358
|
-
value =
|
|
373
|
+
for key, raw in zip(all_keys, raw_values, strict=False):
|
|
374
|
+
value = self._deserialize(raw)
|
|
359
375
|
if value is not None:
|
|
360
|
-
cache_data[
|
|
376
|
+
cache_data[key] = (value, None)
|
|
361
377
|
|
|
362
378
|
logger.debug("Redis GET_CACHE_DATA; keys=%s", len(cache_data))
|
|
363
379
|
return cache_data
|