fastapi-cachex 0.2.11__tar.gz → 0.2.12__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 (34) hide show
  1. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/PKG-INFO +2 -2
  2. fastapi_cachex-0.2.12/fastapi_cachex/__init__.py +70 -0
  3. fastapi_cachex-0.2.12/fastapi_cachex/backends/config.py +40 -0
  4. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/backends/memcached.py +24 -17
  5. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/backends/memory.py +10 -8
  6. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/backends/redis.py +34 -18
  7. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/cache.py +46 -26
  8. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/proxy.py +2 -1
  9. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/routes.py +25 -8
  10. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/session/config.py +0 -4
  11. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/session/manager.py +29 -6
  12. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/session/models.py +1 -0
  13. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/session/security.py +3 -2
  14. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/session/token_serializers.py +5 -1
  15. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/state/manager.py +66 -80
  16. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/types.py +2 -2
  17. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/pyproject.toml +5 -5
  18. fastapi_cachex-0.2.11/fastapi_cachex/__init__.py +0 -27
  19. fastapi_cachex-0.2.11/fastapi_cachex/backends/config.py +0 -15
  20. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/README.md +0 -0
  21. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/backends/__init__.py +0 -0
  22. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/backends/base.py +0 -0
  23. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/dependencies.py +0 -0
  24. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/directives.py +0 -0
  25. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/exceptions.py +0 -0
  26. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/py.typed +0 -0
  27. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/session/__init__.py +0 -0
  28. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/session/dependencies.py +0 -0
  29. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/session/exceptions.py +0 -0
  30. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/session/middleware.py +0 -0
  31. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/session/proxy.py +0 -0
  32. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/state/__init__.py +0 -0
  33. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/fastapi_cachex/state/exceptions.py +0 -0
  34. {fastapi_cachex-0.2.11 → fastapi_cachex-0.2.12}/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.2.12
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
@@ -0,0 +1,70 @@
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 CacheBackend as CacheBackend
8
+ from .dependencies import get_cache_backend as get_cache_backend
9
+ from .proxy import BackendProxy as BackendProxy
10
+ from .routes import add_routes as add_routes
11
+ from .session import Session as Session
12
+ from .session import SessionConfig as SessionConfig
13
+ from .session import SessionManager as SessionManager
14
+ from .session import SessionManagerProxy as SessionManagerProxy
15
+ from .session import SessionMiddleware as SessionMiddleware
16
+ from .session import SessionUser as SessionUser
17
+ from .session import get_optional_session as get_optional_session
18
+ from .session import get_session as get_session
19
+ from .session import get_session_manager as get_session_manager
20
+ from .session import require_session as require_session
21
+ from .session.exceptions import SessionError as SessionError
22
+ from .session.exceptions import SessionExpiredError as SessionExpiredError
23
+ from .session.exceptions import SessionInvalidError as SessionInvalidError
24
+ from .session.exceptions import SessionNotFoundError as SessionNotFoundError
25
+ from .session.exceptions import SessionSecurityError as SessionSecurityError
26
+ from .session.exceptions import SessionTokenError as SessionTokenError
27
+ from .state import InvalidStateError as InvalidStateError
28
+ from .state import StateData as StateData
29
+ from .state import StateDataError as StateDataError
30
+ from .state import StateError as StateError
31
+ from .state import StateExpiredError as StateExpiredError
32
+ from .state import StateManager as StateManager
33
+ from .types import CacheKeyBuilder as CacheKeyBuilder
34
+
35
+ _package_logger = logging.getLogger("fastapi_cachex")
36
+ _package_logger.addHandler(
37
+ logging.NullHandler()
38
+ ) # Attach a NullHandler to avoid "No handler found" warnings in user applications.
39
+
40
+ __all__ = [
41
+ "BackendProxy",
42
+ "CacheBackend",
43
+ "CacheKeyBuilder",
44
+ "InvalidStateError",
45
+ "Session",
46
+ "SessionConfig",
47
+ "SessionError",
48
+ "SessionExpiredError",
49
+ "SessionInvalidError",
50
+ "SessionManager",
51
+ "SessionManagerProxy",
52
+ "SessionMiddleware",
53
+ "SessionNotFoundError",
54
+ "SessionSecurityError",
55
+ "SessionTokenError",
56
+ "SessionUser",
57
+ "StateData",
58
+ "StateDataError",
59
+ "StateError",
60
+ "StateExpiredError",
61
+ "StateManager",
62
+ "add_routes",
63
+ "cache",
64
+ "default_key_builder",
65
+ "get_cache_backend",
66
+ "get_optional_session",
67
+ "get_session",
68
+ "get_session_manager",
69
+ "require_session",
70
+ ]
@@ -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,5 +1,6 @@
1
1
  """Memcached cache backend implementation."""
2
2
 
3
+ import asyncio
3
4
  import logging
4
5
  import warnings
5
6
 
@@ -72,20 +73,20 @@ class MemcachedBackend(BaseCacheBackend):
72
73
  Optional[ETagContent]: Cached value with ETag if exists, None otherwise
