fastapi-cachex 0.2.12__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 (35) hide show
  1. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/PKG-INFO +41 -1
  2. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/README.md +40 -0
  3. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/__init__.py +8 -0
  4. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/backends/base.py +3 -3
  5. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/backends/memcached.py +14 -25
  6. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/backends/memory.py +5 -5
  7. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/backends/redis.py +21 -21
  8. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/cache.py +24 -17
  9. fastapi_cachex-0.3.0/fastapi_cachex/dependencies.py +37 -0
  10. fastapi_cachex-0.3.0/fastapi_cachex/manager.py +147 -0
  11. fastapi_cachex-0.3.0/fastapi_cachex/manager_proxy.py +8 -0
  12. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/routes.py +5 -5
  13. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/session/manager.py +30 -13
  14. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/session/middleware.py +6 -1
  15. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/session/security.py +2 -3
  16. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/session/token_serializers.py +1 -1
  17. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/state/manager.py +18 -20
  18. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/types.py +5 -5
  19. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/pyproject.toml +2 -2
  20. fastapi_cachex-0.2.12/fastapi_cachex/dependencies.py +0 -16
  21. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/backends/__init__.py +0 -0
  22. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/backends/config.py +0 -0
  23. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/directives.py +0 -0
  24. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/exceptions.py +0 -0
  25. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/proxy.py +0 -0
  26. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/py.typed +0 -0
  27. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/session/__init__.py +0 -0
  28. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/session/config.py +0 -0
  29. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/session/dependencies.py +0 -0
  30. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/session/exceptions.py +0 -0
  31. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/session/models.py +0 -0
  32. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/session/proxy.py +0 -0
  33. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/state/__init__.py +0 -0
  34. {fastapi_cachex-0.2.12 → fastapi_cachex-0.3.0}/fastapi_cachex/state/exceptions.py +0 -0
  35. {fastapi_cachex-0.2.12 → 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.12
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
@@ -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):
@@ -4,8 +4,12 @@ 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 .dependencies import AppCache as AppCache
7
8
  from .dependencies import CacheBackend as CacheBackend
9
+ from .dependencies import get_app_cache as get_app_cache
8
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
9
13
  from .proxy import BackendProxy as BackendProxy
10
14
  from .routes import add_routes as add_routes
11
15
  from .session import Session as Session
@@ -38,9 +42,12 @@ _package_logger.addHandler(
38
42
  ) # Attach a NullHandler to avoid "No handler found" warnings in user applications.
39
43
 
40
44
  __all__ = [
45
+ "AppCache",
41
46
  "BackendProxy",
42
47
  "CacheBackend",
43
48
  "CacheKeyBuilder",
49
+ "CacheManager",
50
+ "CacheManagerProxy",
44
51
  "InvalidStateError",
45
52
  "Session",
46
53
  "SessionConfig",
@@ -62,6 +69,7 @@ __all__ = [
62
69
  "add_routes",
63
70
  "cache",
64
71
  "default_key_builder",
72
+ "get_app_cache",
65
73
  "get_cache_backend",
66
74
  "get_optional_session",
67
75
  "get_session",
@@ -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,12 +176,8 @@ 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),
191
- )
180
+ result = await asyncio.to_thread(self.client.delete, prefixed_key, False)
192
181
  except Exception: # noqa: BLE001
193
182
  return 0
194
183
  else:
@@ -245,7 +234,7 @@ class MemcachedBackend(BaseCacheBackend):
245
234
  logger.debug("Memcached GET_ALL_KEYS unsupported; returning empty list")
246
235
  return []
247
236
 
248
- 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]]:
249
238
  """Get all cache data with expiry information.
250
239
 
251
240
  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:
@@ -201,11 +201,11 @@ class MemoryBackend(BaseCacheBackend):
201
201
  async with self.lock:
202
202
  return list(self.cache.keys())
203
203
 
204
- 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]]:
205
205
  """Get all cache data with expiry information.
206
206
 
