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.
Files changed (38) hide show
  1. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/PKG-INFO +43 -1
  2. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/README.md +40 -0
  3. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/__init__.py +18 -0
  4. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/base.py +3 -3
  5. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/memcached.py +15 -24
  6. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/memory.py +10 -5
  7. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/redis.py +21 -21
  8. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/cache.py +65 -20
  9. fastapi_cachex-0.3.1/fastapi_cachex/dependencies.py +37 -0
  10. fastapi_cachex-0.3.1/fastapi_cachex/manager.py +211 -0
  11. fastapi_cachex-0.3.1/fastapi_cachex/manager_proxy.py +8 -0
  12. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/routes.py +11 -7
  13. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/session/__init__.py +2 -0
  14. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/session/config.py +35 -0
  15. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/session/manager.py +35 -16
  16. fastapi_cachex-0.3.1/fastapi_cachex/session/middleware.py +485 -0
  17. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/session/security.py +2 -3
  18. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/session/token_serializers.py +1 -1
  19. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/state/__init__.py +3 -0
  20. fastapi_cachex-0.3.1/fastapi_cachex/state/dependencies.py +28 -0
  21. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/state/manager.py +20 -20
  22. fastapi_cachex-0.3.1/fastapi_cachex/state/proxy.py +9 -0
  23. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/types.py +5 -5
  24. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/pyproject.toml +6 -2
  25. fastapi_cachex-0.2.12/fastapi_cachex/dependencies.py +0 -16
  26. fastapi_cachex-0.2.12/fastapi_cachex/session/middleware.py +0 -159
  27. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/__init__.py +0 -0
  28. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/backends/config.py +0 -0
  29. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/directives.py +0 -0
  30. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/exceptions.py +0 -0
  31. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/proxy.py +0 -0
  32. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/py.typed +0 -0
  33. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/session/dependencies.py +0 -0
  34. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/session/exceptions.py +0 -0
  35. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/session/models.py +0 -0
  36. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/session/proxy.py +0 -0
  37. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.1}/fastapi_cachex/state/exceptions.py +0 -0
  38. {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.2.12
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 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
@@ -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 ETagContent
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) -> ETagContent | None:
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
- Optional[ETagContent]: Cached value with ETag if exists, None otherwise
73
+ Cached entry if found, None otherwise
74
74
  """
75
75
  prefixed_key = self._make_key(key)
76
- loop = asyncio.get_running_loop()
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 ETagContent(
87
- etag=data["etag"],
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: ETagContent, ttl: int | None = None) -> None:
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: ETagContent to store
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
- "etag": value.etag,
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
- loop = asyncio.get_running_loop()
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
- loop = asyncio.get_running_loop()
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
- loop = asyncio.get_running_loop()
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 loop.run_in_executor(
189
- None,
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[ETagContent, float | None]]:
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) -> ETagContent | None:
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: ETagContent, ttl: int | None = None) -> None:
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[ETagContent, float | None]]:
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 (ETagContent, expiry) tuples
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 ETagContent
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: ETagContent) -> str:
129
- """Serialize ETagContent to JSON string."""
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
- "etag": value.etag,
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) -> ETagContent | None:
145
- """Deserialize JSON string to ETagContent.
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 ETagContent(
156
- etag=data["etag"],
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) -> ETagContent | None:
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: ETagContent, ttl: int | None = None) -> None:
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 all cache keys currently stored in the backend
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
- logger.debug("Redis GET_ALL_KEYS; count=%s", len(all_keys))
350
- 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
351
352
 
352
- 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]]:
353
354
  """Get all cache data with expiry information.
354
355
 
355
356
  Returns:
356
- Dictionary mapping cache keys to (ETagContent, expiry) tuples.
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[ETagContent, float | None]] = {}
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 prefixed_key in all_keys:
369
- pipe.get(prefixed_key)
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 prefixed_key, raw in zip(all_keys, raw_values, strict=False):
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[original_key] = (value, None)
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 stale parameters eagerly at decoration time
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 = getattr(current_response, "body", None)
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.etag,
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.etag,
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 = getattr(current_response, "body", None)
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.etag != current_etag:
337
- if current_body is None: # pragma: no cover - guaranteed by earlier guards
338
- msg = "Unexpected state: response body unavailable after ETag computation"
339
- raise CacheXError(msg)
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
- ETagContent(
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
  )