73
74
  """
74
75
  prefixed_key = self._make_key(key)
75
- value = self.client.get(prefixed_key)
76
+ loop = asyncio.get_running_loop()
77
+ value = await loop.run_in_executor(None, self.client.get, prefixed_key)
76
78
  if value is None:
77
79
  logger.debug("Memcached MISS; key=%s", key)
78
80
  return None
79
81
 
80
82
  # Memcached stores data as bytes; deserialize from JSON
81
83
  try:
82
- data = json.loads(value.decode("utf-8"))
84
+ data = json.loads(value)
83
85
  logger.debug("Memcached HIT; key=%s", key)
84
86
  return ETagContent(
85
87
  etag=data["etag"],
86
- content=data["content"].encode()
87
- if isinstance(data["content"], str)
88
- else data["content"],
88
+ content=data["content"].encode("latin-1"),
89
+ media_type=data.get("media_type"),
89
90
  )
90
91
  except (json.JSONDecodeError, KeyError, ValueError):
91
92
  logger.debug("Memcached DESERIALIZE ERROR; key=%s", key)
@@ -101,16 +102,14 @@ class MemcachedBackend(BaseCacheBackend):
101
102
  """
102
103
  prefixed_key = self._make_key(key)
103
104
 
104
- # Prepare content for JSON serialization
105
- if isinstance(value.content, bytes):
106
- content = value.content.decode()
107
- else:
108
- content = value.content
105
+ # Use latin-1 to round-trip arbitrary bytes through JSON storage
106
+ content = value.content.decode("latin-1")
109
107
 
110
108
  serialized_data: str | bytes = json.dumps(
111
109
  {
112
110
  "etag": value.etag,
113
111
  "content": content,
112
+ "media_type": value.media_type,
114
113
  },
115
114
  )
116
115
 
@@ -121,10 +120,11 @@ class MemcachedBackend(BaseCacheBackend):
121
120
  else serialized_data.encode("utf-8")
122
121
  )
123
122
 
124
- self.client.set(
125
- prefixed_key,
126
- serialized_bytes,
127
- expire=ttl if ttl is not None else 0,
123
+ 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
128
  )
129
129
  logger.debug("Memcached SET; key=%s ttl=%s", key, ttl)
130
130
 
@@ -134,7 +134,9 @@ class MemcachedBackend(BaseCacheBackend):
134
134
  Args:
135
135
  key: Cache key to delete
136
136
  """
137
- self.client.delete(self._make_key(key))
137
+ prefixed = self._make_key(key)
138
+ loop = asyncio.get_running_loop()
139
+ await loop.run_in_executor(None, self.client.delete, prefixed)
138
140
  logger.debug("Memcached DELETE; key=%s", key)
139
141
 
140
142
  async def clear(self) -> None:
@@ -150,7 +152,8 @@ class MemcachedBackend(BaseCacheBackend):
150
152
  RuntimeWarning,
151
153
  stacklevel=2,
152
154
  )
153
- self.client.flush_all()
155
+ loop = asyncio.get_running_loop()
156
+ await loop.run_in_executor(None, self.client.flush_all)
154
157
  logger.debug("Memcached CLEAR; flush_all issued")
155
158
 
156
159
  async def clear_path(self, path: str, include_params: bool = False) -> int:
@@ -180,8 +183,12 @@ class MemcachedBackend(BaseCacheBackend):
180
183
 
181
184
  # Try to delete the prefixed key (exact match only)
182
185
  prefixed_key = self._make_key(path)
186
+ loop = asyncio.get_running_loop()
183
187
  try:
184
- result = self.client.delete(prefixed_key, noreply=False)
188
+ result = await loop.run_in_executor(
189
+ None,
190
+ lambda: self.client.delete(prefixed_key, noreply=False),
191
+ )
185
192
  except Exception: # noqa: BLE001
186
193
  return 0
187
194
  else:
@@ -1,7 +1,6 @@
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
@@ -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.
@@ -5,6 +5,9 @@ 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
@@ -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,6 +113,12 @@ 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:
@@ -112,15 +127,14 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
112
127
 
113
128
  def _serialize(self, value: ETagContent) -> str:
114
129
  """Serialize ETagContent to JSON string."""
115
- if isinstance(value.content, bytes):
116
- content = value.content.decode()
117
- else:
118
- content = value.content
130
+ # Use latin-1 to round-trip arbitrary bytes through JSON/UTF-8 Redis storage
131
+ content = value.content.decode("latin-1")
119
132
 
120
133
  serialized: str | bytes = json.dumps(
121
134
  {
122
135
  "etag": value.etag,
123
136
  "content": content,
137
+ "media_type": value.media_type,
124
138
  },
125
139
  )
126
140
 
@@ -140,11 +154,10 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
140
154
  logger.debug("Content type in JSON: %s", type(data["content"]))
141
155
  return ETagContent(
142
156
  etag=data["etag"],
143
- content=data["content"].encode()
144
- if isinstance(data["content"], str)
145
- else data["content"],
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
163
  async def get(self, key: str) -> ETagContent | None:
@@ -158,10 +171,7 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
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:
@@ -350,12 +360,18 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
350
360
  all_keys = await self.get_all_keys()
351
361
  cache_data: dict[str, tuple[ETagContent, float | None]] = {}
352
362
 
363
+ if not all_keys:
364
+ return cache_data
365
+
366
+ # Fetch all values in a single pipeline round-trip instead of N+1 GETs
367
+ pipe = self.client.pipeline()
353
368
  for prefixed_key in all_keys:
354
- # Remove prefix to get the original cache key
355
- original_key = prefixed_key.removeprefix(self.key_prefix)
369
+ pipe.get(prefixed_key)
370
+ raw_values: list[str | None] = await pipe.execute()
356
371
 
357
- # Get the value using the original key (get() adds prefix internally)
358
- value = await self.get(original_key)
372
+ for prefixed_key, raw in zip(all_keys, raw_values, strict=False):
373
+ original_key = prefixed_key.removeprefix(self.key_prefix)
374
+ value = self._deserialize(raw)
359
375
  if value is not None:
360
376
  cache_data[original_key] = (value, None)
361
377
 
@@ -12,10 +12,10 @@ from inspect import Signature
12
12
  from typing import TYPE_CHECKING
13
13
  from typing import Any
14
14
  from typing import Literal
15
+ from typing import cast
15
16
 
16
17
  from fastapi import Request
17
18
  from fastapi import Response
18
- from fastapi.datastructures import DefaultPlaceholder
19
19
  from starlette.status import HTTP_304_NOT_MODIFIED
20
20
 
21
21
  from .backends import MemoryBackend
@@ -108,11 +108,12 @@ async def get_response(
108
108
  msg = "Route not found in request scope"
109
109
  raise CacheXError(msg)
110
110
 
111
- if isinstance(route.response_class, DefaultPlaceholder):
112
- response_class: type[Response] = route.response_class.value
113
-
114
- else:
115
- response_class = route.response_class
111
+ # FastAPI <= 0.136.x stores a DefaultPlaceholder; >= 0.137.0 stores the class directly.
112
+ # getattr with a fallback handles both without importing the internal class.
113
+ response_class: type[Response] = cast(
114
+ "type[Response]",
115
+ getattr(route.response_class, "value", route.response_class),
116
+ )
116
117
 
117
118
  # Convert non-Response result to Response using appropriate response_class
118
119
  return response_class(content=result)
@@ -150,13 +151,10 @@ def cache(
150
151
  """