207
207
  Returns:
208
- Dictionary mapping cache keys to (ETagContent, expiry) tuples
208
+ Dictionary mapping cache keys to (CacheEntry, expiry) tuples
209
209
  """
210
210
  async with self.lock:
211
211
  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
@@ -85,6 +85,11 @@ class CacheControl:
85
85
  return ", ".join(self.directives)
86
86
 
87
87
 
88
+ def _get_response_body(response: Response) -> bytes | None:
89
+ """Return response body bytes, or None for streaming/file responses."""
90
+ return getattr(response, "body", None)
91
+
92
+
88
93
  async def get_response(
89
94
  __func: HandlerCallable,
90
95
  __request: Request,
@@ -151,10 +156,16 @@ def cache(
151
156
  """
152
157
 
153
158
  def decorator(func: HandlerCallable) -> AsyncResponseCallable:
154
- # Validate stale parameters eagerly at decoration time
159
+ # Validate parameters eagerly at decoration time
155
160
  if stale is not None and stale_ttl is None:
156
161
  msg = "stale_ttl must be set if stale is used"
157
162
  raise CacheXError(msg)
163
+ if stale_ttl is not None and stale is None:
164
+ msg = "stale must be set if stale_ttl is used"
165
+ raise CacheXError(msg)
166
+ if public and private:
167
+ msg = "public and private are mutually exclusive"
168
+ raise CacheXError(msg)
158
169
 
159
170
  # Analyze the original function's signature
160
171
  sig: Signature = inspect.signature(func)
