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.
Files changed (37) hide show
  1. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/PKG-INFO +42 -2
  2. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/README.md +40 -0
  3. fastapi_cachex-0.3.0/fastapi_cachex/__init__.py +78 -0
  4. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/backends/base.py +3 -3
  5. fastapi_cachex-0.3.0/fastapi_cachex/backends/config.py +40 -0
  6. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/backends/memcached.py +23 -27
  7. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/backends/memory.py +15 -13
  8. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/backends/redis.py +52 -36
  9. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/cache.py +60 -33
  10. fastapi_cachex-0.3.0/fastapi_cachex/dependencies.py +37 -0
  11. fastapi_cachex-0.3.0/fastapi_cachex/manager.py +147 -0
  12. fastapi_cachex-0.3.0/fastapi_cachex/manager_proxy.py +8 -0
  13. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/proxy.py +2 -1
  14. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/routes.py +30 -13
  15. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/session/config.py +0 -4
  16. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/session/manager.py +59 -19
  17. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/session/middleware.py +6 -1
  18. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/session/models.py +1 -0
  19. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/session/security.py +2 -2
  20. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/session/token_serializers.py +6 -2
  21. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/state/manager.py +75 -91
  22. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/types.py +7 -7
  23. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/pyproject.toml +5 -5
  24. fastapi_cachex-0.2.11/fastapi_cachex/__init__.py +0 -27
  25. fastapi_cachex-0.2.11/fastapi_cachex/backends/config.py +0 -15
  26. fastapi_cachex-0.2.11/fastapi_cachex/dependencies.py +0 -16
  27. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/backends/__init__.py +0 -0
  28. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/directives.py +0 -0
  29. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/exceptions.py +0 -0
  30. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/py.typed +0 -0
  31. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/session/__init__.py +0 -0
  32. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/session/dependencies.py +0 -0
  33. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/session/exceptions.py +0 -0
  34. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/session/proxy.py +0 -0
  35. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/state/__init__.py +0 -0
  36. {fastapi_cachex-0.2.11 → fastapi_cachex-0.3.0}/fastapi_cachex/state/exceptions.py +0 -0
  37. {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.2.11
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 ETagContent
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) -> ETagContent | None:
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: ETagContent, ttl: int | None = None) -> None:
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 ETagContent
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) -> ETagContent | None:
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
- Optional[ETagContent]: Cached value with ETag if exists, None otherwise
73
+ Cached entry if found, None otherwise
73
74
  """
74
75
  prefixed_key = self._make_key(key)
75
- value = self.client.get(prefixed_key)
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.decode("utf-8"))
83
+ data = json.loads(value)
83
84
  logger.debug("Memcached HIT; key=%s", key)
84
- return ETagContent(
85
- etag=data["etag"],
86
- content=data["content"].encode()
87
- if isinstance(data["content"], str)
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: ETagContent, ttl: int | None = None) -> None:
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: ETagContent to store
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
- # Prepare content for JSON serialization
105
- if isinstance(value.content, bytes):
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
- "etag": value.etag,
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
- self.client.set(
125
- prefixed_key,
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.client.delete(self._make_key(key))
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(prefixed_key, noreply=False)
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[ETagContent, float | None]]:
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
- with contextlib.suppress(RuntimeError):
48
- # No event loop yet; will be created on first async operation
49
- self._cleanup_task = asyncio.create_task(self._cleanup_task_impl())
50
- logger.debug(
51
- "Started memory backend cleanup task (interval=%s)",
52
- self.cleanup_interval,
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) -> ETagContent | None:
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: ETagContent, ttl: int | None = None) -> None:
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[ETagContent, float | None]]:
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 (ETagContent, expiry) tuples
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 ETagContent
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: ETagContent) -> str:
114
- """Serialize ETagContent to JSON string."""
115
- if isinstance(value.content, bytes):
116
- content = value.content.decode()
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
- "etag": value.etag,
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) -> ETagContent | None:
131
- """Deserialize JSON string to ETagContent.
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 ETagContent(
142
- etag=data["etag"],
143
- content=data["content"].encode()
144
- if isinstance(data["content"], str)
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) -> ETagContent | None:
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: ETagContent, ttl: int | None = None) -> None:
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
- if ttl is not None:
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 all cache keys currently stored in the backend
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
- logger.debug("Redis GET_ALL_KEYS; count=%s", len(all_keys))
340
- return all_keys
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[ETagContent, float | None]]:
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 (ETagContent, expiry) tuples.
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[ETagContent, float | None]] = {}
362
+ cache_data: dict[str, tuple[CacheEntry, float | None]] = {}
363
+
364
+ if not all_keys:
365
+ return cache_data
352
366
 
353
- for prefixed_key in all_keys:
354
- # Remove prefix to get the original cache key
355
- original_key = prefixed_key.removeprefix(self.key_prefix)
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
- # Get the value using the original key (get() adds prefix internally)
358
- value = await self.get(original_key)
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[original_key] = (value, None)
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