151
152
 
152
153
  def decorator(func: HandlerCallable) -> AsyncResponseCallable:
153
- try:
154
- cache_backend = BackendProxy.get()
155
- except BackendNotFoundError:
156
- # Fallback to memory backend if no backend is set
157
- cache_backend = MemoryBackend()
158
- BackendProxy.set(cache_backend)
159
- logger.debug("No backend configured; using MemoryBackend fallback")
154
+ # Validate stale parameters eagerly at decoration time
155
+ if stale is not None and stale_ttl is None:
156
+ msg = "stale_ttl must be set if stale is used"
157
+ raise CacheXError(msg)
160
158
 
161
159
  # Analyze the original function's signature
162
160
  sig: Signature = inspect.signature(func)
@@ -183,14 +181,13 @@ def cache(
183
181
  else:
184
182
  request_name = found_request.name
185
183
 
186
- async def get_cache_control(cache_control: CacheControl) -> str:
184
+ def get_cache_control(cache_control: CacheControl) -> str:
187
185
  # Set Cache-Control headers
188
186
  if no_cache:
189
187
  cache_control.add(DirectiveType.NO_CACHE)
190
188
  if must_revalidate:
191
189
  cache_control.add(DirectiveType.MUST_REVALIDATE)
192
190
  else:
193
- # Handle normal cache control cases
194
191
  # 1. Access scope (public/private)
195
192
  if public:
196
193
  cache_control.add(DirectiveType.PUBLIC)
@@ -205,11 +202,7 @@ def cache(
205
202
  if must_revalidate:
206
203
  cache_control.add(DirectiveType.MUST_REVALIDATE)
207
204
 
208
- # 4. Stale response handling
209
- if stale is not None and stale_ttl is None:
210
- msg = "stale_ttl must be set if stale is used"
211
- raise CacheXError(msg)
212
-
205
+ # 4. Stale response handling (stale_ttl is validated at decoration time)
213
206
  if stale == "revalidate":
214
207
  cache_control.add(DirectiveType.STALE_WHILE_REVALIDATE, stale_ttl)
215
208
  elif stale == "error":
@@ -223,6 +216,14 @@ def cache(
223
216
 
224
217
  @wraps(func)
225
218
  async def wrapper(*args: Any, **kwargs: Any) -> Response:
219
+ # Resolve backend on every request to support lifespan-configured backends
220
+ try:
221
+ cache_backend = BackendProxy.get()
222
+ except BackendNotFoundError:
223
+ cache_backend = MemoryBackend()
224
+ BackendProxy.set(cache_backend)
225
+ logger.debug("No backend configured; using MemoryBackend fallback")
226
+
226
227
  if found_request:
227
228
  req: Request | None = kwargs.get(request_name)
228
229
  else:
@@ -243,7 +244,7 @@ def cache(
243
244
  builder = key_builder or default_key_builder
244
245
  cache_key = builder(req)
245
246
  client_etag = req.headers.get("if-none-match")
246
- cache_control = await get_cache_control(CacheControl())
247
+ cache_control = get_cache_control(CacheControl())
247
248
 
248
249
  # Handle special case: no-store (highest priority)
249
250
  if no_store:
@@ -257,15 +258,21 @@ def cache(
257
258
  # Check cache and handle ETag validation
258
259
  cached_data = await cache_backend.get(cache_key)
259
260
 
260
- current_response = None
261
- current_etag = None
261
+ current_response: Response | None = None
262
+ current_etag: str | None = None
263
+ current_body: bytes | None = None
262
264
 
263
265
  if client_etag:
264
266
  if no_cache:
265
267
  # Get fresh response first if using no-cache
266
268
  current_response = await get_response(func, req, *args, **kwargs)
269
+ current_body = getattr(current_response, "body", None)
270
+ if current_body is None:
271
+ # StreamingResponse/FileResponse — cannot compute ETag; serve as-is
272
+ current_response.headers["Cache-Control"] = cache_control
273
+ return current_response
267
274
  current_etag = (
268
- f'W/"{hashlib.md5(current_response.body).hexdigest()}"' # noqa: S324
275
+ f'W/"{hashlib.md5(current_body).hexdigest()}"' # noqa: S324
269
276
  )
270
277
 
271
278
  if client_etag == current_etag:
@@ -304,6 +311,7 @@ def cache(
304
311
  return Response(
305
312
  content=cached_data.content,
306
313
  status_code=200,
314
+ media_type=cached_data.media_type,
307
315
  headers={
308
316
  "ETag": cached_data.etag,
309
317
  "Cache-Control": cache_control,
@@ -313,7 +321,12 @@ def cache(
313
321
  if not current_response or not current_etag:
314
322
  # Retrieve the current response if not already done
315
323
  current_response = await get_response(func, req, *args, **kwargs)
316
- current_etag = f'W/"{hashlib.md5(current_response.body).hexdigest()}"' # noqa: S324
324
+ current_body = getattr(current_response, "body", None)
325
+ if current_body is None:
326
+ # StreamingResponse/FileResponse — cannot compute ETag; serve as-is
327
+ current_response.headers["Cache-Control"] = cache_control
328
+ return current_response
329
+ current_etag = f'W/"{hashlib.md5(current_body).hexdigest()}"' # noqa: S324
317
330
  logger.debug("Cache MISS; computed fresh ETag for key=%s", cache_key)
318
331
 
319
332
  # Set ETag header
@@ -321,10 +334,17 @@ def cache(
321
334
 
322
335
  # Update cache if needed
323
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)
324
340
  # Store in cache if data changed
325
341
  await cache_backend.set(
326
342
  cache_key,
327
- ETagContent(current_etag, current_response.body),
343
+ ETagContent(
344
+ current_etag,
345
+ current_body,
346
+ current_response.media_type,
347
+ ),
328
348
  ttl=ttl,
329
349
  )
330
350
  logger.debug("Updated cache entry; key=%s ttl=%s", cache_key, ttl)
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import warnings
6
6
  from logging import getLogger
7
7
  from typing import Generic
8
+ from typing import NoReturn
8
9
  from typing import TypeVar
9
10
 
10
11
  from .backends import BaseCacheBackend
@@ -18,7 +19,7 @@ logger = getLogger(__name__)
18
19
  class ProxyMeta(type):
19
20
  """Metaclass for BackendProxy to prevent instantiation."""
20
21
 
21
- def __call__(cls) -> None:
22
+ def __call__(cls) -> NoReturn:
22
23
  """Prevent instantiation of BackendProxy."""
23
24
  msg = "Proxy class cannot be instantiated. Use static methods instead."
24
25
  raise TypeError(msg)
@@ -1,8 +1,10 @@
1
1
  """Optional routes for cache monitoring and management."""
2
2
 
3
3
  import time
4
+ from collections.abc import Sequence
4
5
  from dataclasses import dataclass
5
6
  from typing import TYPE_CHECKING
7
+ from typing import Any
6
8
 
7
9
  from .backends import BaseCacheBackend
8
10
  from .exceptions import BackendNotFoundError
@@ -37,7 +39,7 @@ class CacheHitSummary:
37
39
 
38
40
  total_cached_entries: int
39
41
  active_entries: int
40
- frequently_cached_routes: list[str]
42
+ cached_paths: list[str]
41
43
 
42
44
 
43
45
  @dataclass
@@ -127,7 +129,7 @@ async def _get_cached_hits_handler(backend: BaseCacheBackend) -> CacheHitsRespon
127
129
  if method: # Valid cache key
128
130
  # Check if cache entry is expired
129
131
  is_expired = expiry is not None and expiry <= now
130
- ttl_remaining = round(expiry - now, 2) if expiry is not None else None
132
+ ttl_remaining = max(0.0, round(expiry - now, 2)) if expiry is not None else None
131
133
 
132
134
  cached_hits.append(
133
135
  CacheHitRecord(
@@ -155,7 +157,7 @@ async def _get_cached_hits_handler(backend: BaseCacheBackend) -> CacheHitsRespon
155
157
  summary=CacheHitSummary(
156
158
  total_cached_entries=len(cached_hits),
157
159
  active_entries=len(valid_hits),
158
- frequently_cached_routes=sorted(routes_hit),
160
+ cached_paths=sorted(routes_hit),
159
161
  ),
160
162
  )
161
163
 
@@ -186,7 +188,7 @@ async def _get_cached_records_handler(
186
188
  content = etag_content.content
187
189
  content_size = len(content) if isinstance(content, (bytes, str)) else 0
188
190
 
189
- ttl_remaining = round(expiry - now, 2) if expiry is not None else None
191
+ ttl_remaining = max(0.0, round(expiry - now, 2)) if expiry is not None else None
190
192
 
191
193
  content_preview = (
192
194
  content[:100].decode("utf-8", errors="ignore")
@@ -229,7 +231,10 @@ async def _get_cached_records_handler(
229
231
 
230
232
 
231
233
  def add_routes(
232
- app: "FastAPI", prefix: str = "", include_in_schema: bool = False
234
+ app: "FastAPI",
235
+ prefix: str = "",
236
+ include_in_schema: bool = False,
237
+ dependencies: Sequence[Any] | None = None,
233
238
  ) -> None:
234
239
  """Add cache monitoring routes to the FastAPI application.
235
240
 
@@ -243,6 +248,10 @@ def add_routes(
243
248
  Defaults to "" (no prefix).
244
249
  include_in_schema: Whether to include routes in OpenAPI schema.
245
250
  Defaults to False.
251
+ dependencies: Optional list of FastAPI ``Depends`` objects applied to
252
+ all monitoring routes. Useful for adding authentication
253
+ or authorization guards (e.g.
254
+ ``[Depends(verify_api_key)]``).
246
255
 
247
256
  Example:
248
257
  from fastapi import FastAPI
@@ -255,7 +264,11 @@ def add_routes(
255
264
  add_routes(app, prefix="/api/cache") # Routes at /api/cache/cached-hits and /api/cache/cached-records
256
265
  """
257
266
 
258
- @app.get(f"{prefix}/cached-hits", include_in_schema=include_in_schema)
267
+ @app.get(
268
+ f"{prefix}/cached-hits",
269
+ include_in_schema=include_in_schema,
270
+ dependencies=dependencies,
271
+ )
259
272
  async def get_cached_hits() -> CacheHitsResponse:
260
273
  """Return cached hit records.
261
274
 
@@ -277,13 +290,17 @@ def add_routes(
277
290
  summary=CacheHitSummary(
278
291
  total_cached_entries=0,
279
292
  active_entries=0,
280
- frequently_cached_routes=[],
293
+ cached_paths=[],
281
294
  ),
282
295
  )
283
296
 
284
297
  return await _get_cached_hits_handler(backend)
285
298
 
286
- @app.get(f"{prefix}/cached-records", include_in_schema=include_in_schema)
299
+ @app.get(
300
+ f"{prefix}/cached-records",
301
+ include_in_schema=include_in_schema,
302
+ dependencies=dependencies,
303
+ )
287
304
  async def get_cached_records() -> CachedRecordsResponse:
288
305
  """Display currently cached records.
289
306
 
@@ -81,10 +81,6 @@ class SessionConfig(BaseModel):
81
81
  default=False,
82
82
  description="Whether to bind session to User-Agent",
83
83
  )
84
- regenerate_on_login: bool = Field(
85
- default=True,
86
- description="Whether to regenerate session ID on login",
87
- )
88
84
 
89
85
  # Backend settings
90
86
  backend_key_prefix: str = Field(
@@ -138,8 +138,8 @@ class SessionManager:
138
138
  # Store in backend
139
139
  await self._save_session(session)
140
140
 
141
- # Generate signed token
142
- token = self._create_token(session.session_id)
141
+ # Generate signed token (pass expires_at so JWT exp reflects sliding expiration)
142
+ token = self._create_token(session.session_id, expires_at=session.expires_at)
143
143
  logger.debug(
144
144
  "Session created; id=%s ttl=%s ip=%s ua=%s",
145
145
  session.session_id,
@@ -214,6 +214,22 @@ class SessionManager:
214
214
  logger.debug("Session expired; id=%s", session.session_id)
215
215
  raise SessionExpiredError(msg)
216
216
 
217
+ # Check absolute timeout (hard cap regardless of sliding expiration)
218
+ if self.config.absolute_timeout is not None:
219
+ absolute_expires_at = session.created_at + timedelta(
220
+ seconds=self.config.absolute_timeout,
221
+ )
222
+ if datetime.now(timezone.utc) >= absolute_expires_at:
223
+ session.status = SessionStatus.EXPIRED
224
+ await self._save_session(session)
225
+ msg = "Session has exceeded absolute timeout"
226
+ logger.debug(
227
+ "Session absolute timeout exceeded; id=%s created_at=%s",
228
+ session.session_id,
229
+ session.created_at,
230
+ )
231
+ raise SessionExpiredError(msg)
232
+
217
233
  # Security checks
218
234
  if self.config.ip_binding and not self.security.check_ip_match(
219
235
  session,
@@ -314,8 +330,8 @@ class SessionManager:
314
330
  # Save with new ID
315
331
  await self._save_session(session)
316
332
 
317
- # Create new token
318
- token = self._create_token(session.session_id)
333
+ # Create new token (pass expires_at so JWT exp reflects current expiry)
334
+ token = self._create_token(session.session_id, expires_at=session.expires_at)
319
335
  logger.debug(
320
336
  "Session ID regenerated; old_id=%s new_id=%s", old_id, session.session_id
321
337
  )
@@ -372,11 +388,16 @@ class SessionManager:
372
388
  logger.debug("Expired sessions cleared; count=%s", count)
373
389
  return count
374
390
 
375
- def _create_token(self, session_id: str) -> SessionToken:
391
+ def _create_token(
392
+ self,
393
+ session_id: str,
394
+ expires_at: datetime | None = None,
395
+ ) -> SessionToken:
376
396
  """Create a signed session token.
377
397
 
378
398
  Args:
379
399
  session_id: Session ID to sign
400
+ expires_at: Session expiry time, forwarded to JWT serializers
380
401
 
381
402
  Returns:
382
403
  SessionToken object
@@ -389,7 +410,9 @@ class SessionManager:
389
410
  if self.config.token_format == "simple"
390
411
  else ""
391
412
  )
392
- return SessionToken(session_id=session_id, signature=signature)
413
+ return SessionToken(
414
+ session_id=session_id, signature=signature, expires_at=expires_at
415
+ )
393
416
 
394
417
  async def _save_session(self, session: Session) -> None:
395
418
  """Save session to backend.
@@ -142,3 +142,4 @@ class SessionToken(BaseModel):
142
142
  session_id: str
143
143
  signature: str
144
144
  issued_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
145
+ expires_at: datetime | None = None
@@ -21,7 +21,8 @@ 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
- self.secret_key = secret_key.encode("utf-8")
24
+ # Use name mangling so the key bytes are not trivially accessible via repr
25
+ self.__secret_key_bytes = secret_key.encode("utf-8")
25
26
 
26
27
  logger.debug(
27
28
  "SecurityManager initialized with secret length=%s", len(secret_key)
@@ -37,7 +38,7 @@ class SecurityManager:
37
38
  The signature as a hex string
38
39
  """
39
40
  return hmac.new(
40
- self.secret_key,
41
+ self.__secret_key_bytes,
41
42
  session_id.encode("utf-8"),
42
43
  hashlib.sha256,
43
44
  ).hexdigest()
@@ -133,7 +133,11 @@ class JWTTokenSerializer:
133
133
  Uses claims `sid`, `iat`, `exp`, and optional `iss`/`aud`.
134
134
  """
135
135
  iat = int(token.issued_at.timestamp())
136
- exp = iat + int(self._session_ttl)
136
+ # Use session's expires_at when available (supports sliding expiration)
137
+ if token.expires_at is not None:
138
+ exp = int(token.expires_at.timestamp())
139
+ else:
140
+ exp = iat + int(self._session_ttl)
137
141
 
138
142
  payload: dict[str, object] = {
139
143
  "sid": token.session_id,
@@ -9,6 +9,7 @@ from datetime import timedelta
9
9
  from datetime import timezone
10
10
  from typing import Any
11
11
 
12
+ from fastapi_cachex.backends.base import BaseCacheBackend
12
13
  from fastapi_cachex.proxy import BackendProxy
13
14
  from fastapi_cachex.types import ETagContent
14
15
 
@@ -27,18 +28,67 @@ class StateManager:
27
28
  """Manages OAuth state and session state lifecycle and storage."""
28
29
 
29
30
  def __init__(
30
- self, key_prefix: str = "oauth_state:", default_ttl: int = DEFAULT_STATE_TTL
31
+ self,
32
+ backend: BaseCacheBackend | None = None,
33
+ key_prefix: str = "oauth_state:",
34
+ default_ttl: int = DEFAULT_STATE_TTL,
31
35
  ) -> None:
32
36
  """Initialize StateManager.
33
37
 
34
38
  Args:
39
+ backend: Cache backend instance. If None, uses BackendProxy.get().
35
40
  key_prefix: Prefix for state keys in cache backend
36
41
  default_ttl: Default time-to-live in seconds for state
37
42
  """
38
- self.backend = BackendProxy.get()
43
+ self.backend = backend if backend is not None else BackendProxy.get()
39
44
  self.key_prefix = key_prefix
40
45
  self.default_ttl = default_ttl
41
46
 
47
+ def _extract_json_content(self, cached: ETagContent) -> str:
48
+ """Extract JSON string from a cached ETagContent value.
49
+
50
+ Args:
51
+ cached: The ETagContent retrieved from backend
52
+
53
+ Returns:
54
+ JSON string
55
+
56
+ Raises:
57
+ StateDataError: If the content cannot be decoded as UTF-8 text
58
+ """
59
+ try:
60
+ return cached.content.decode("utf-8")
61
+ except (AttributeError, UnicodeDecodeError) as e:
62
+ msg = "Unexpected state data format"
63
+ raise StateDataError(msg) from e
64
+
65
+ def _parse_state_data(self, json_content: str, state: str) -> StateData:
66
+ """Parse JSON content into a StateData model.
67
+
68
+ Args:
69
+ json_content: JSON string to parse
70
+ state: State string (used for logging)
71
+
72
+ Returns:
73
+ StateData instance
74
+
75
+ Raises:
76
+ StateDataError: If parsing or validation fails
77
+ """
78
+ try:
79
+ state_dict: dict[str, Any] = json.loads(json_content)
80
+ except json.JSONDecodeError as e:
81
+ msg = f"Failed to parse state data: {e}"
82
+ logger.exception("Failed to parse state data; state=%s", state)
83
+ raise StateDataError(msg) from e
84
+
85
+ try:
86
+ return StateData(**state_dict)
87
+ except ValueError as e:
88
+ msg = f"Invalid state data structure: {e}"
89
+ logger.exception("Failed to create StateData model; state=%s", state)
90
+ raise StateDataError(msg) from e
91
+
42
92
  async def create_state(
43
93
  self,
44
94
  ttl: int | None = None,
@@ -77,7 +127,7 @@ class StateManager:
77
127
 
78
128
  # Store in backend with TTL using ETagContent
79
129
  cache_key = f"{self.key_prefix}{state}"
80
- etag_content = ETagContent(etag=etag, content=json_content)
130
+ etag_content = ETagContent(etag=etag, content=json_content.encode("utf-8"))
81
131
  await self.backend.set(cache_key, etag_content, ttl=effective_ttl)
82
132
 
83
133
  logger.debug("OAuth state created; state=%s ttl=%s", state, effective_ttl)
@@ -106,34 +156,8 @@ class StateManager:
106
156
  msg = "Invalid or expired state"
107
157
  raise InvalidStateError(msg)
108
158
 
109
- # Extract content from ETagContent
110
- json_content = cached_etag_content.content
111
- if isinstance(json_content, bytes):
112
- json_content = json_content.decode("utf-8")
113
- elif not isinstance(json_content, str):
114
- msg = "Unexpected state data format"
115
- logger.error(
116
- "Unexpected content type in state; state=%s type=%s",
117
- state,
118
- type(json_content),
119
- )
120
- raise StateDataError(msg)
121
-
122
- # Parse the stored state data
123
- try:
124
- state_dict: dict[str, Any] = json.loads(json_content)
125
- except json.JSONDecodeError as e:
126
- msg = f"Failed to parse state data: {e}"
127
- logger.exception("Failed to parse state data; state=%s", state)
128
- raise StateDataError(msg) from e
129
-
130
- # Validate and create StateData model
131
- try:
132
- state_data = StateData(**state_dict)
133
- except ValueError as e:
134
- msg = f"Invalid state data structure: {e}"
135
- logger.exception("Failed to create StateData model; state=%s", state)
136
- raise StateDataError(msg) from e
159
+ json_content = self._extract_json_content(cached_etag_content)
160
+ state_data = self._parse_state_data(json_content, state)
137
161
 
138
162
  # Verify expiry
139
163
  if datetime.now(timezone.utc) > state_data.expires_at:
@@ -158,43 +182,21 @@ class StateManager:
158
182
  """
159
183
  cache_key = f"{self.key_prefix}{state}"
160
184
 
161
- # Try to retrieve state data from backend
162
185
  cached_etag_content = await self.backend.get(cache_key)
163
186
  if cached_etag_content is None:
164
187
  logger.debug("State validation failed - not found; state=%s", state)
165
188
  return False
166
189
 
167
- # Extract content from ETagContent
168
- json_content = cached_etag_content.content
169
- if isinstance(json_content, bytes):
170
- try:
171
- json_content = json_content.decode("utf-8")
172
- except UnicodeDecodeError:
173
- logger.exception(
174
- "Failed to decode bytes content in state; state=%s",
175
- state,
176
- )
177
- return False
178
- elif not isinstance(json_content, str):
179
- logger.error(
180
- "Unexpected content type in state; state=%s type=%s",
181
- state,
182
- type(json_content),
183
- )
184
- return False
185
-
186
190
  try:
187
- state_dict: dict[str, Any] = json.loads(json_content)
188
- # Validate and create StateData model
189
- state_data = StateData(**state_dict)
190
- except (json.JSONDecodeError, ValueError):
191
+ json_content = self._extract_json_content(cached_etag_content)
192
+ state_data = self._parse_state_data(json_content, state)
193
+ except (StateDataError, UnicodeDecodeError):
191
194
  logger.exception(
192
195
  "Failed to parse or validate state data; state=%s",
193
196
  state,
194
197
  )
195
198
  return False
196
199
 
197
- # Check expiry
198
200
  if datetime.now(timezone.utc) > state_data.expires_at:
199
201
  logger.debug("State validation failed - expired; state=%s", state)
200
202
  return False
@@ -217,34 +219,13 @@ class StateManager:
217
219
  if cached_etag_content is None:
218
220
  return None
219
221
 
220
- # Extract content from ETagContent
221
- json_content = cached_etag_content.content
222
- if isinstance(json_content, bytes):
223
- try:
224
- json_content = json_content.decode("utf-8")
225
- except UnicodeDecodeError:
226
- logger.exception(
227
- "Failed to decode bytes content in state; state=%s",
228
- state,
229
- )
230
- return None
231
- elif not isinstance(json_content, str):
232
- logger.error(
233
- "Unexpected content type in state; state=%s type=%s",
234
- state,
235
- type(json_content),
236
- )
237
- return None
238
-
239
222
  try:
240
- state_dict: dict[str, Any] = json.loads(json_content)
241
- # Validate and create StateData model
242
- state_data = StateData(**state_dict)
243
- except (json.JSONDecodeError, ValueError):
223
+ json_content = self._extract_json_content(cached_etag_content)
224
+ state_data = self._parse_state_data(json_content, state)
225
+ except (StateDataError, UnicodeDecodeError):
244
226
  logger.exception("Failed to parse or validate state data; state=%s", state)
245
227
  return None
246
228
 
247
- # Check expiry
248
229
  if datetime.now(timezone.utc) > state_data.expires_at:
249
230
  return None
250
231
 
@@ -260,6 +241,11 @@ class StateManager:
260
241
  True if state was deleted, False if it didn't exist
261
242
  """
262
243
  cache_key = f"{self.key_prefix}{state}"
244
+ # Check existence before deleting to return accurate result
245
+ existing = await self.backend.get(cache_key)
246
+ if existing is None:
247
+ logger.debug("OAuth state not found for deletion; state=%s", state)
248
+ return False
263
249
  await self.backend.delete(cache_key)
264
250
  logger.debug("OAuth state deleted; state=%s", state)
265
251
  return True
@@ -2,7 +2,6 @@
2
2
 
3
3
  from collections.abc import Callable
4
4
  from dataclasses import dataclass
5
- from typing import Any
6
5
 
7
6
  from fastapi import Request
8
7
 
@@ -18,7 +17,8 @@ class ETagContent:
18
17
  """ETag and content for cache items."""
19
18
 
20
19
  etag: str
21
- content: Any
20
+ content: bytes
21
+ media_type: str | None = None
22
22
 
23
23
 
24
24
  @dataclass
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-cachex"
3
- version = "0.2.11"
3
+ version = "0.2.12"
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"
@@ -45,7 +45,7 @@ dev = [
45
45
  "pytest>=8.3.5",
46
46
  "pytest-asyncio>=0.26.0",
47
47
  "pytest-cov>=6.1.0",
48
- "redis[hiredis]>=5.2.1",
48
+ "redis[hiredis]>=5.3.0",
49
49
  "ruff>=0.11.2",
50
50
  "tox>=4.25.0",
51
51
  "tox-uv>=1.29.0",
@@ -56,11 +56,11 @@ dev = [
56
56
 
57
57
  [project.optional-dependencies]
58
58
  memcache = ["pymemcache"]
59
- redis = ["redis[hiredis]", "orjson"]
59
+ 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.10.0"]
63
+ requires = ["uv_build>=0.9.17,<0.11.0"]
64
64
  build-backend = "uv_build"
65
65
 
66
66
  [tool.uv.build-backend]
@@ -110,7 +110,7 @@ keep-runtime-typing = true
110
110
  "PT031" # Single statement in pytest.warns is acceptable
111
111
  ]
112
112
  "fastapi_cachex/cache.py" = [
113
- "PLR0913", "PLR0915", # Many arguments/statements needed for flexible caching logic
113
+ "PLR0913", "PLR0915", "PLR0911", "PLR0912", # Many arguments/statements/returns/branches needed for flexible caching logic
114
114
  ]
115
115
  "fastapi_cachex/backends/memcached.py" = ["PLC0415"] # Optional dependency
116
116
  "fastapi_cachex/backends/redis.py" = ["PLR0913", "PLC0415"] # Optional dependency, Redis config
@@ -1,27 +0,0 @@
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 CacheBackend as CacheBackend
8
- from .dependencies import get_cache_backend as get_cache_backend
9
- from .proxy import BackendProxy as BackendProxy
10
- from .routes import add_routes as add_routes
11
- from .types import CacheKeyBuilder as CacheKeyBuilder
12
-
13
- _package_logger = logging.getLogger("fastapi_cachex")
14
- _package_logger.addHandler(
15
- logging.NullHandler()
16
- ) # Attach a NullHandler to avoid "No handler found" warnings in user applications.
17
-
18
- # Session management (optional feature)
19
- __all__ = [
20
- "BackendProxy",
21
- "CacheBackend",
22
- "CacheKeyBuilder",
23
- "add_routes",
24
- "cache",
25
- "default_key_builder",
26
- "get_cache_backend",
27
- ]
@@ -1,15 +0,0 @@
1
- """Configuration models for cache backends."""
2
-
3
- from pydantic import BaseModel
4
- from pydantic import Field
5
- from pydantic import SecretStr
6
-
7
-
8
- class RedisConfig(BaseModel):
9
- """Configuration for Redis backend."""
10
-
11
- host: str = Field(default="localhost", description="Redis server address")
12
- port: int = Field(default=6379, description="Redis server port")
13
- password: SecretStr | None = Field(
14
- default=None, description="Redis server password"
15
- )