@@ -266,7 +277,7 @@ def cache(
266
277
  if no_cache:
267
278
  # Get fresh response first if using no-cache
268
279
  current_response = await get_response(func, req, *args, **kwargs)
269
- current_body = getattr(current_response, "body", None)
280
+ current_body = _get_response_body(current_response)
270
281
  if current_body is None:
271
282
  # StreamingResponse/FileResponse — cannot compute ETag; serve as-is
272
283
  current_response.headers["Cache-Control"] = cache_control
@@ -287,9 +298,7 @@ def cache(
287
298
  )
288
299
 
289
300
  # Compare with cached ETag - if match, return 304
290
- elif (
291
- cached_data and client_etag == cached_data.etag
292
- ): # pragma: no branch
301
+ elif cached_data and client_etag == cached_data.fingerprint:
293
302
  # Cache hit with matching ETag: return 304 Not Modified
294
303
  logger.debug(
295
304
  "304 Not Modified (cached ETag match); key=%s", cache_key
@@ -297,7 +306,7 @@ def cache(
297
306
  return Response(
298
307
  status_code=HTTP_304_NOT_MODIFIED,
299
308
  headers={
300
- "ETag": cached_data.etag,
309
+ "ETag": cached_data.fingerprint,
301
310
  "Cache-Control": cache_control,
302
311
  },
303
312
  )
@@ -313,7 +322,7 @@ def cache(
313
322
  status_code=200,
314
323
  media_type=cached_data.media_type,
315
324
  headers={
316
- "ETag": cached_data.etag,
325
+ "ETag": cached_data.fingerprint,
317
326
  "Cache-Control": cache_control,
318
327
  },
319
328
  )
@@ -321,7 +330,7 @@ def cache(
321
330
  if not current_response or not current_etag:
322
331
  # Retrieve the current response if not already done
323
332
  current_response = await get_response(func, req, *args, **kwargs)
324
- current_body = getattr(current_response, "body", None)
333
+ current_body = _get_response_body(current_response)
325
334
  if current_body is None:
326
335
  # StreamingResponse/FileResponse — cannot compute ETag; serve as-is
327
336
  current_response.headers["Cache-Control"] = cache_control
@@ -333,17 +342,15 @@ def cache(
333
342
  current_response.headers["ETag"] = current_etag
334
343
 
335
344
  # 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)
345
+ if not cached_data or cached_data.fingerprint != current_etag:
346
+ assert current_body is not None # guaranteed by early-return guards above
340
347
  # Store in cache if data changed
341
348
  await cache_backend.set(
342
349
  cache_key,
343
- ETagContent(
344
- current_etag,
345
- current_body,
346
- current_response.media_type,
350
+ CacheEntry(
351
+ fingerprint=current_etag,
352
+ content=current_body,
353
+ media_type=current_response.media_type,
347
354
  ),
348
355
  ttl=ttl,
349
356
  )
@@ -0,0 +1,37 @@
1
+ """FastAPI dependency injection utilities for cache control."""
2
+
3
+ from typing import Annotated
4
+
5
+ from fastapi import Depends
6
+
7
+ from .backends.base import BaseCacheBackend
8
+ from .exceptions import BackendNotFoundError
9
+ from .manager import CacheManager
10
+ from .manager_proxy import CacheManagerProxy
11
+ from .proxy import BackendProxy
12
+
13
+
14
+ def get_cache_backend() -> BaseCacheBackend:
15
+ """Dependency to get the current cache backend instance."""
16
+ return BackendProxy.get()
17
+
18
+
19
+ CacheBackend = Annotated[BaseCacheBackend, Depends(get_cache_backend)]
20
+
21
+
22
+ def get_app_cache() -> CacheManager:
23
+ """Dependency to get the application CacheManager instance.
24
+
25
+ Lazily creates and registers a default CacheManager (backed by
26
+ BackendProxy) the first time it's requested, unless one was already
27
+ set via CacheManagerProxy.set(...).
28
+ """
29
+ try:
30
+ return CacheManagerProxy.get()
31
+ except BackendNotFoundError:
32
+ manager = CacheManager()
33
+ CacheManagerProxy.set(manager)
34
+ return manager
35
+
36
+
37
+ AppCache = Annotated[CacheManager, Depends(get_app_cache)]
@@ -0,0 +1,147 @@
1
+ """Generic application-level cache manager for FastAPI-CacheX."""
2
+
3
+ import hashlib
4
+ import json
5
+ import logging
6
+ from typing import Any
7
+
8
+ from .backends.base import BaseCacheBackend
9
+ from .proxy import BackendProxy
10
+ from .types import CacheEntry
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ _DECODE_ERRORS = (AttributeError, UnicodeDecodeError, json.JSONDecodeError)
15
+
16
+
17
+ class CacheManager:
18
+ """Provides convenient get/set/delete access to the configured cache backend.
19
+
20
+ Unlike the ``@cache`` decorator (which caches HTTP response bodies) or
21
+ ``StateManager``/``SessionManager`` (which manage OAuth state and sessions),
22
+ ``CacheManager`` is a thin, JSON-serializing wrapper for caching arbitrary
23
+ application values under a dedicated key namespace.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ backend: BaseCacheBackend | None = None,
29
+ key_prefix: str = "cache:",
30
+ default_ttl: int | None = None,
31
+ ) -> None:
32
+ """Initialize CacheManager.
33
+
34
+ Args:
35
+ backend: Cache backend instance. If None, uses BackendProxy.get().
36
+ key_prefix: Prefix prepended to all logical keys in the cache backend.
37
+ default_ttl: Default TTL (seconds) applied when set() is called
38
+ without an explicit ttl. None means no expiry by default.
39
+ """
40
+ self.backend = backend if backend is not None else BackendProxy.get()
41
+ self.key_prefix = key_prefix
42
+ self.default_ttl = default_ttl
43
+
44
+ def _cache_key(self, key: str) -> str:
45
+ return f"{self.key_prefix}{key}"
46
+
47
+ async def get(self, key: str, default: Any = None) -> Any:
48
+ """Retrieve and JSON-decode a cached value.
49
+
50
+ Args:
51
+ key: Logical cache key (without the manager's prefix).
52
+ default: Value returned when the key is missing, expired, or the
53
+ stored content cannot be decoded.
54
+
55
+ Returns:
56
+ The cached value, or ``default`` on a miss or decode failure.
57
+ """
58
+ cached = await self.backend.get(self._cache_key(key))
59
+ if cached is None:
60
+ return default
61
+
62
+ try:
63
+ json_content = cached.content.decode("utf-8")
64
+ return json.loads(json_content)
65
+ except _DECODE_ERRORS:
66
+ logger.warning("Failed to decode cached value; key=%s", key)
67
+ return default
68
+
69
+ async def set(self, key: str, value: Any, ttl: int | None = None) -> None:
70
+ """JSON-encode and store a value in the cache.
71
+
72
+ Args:
73
+ key: Logical cache key (without the manager's prefix).
74
+ value: A JSON-serializable Python value.
75
+ ttl: Time-to-live in seconds. If None, uses ``self.default_ttl``
76
+ (which itself defaults to no expiry).
77
+
78
+ Raises:
79
+ TypeError: If ``value`` is not JSON-serializable.
80
+ """
81
+ effective_ttl = ttl if ttl is not None else self.default_ttl
82
+
83
+ json_content = json.dumps(value)
84
+ fingerprint = hashlib.sha256(json_content.encode()).hexdigest()
85
+ entry = CacheEntry(
86
+ fingerprint=fingerprint, content=json_content.encode("utf-8")
87
+ )
88
+
89
+ await self.backend.set(self._cache_key(key), entry, ttl=effective_ttl)
90
+ logger.debug("Cache SET; key=%s ttl=%s", key, effective_ttl)
91
+
92
+ async def delete(self, key: str) -> bool:
93
+ """Remove a value from the cache.
94
+
95
+ Args:
96
+ key: Logical cache key (without the manager's prefix).
97
+
98
+ Returns:
99
+ True if the key existed and was deleted, False otherwise.
100
+ """
101
+ cache_key = self._cache_key(key)
102
+ existing = await self.backend.get(cache_key)
103
+ if existing is None:
104
+ return False
105
+ await self.backend.delete(cache_key)
106
+ logger.debug("Cache DELETE; key=%s", key)
107
+ return True
108
+
109
+ async def has(self, key: str) -> bool:
110
+ """Check whether a key exists in the cache without decoding its value.
111
+
112
+ Args:
113
+ key: Logical cache key (without the manager's prefix).
114
+
115
+ Returns:
116
+ True if the key exists and has not expired.
117
+ """
118
+ return await self.backend.get(self._cache_key(key)) is not None
119
+
120
+ async def clear_prefix(self, prefix: str | None = None) -> int:
121
+ """Clear all keys under this manager's namespace matching a sub-prefix.
122
+
123
+ Args:
124
+ prefix: Optional additional prefix (relative to ``self.key_prefix``)
125
+ to restrict which keys are cleared. If None, clears everything
126
+ under ``self.key_prefix``.
127
+
128
+ Returns:
129
+ Number of cache entries cleared.
130
+ """
131
+ match_prefix = self._cache_key(prefix or "")
132
+ keys = await self.backend.get_all_keys()
133
+ matching_keys = [key for key in keys if key.startswith(match_prefix)]
134
+ for key in matching_keys:
135
+ await self.backend.delete(key)
136
+ logger.debug(
137
+ "Cache CLEAR_PREFIX; prefix=%s removed=%s", match_prefix, len(matching_keys)
138
+ )
139
+ return len(matching_keys)
140
+
141
+ async def clear(self) -> int:
142
+ """Clear all keys under this manager's namespace.
143
+
144
+ Returns:
145
+ Number of cache entries cleared.
146
+ """
147
+ return await self.clear_prefix()
@@ -0,0 +1,8 @@
1
+ """Proxy for CacheManager singleton access."""
2
+
3
+ from .manager import CacheManager
4
+ from .proxy import ProxyBase
5
+
6
+
7
+ class CacheManagerProxy(ProxyBase[CacheManager]):
8
+ """FastAPI CacheX Proxy for CacheManager instance management."""
@@ -124,7 +124,7 @@ async def _get_cached_hits_handler(backend: BaseCacheBackend) -> CacheHitsRespon
124
124
  now = time.time()
125
125
  cached_hits: list[CacheHitRecord] = []
126
126
 
127
- for cache_key, (etag_content, expiry) in cache_data.items():
127
+ for cache_key, (entry, expiry) in cache_data.items():
128
128
  method, host, path, query_params = _parse_cache_key(cache_key)
129
129
  if method: # Valid cache key
130
130
  # Check if cache entry is expired
@@ -138,7 +138,7 @@ async def _get_cached_hits_handler(backend: BaseCacheBackend) -> CacheHitsRespon
138
138
  host=host,
139
139
  path=path,
140
140
  query_params=query_params,
141
- etag=etag_content.etag,
141
+ etag=entry.fingerprint,
142
142
  is_expired=is_expired,
143
143
  ttl_remaining=ttl_remaining,
144
144
  )
@@ -178,14 +178,14 @@ async def _get_cached_records_handler(
178
178
  now = time.time()
179
179
  cached_records: list[CachedRecord] = []
180
180
 
181
- for cache_key, (etag_content, expiry) in cache_data.items():
181
+ for cache_key, (entry, expiry) in cache_data.items():
182
182
  method, host, path, query_params = _parse_cache_key(cache_key)
183
183
  if method: # Valid cache key
184
184
  # Check if cache entry is expired
185
185
  is_expired = expiry is not None and expiry <= now
186
186
 
187
187
  # Get content size
188
- content = etag_content.content
188
+ content = entry.content
189
189
  content_size = len(content) if isinstance(content, (bytes, str)) else 0
190
190
 
191
191
  ttl_remaining = max(0.0, round(expiry - now, 2)) if expiry is not None else None
@@ -203,7 +203,7 @@ async def _get_cached_records_handler(
203
203
  host=host,
204
204
  path=path,
205
205
  query_params=query_params,
206
- etag=etag_content.etag,
206
+ etag=entry.fingerprint,
207
207
  content_type=type(content).__name__,
208
208
  content_size=content_size,
209
209
  is_expired=is_expired,
@@ -6,7 +6,7 @@ from datetime import timedelta
6
6
  from datetime import timezone
7
7
 
8
8
  from fastapi_cachex.backends.base import BaseCacheBackend
9
- from fastapi_cachex.types import ETagContent
9
+ from fastapi_cachex.types import CacheEntry
10
10
 
11
11
  from .config import SessionConfig
12
12
  from .exceptions import SessionExpiredError
@@ -130,10 +130,22 @@ class SessionManager:
130
130
  )
131
131
 
132
132
  # Bind IP and User-Agent if configured
133
- if self.config.ip_binding and ip_address:
134
- session.ip_address = ip_address
135
- if self.config.user_agent_binding and user_agent:
136
- session.user_agent = user_agent
133
+ if self.config.ip_binding:
134
+ if ip_address:
135
+ session.ip_address = ip_address
136
+ else:
137
+ logger.warning(
138
+ "ip_binding is enabled but no IP address available; "
139
+ "session created without IP binding"
140
+ )
141
+ if self.config.user_agent_binding:
142
+ if user_agent:
143
+ session.user_agent = user_agent
144
+ else:
145
+ logger.warning(
146
+ "user_agent_binding is enabled but no User-Agent available; "
147
+ "session created without UA binding"
148
+ )
137
149
 
138
150
  # Store in backend
139
151
  await self._save_session(session)
@@ -155,7 +167,7 @@ class SessionManager:
155
167
  token_string: str,
156
168
  ip_address: str | None = None,
157
169
  user_agent: str | None = None,
158
- ) -> Session:
170
+ ) -> tuple[Session, str | None]:
159
171
  """Retrieve and validate a session.
160
172
 
161
173
  Args:
@@ -164,7 +176,9 @@ class SessionManager:
164
176
  user_agent: Current request User-Agent
165
177
 
166
178
  Returns:
167
- Session object
179
+ Tuple of (session, new_token_string). new_token_string is non-None
180
+ when sliding expiration triggered a renewal; the caller should
181
+ propagate it to the client (e.g. via a response header).
168
182
 
169
183
  Raises:
170
184
  SessionTokenError: If token is invalid
@@ -260,6 +274,7 @@ class SessionManager:
260
274
  # Update last accessed and handle sliding expiration
261
275
  session.update_last_accessed()
262
276
 
277
+ renewed_token: str | None = None
263
278
  if self.config.sliding_expiration and session.expires_at:
264
279
  time_remaining = (
265
280
  session.expires_at - datetime.now(timezone.utc)
@@ -268,6 +283,10 @@ class SessionManager:
268
283
 
269
284
  if time_remaining < threshold:
270
285
  session.renew(self.config.session_ttl)
286
+ token = self._create_token(
287
+ session.session_id, expires_at=session.expires_at
288
+ )
289
+ renewed_token = self._serializer.to_string(token)
271
290
  logger.debug(
272
291
  "Session renewed (sliding expiration); id=%s ttl=%s",
273
292
  session.session_id,
@@ -276,7 +295,7 @@ class SessionManager:
276
295
 
277
296
  await self._save_session(session)
278
297
 
279
- return session
298
+ return session, renewed_token
280
299
 
281
300
  async def update_session(self, session: Session) -> None:
282
301
  """Update an existing session.
@@ -429,9 +448,8 @@ class SessionManager:
429
448
  ttl = int((session.expires_at - datetime.now(timezone.utc)).total_seconds())
430
449
  ttl = max(ttl, 1) # Ensure at least 1 second
431
450
 
432
- # Store as bytes in cache backend (wrapped in ETagContent for compatibility)
433
- etag = self.security.hash_data(value.decode("utf-8"))
434
- await self.backend.set(key, ETagContent(etag=etag, content=value), ttl=ttl)
451
+ fingerprint = self.security.hash_data(value.decode("utf-8"))
452
+ await self.backend.set(key, CacheEntry(fingerprint=fingerprint, content=value), ttl=ttl)
435
453
  logger.debug("Session saved; id=%s ttl=%s", session.session_id, ttl)
436
454
 
437
455
  async def _load_session(self, session_id: str) -> Session | None:
@@ -462,7 +480,6 @@ class SessionManager:
462
480
 
463
481
  try:
464
482
  return Session.model_validate_json(cached.content)
465
- except (ValueError, TypeError): # pragma: no cover
466
- # Invalid session data
483
+ except (ValueError, TypeError):
467
484
  logger.debug("Session load DESERIALIZE ERROR; key=%s", key)
468
485
  return None
@@ -78,11 +78,12 @@ class SessionMiddleware(BaseHTTPMiddleware):
78
78
 
79
79
  # Try to load session
80
80
  session: Session | None = None
81
+ renewed_token: str | None = None
81
82
  if token:
82
83
  try:
83
84
  ip_address = self._get_client_ip(request)
84
85
  user_agent = request.headers.get("user-agent")
85
- session = await self.session_manager.get_session(
86
+ session, renewed_token = await self.session_manager.get_session(
86
87
  token,
87
88
  ip_address=ip_address,
88
89
  user_agent=user_agent,
@@ -99,6 +100,10 @@ class SessionMiddleware(BaseHTTPMiddleware):
99
100
  # Process request
100
101
  response: Response = await call_next(request)
101
102
 
103
+ # Propagate renewed token to client so its JWT exp stays in sync
104
+ if renewed_token is not None:
105
+ response.headers[self.config.header_name] = renewed_token
106
+
102
107
  return response
103
108
 
104
109
  def _extract_token(self, request: Request) -> str | None:
@@ -21,8 +21,7 @@ class SecurityManager:
21
21
  if len(secret_key) < 32: # noqa: PLR2004
22
22
  msg = "Secret key must be at least 32 characters"
23
23
  raise ValueError(msg)
24
- # Use name mangling so the key bytes are not trivially accessible via repr
25
- self.__secret_key_bytes = secret_key.encode("utf-8")
24
+ self._secret_key_bytes = secret_key.encode("utf-8")
26
25
 
27
26
  logger.debug(
28
27
  "SecurityManager initialized with secret length=%s", len(secret_key)
@@ -38,7 +37,7 @@ class SecurityManager:
38
37
  The signature as a hex string
39
38
  """
40
39
  return hmac.new(
41
- self.__secret_key_bytes,
40
+ self._secret_key_bytes,
42
41
  session_id.encode("utf-8"),
43
42
  hashlib.sha256,
44
43
  ).hexdigest()
@@ -80,7 +80,7 @@ class SimpleTokenSerializer:
80
80
  session_id, signature, timestamp = parts
81
81
  try:
82
82
  issued_at = datetime.fromtimestamp(int(timestamp), tz=timezone.utc)
83
- except (ValueError, OSError) as e:
83
+ except (ValueError, OSError, OverflowError) as e:
84
84
  msg = f"Invalid timestamp in token: {e}"
85
85
  raise ValueError(msg) from e
86
86
 
@@ -11,7 +11,7 @@ from typing import Any
11
11
 
12
12
  from fastapi_cachex.backends.base import BaseCacheBackend
13
13
  from fastapi_cachex.proxy import BackendProxy
14
- from fastapi_cachex.types import ETagContent
14
+ from fastapi_cachex.types import CacheEntry
15
15
 
16
16
  from .exceptions import InvalidStateError
17
17
  from .exceptions import StateDataError
@@ -44,11 +44,11 @@ class StateManager:
44
44
  self.key_prefix = key_prefix
45
45
  self.default_ttl = default_ttl
46
46
 
47
- def _extract_json_content(self, cached: ETagContent) -> str:
48
- """Extract JSON string from a cached ETagContent value.
47
+ def _extract_json_content(self, cached: CacheEntry) -> str:
48
+ """Extract JSON string from a cached CacheEntry.
49
49
 
50
50
  Args:
51
- cached: The ETagContent retrieved from backend
51
+ cached: The CacheEntry retrieved from backend
52
52
 
53
53
  Returns:
54
54
  JSON string
@@ -122,13 +122,11 @@ class StateManager:
122
122
  # Serialize to JSON
123
123
  json_content = json.dumps(state_data.model_dump(mode="json"))
124
124
 
125
- # Create ETag from hash of state data
126
- etag = hashlib.sha256(json_content.encode()).hexdigest()
125
+ fingerprint = hashlib.sha256(json_content.encode()).hexdigest()
127
126
 
128
- # Store in backend with TTL using ETagContent
129
127
  cache_key = f"{self.key_prefix}{state}"
130
- etag_content = ETagContent(etag=etag, content=json_content.encode("utf-8"))
131
- await self.backend.set(cache_key, etag_content, ttl=effective_ttl)
128
+ entry = CacheEntry(fingerprint=fingerprint, content=json_content.encode("utf-8"))
129
+ await self.backend.set(cache_key, entry, ttl=effective_ttl)
132
130
 
133
131
  logger.debug("OAuth state created; state=%s ttl=%s", state, effective_ttl)
134
132
  return state
@@ -150,13 +148,13 @@ class StateManager:
150
148
  cache_key = f"{self.key_prefix}{state}"
151
149
 
152
150
  # Retrieve state data from backend
153
- cached_etag_content = await self.backend.get(cache_key)
154
- if cached_etag_content is None:
151
+ cached = await self.backend.get(cache_key)
152
+ if cached is None:
155
153
  logger.warning("OAuth state not found or expired; state=%s", state)
156
154
  msg = "Invalid or expired state"
157
155
  raise InvalidStateError(msg)
158
156
 
159
- json_content = self._extract_json_content(cached_etag_content)
157
+ json_content = self._extract_json_content(cached)
160
158
  state_data = self._parse_state_data(json_content, state)
161
159
 
162
160
  # Verify expiry
@@ -182,15 +180,15 @@ class StateManager:
182
180
  """
183
181
  cache_key = f"{self.key_prefix}{state}"
184
182
 
185
- cached_etag_content = await self.backend.get(cache_key)
186
- if cached_etag_content is None:
183
+ cached = await self.backend.get(cache_key)
184
+ if cached is None:
187
185
  logger.debug("State validation failed - not found; state=%s", state)
188
186
  return False
189
187
 
190
188
  try:
191
- json_content = self._extract_json_content(cached_etag_content)
189
+ json_content = self._extract_json_content(cached)
192
190
  state_data = self._parse_state_data(json_content, state)
193
- except (StateDataError, UnicodeDecodeError):
191
+ except StateDataError:
194
192
  logger.exception(
195
193
  "Failed to parse or validate state data; state=%s",
196
194
  state,
@@ -215,14 +213,14 @@ class StateManager:
215
213
  """
216
214
  cache_key = f"{self.key_prefix}{state}"
217
215
 
218
- cached_etag_content = await self.backend.get(cache_key)
219
- if cached_etag_content is None:
216
+ cached = await self.backend.get(cache_key)
217
+ if cached is None:
220
218
  return None
221
219
 
222
220
  try:
223
- json_content = self._extract_json_content(cached_etag_content)
221
+ json_content = self._extract_json_content(cached)
224
222
  state_data = self._parse_state_data(json_content, state)
225
- except (StateDataError, UnicodeDecodeError):
223
+ except StateDataError:
226
224
  logger.exception("Failed to parse or validate state data; state=%s", state)
227
225
  return None
228
226
 
@@ -13,10 +13,10 @@ CacheKeyBuilder = Callable[[Request], str]
13
13
 
14
14
 
15
15
  @dataclass
16
- class ETagContent:
17
- """ETag and content for cache items."""
16
+ class CacheEntry:
17
+ """Cache entry storing a fingerprint, raw content bytes, and an optional media type."""
18
18
 
19
- etag: str
19
+ fingerprint: str
20
20
  content: bytes
21
21
  media_type: str | None = None
22
22
 
@@ -26,9 +26,9 @@ class CacheItem:
26
26
  """Cache item with optional expiry time.
27
27
 
28
28
  Args:
29
- value: The cached ETagContent
29
+ value: The cached entry
30
30
  expiry: Epoch timestamp when this cache item expires (None = never expires)
31
31
  """
32
32
 
33
- value: ETagContent
33
+ value: CacheEntry
34
34
  expiry: float | None = None
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-cachex"
3
- version = "0.2.12"
3
+ version = "0.3.0"
4
4
  description = "A caching library for FastAPI with support for Cache-Control, ETag, and multiple backends."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -60,7 +60,7 @@ redis = ["redis[hiredis]>=5.3.0", "orjson"]
60
60
  jwt = ["PyJWT>=2.9.0"]
61
61
 
62
62
  [build-system]
63
- requires = ["uv_build>=0.9.17,<0.11.0"]
63
+ requires = ["uv_build>=0.11.26,<0.12"]
64
64
  build-backend = "uv_build"
65
65
 
66
66
  [tool.uv.build-backend]
@@ -1,16 +0,0 @@
1
- """FastAPI dependency injection utilities for cache control."""
2
-
3
- from typing import Annotated
4
-
5
- from fastapi import Depends
6
-
7
- from .backends.base import BaseCacheBackend
8
- from .proxy import BackendProxy
9
-
10
-
11
- def get_cache_backend() -> BaseCacheBackend:
12
- """Dependency to get the current cache backend instance."""
13
- return BackendProxy.get()
14
-
15
-
16
- CacheBackend = Annotated[BaseCacheBackend, Depends(get_cache_backend)]