fastapi-cachex 0.3.0__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/PKG-INFO +3 -1
  2. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/__init__.py +10 -0
  3. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/backends/memcached.py +3 -1
  4. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/backends/memory.py +5 -0
  5. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/cache.py +55 -7
  6. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/manager.py +64 -0
  7. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/routes.py +6 -2
  8. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/session/__init__.py +2 -0
  9. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/session/config.py +35 -0
  10. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/session/manager.py +6 -4
  11. fastapi_cachex-0.3.2/fastapi_cachex/session/middleware.py +485 -0
  12. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/state/__init__.py +3 -0
  13. fastapi_cachex-0.3.2/fastapi_cachex/state/dependencies.py +28 -0
  14. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/state/manager.py +3 -1
  15. fastapi_cachex-0.3.2/fastapi_cachex/state/proxy.py +9 -0
  16. fastapi_cachex-0.3.2/pyproject.toml +189 -0
  17. fastapi_cachex-0.3.0/pyproject.toml → fastapi_cachex-0.3.2/pyproject.toml.orig +6 -2
  18. fastapi_cachex-0.3.0/fastapi_cachex/session/middleware.py +0 -164
  19. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/README.md +0 -0
  20. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/backends/__init__.py +0 -0
  21. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/backends/base.py +0 -0
  22. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/backends/config.py +0 -0
  23. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/backends/redis.py +0 -0
  24. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/dependencies.py +0 -0
  25. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/directives.py +0 -0
  26. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/exceptions.py +0 -0
  27. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/manager_proxy.py +0 -0
  28. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/proxy.py +0 -0
  29. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/py.typed +0 -0
  30. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/session/dependencies.py +0 -0
  31. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/session/exceptions.py +0 -0
  32. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/session/models.py +0 -0
  33. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/session/proxy.py +0 -0
  34. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/session/security.py +0 -0
  35. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/session/token_serializers.py +0 -0
  36. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/state/exceptions.py +0 -0
  37. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/state/models.py +0 -0
  38. {fastapi_cachex-0.3.0 → fastapi_cachex-0.3.2}/fastapi_cachex/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-cachex
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: A caching library for FastAPI with support for Cache-Control, ETag, and multiple backends.
5
5
  Keywords: fastapi,cache,etag,cache-control,redis,memcached,in-memory
6
6
  Author: allen0099
@@ -25,6 +25,7 @@ Requires-Dist: pyjwt>=2.9.0 ; extra == 'jwt'
25
25
  Requires-Dist: pymemcache ; extra == 'memcache'
26
26
  Requires-Dist: redis[hiredis]>=5.3.0 ; extra == 'redis'
27
27
  Requires-Dist: orjson ; extra == 'redis'
28
+ Requires-Dist: itsdangerous ; extra == 'starlette'
28
29
  Requires-Python: >=3.10
29
30
  Project-URL: Homepage, https://github.com/allen0099/FastAPI-CacheX
30
31
  Project-URL: Repository, https://github.com/allen0099/FastAPI-CacheX.git
@@ -32,6 +33,7 @@ Project-URL: Issues, https://github.com/allen0099/FastAPI-CacheX/issues
32
33
  Provides-Extra: jwt
33
34
  Provides-Extra: memcache
34
35
  Provides-Extra: redis
36
+ Provides-Extra: starlette
35
37
  Description-Content-Type: text/markdown
36
38
 
37
39
  # FastAPI-Cache X
@@ -4,6 +4,7 @@ import logging
4
4
 
5
5
  from .cache import cache as cache
6
6
  from .cache import default_key_builder as default_key_builder
7
+ from .cache import invalidate as invalidate
7
8
  from .dependencies import AppCache as AppCache
8
9
  from .dependencies import CacheBackend as CacheBackend
9
10
  from .dependencies import get_app_cache as get_app_cache
@@ -12,6 +13,7 @@ from .manager import CacheManager as CacheManager
12
13
  from .manager_proxy import CacheManagerProxy as CacheManagerProxy
13
14
  from .proxy import BackendProxy as BackendProxy
14
15
  from .routes import add_routes as add_routes
16
+ from .session import FastAPICacheXSessionMiddleware as FastAPICacheXSessionMiddleware
15
17
  from .session import Session as Session
16
18
  from .session import SessionConfig as SessionConfig
17
19
  from .session import SessionManager as SessionManager
@@ -34,6 +36,9 @@ from .state import StateDataError as StateDataError
34
36
  from .state import StateError as StateError
35
37
  from .state import StateExpiredError as StateExpiredError
36
38
  from .state import StateManager as StateManager
39
+ from .state import StateManagerDep as StateManagerDep
40
+ from .state import StateManagerProxy as StateManagerProxy
41
+ from .state import get_state_manager as get_state_manager
37
42
  from .types import CacheKeyBuilder as CacheKeyBuilder
38
43
 
39
44
  _package_logger = logging.getLogger("fastapi_cachex")
@@ -48,6 +53,7 @@ __all__ = [
48
53
  "CacheKeyBuilder",
49
54
  "CacheManager",
50
55
  "CacheManagerProxy",
56
+ "FastAPICacheXSessionMiddleware",
51
57
  "InvalidStateError",
52
58
  "Session",
53
59
  "SessionConfig",
@@ -66,6 +72,8 @@ __all__ = [
66
72
  "StateError",
67
73
  "StateExpiredError",
68
74
  "StateManager",
75
+ "StateManagerDep",
76
+ "StateManagerProxy",
69
77
  "add_routes",
70
78
  "cache",
71
79
  "default_key_builder",
@@ -74,5 +82,7 @@ __all__ = [
74
82
  "get_optional_session",
75
83
  "get_session",
76
84
  "get_session_manager",
85
+ "get_state_manager",
86
+ "invalidate",
77
87
  "require_session",
78
88
  ]
@@ -177,7 +177,9 @@ class MemcachedBackend(BaseCacheBackend):
177
177
  # Try to delete the prefixed key (exact match only)
178
178
  prefixed_key = self._make_key(path)
179
179
  try:
180
- result = await asyncio.to_thread(self.client.delete, prefixed_key, False)
180
+ result = await asyncio.to_thread(
181
+ self.client.delete, prefixed_key, noreply=False
182
+ )
181
183
  except Exception: # noqa: BLE001
182
184
  return 0
183
185
  else:
@@ -183,6 +183,11 @@ class MemoryBackend(BaseCacheBackend):
183
183
  if fnmatch.fnmatch(cache_path, pattern):
184
184
  keys_to_delete.append(key)
185
185
  cleared_count += 1
186
+ elif fnmatch.fnmatch(key, pattern):
187
+ # Non-HTTP-cache key (no separators, e.g. CacheManager/
188
+ # StateManager keys) - match against the raw key.
189
+ keys_to_delete.append(key)
190
+ cleared_count += 1
186
191
 
187
192
  for key in keys_to_delete:
188
193
  del self.cache[key]
@@ -61,6 +61,44 @@ def default_key_builder(request: Request) -> str:
61
61
  return key
62
62
 
63
63
 
64
+ async def invalidate(
65
+ request: Request,
66
+ key_builder: CacheKeyBuilder | None = None,
67
+ ) -> bool:
68
+ """Invalidate the cache entry a ``@cache``-decorated route would use.
69
+
70
+ Builds the same cache key the ``@cache`` decorator would build for
71
+ ``request`` (via ``key_builder`` or ``default_key_builder``) and deletes
72
+ it from the configured backend. Use this after a mutation to bust the
73
+ cache for a specific cached route response.
74
+
75
+ Args:
76
+ request: The request whose cache key should be invalidated. Typically
77
+ a request to the same route/method as the cached one (e.g. build
78
+ it via ``request.app.url_path_for(...)`` for a GET route).
79
+ key_builder: Custom key builder used by the target route's ``@cache``
80
+ decorator, if any. If None, uses ``default_key_builder``.
81
+
82
+ Returns:
83
+ True if a cache entry existed and was deleted, False otherwise.
84
+ """
85
+ builder = key_builder or default_key_builder
86
+ cache_key = builder(request)
87
+
88
+ try:
89
+ cache_backend = BackendProxy.get()
90
+ except BackendNotFoundError:
91
+ return False
92
+
93
+ existing = await cache_backend.get(cache_key)
94
+ if existing is None:
95
+ return False
96
+
97
+ await cache_backend.delete(cache_key)
98
+ logger.debug("Cache INVALIDATE; key=%s", cache_key)
99
+ return True
100
+
101
+
64
102
  class CacheControl:
65
103
  """Manages Cache-Control header directives."""
66
104
 
@@ -113,11 +151,21 @@ async def get_response(
113
151
  msg = "Route not found in request scope"
114
152
  raise CacheXError(msg)
115
153
 
116
- # FastAPI <= 0.136.x stores a DefaultPlaceholder; >= 0.137.0 stores the class directly.
117
- # getattr with a fallback handles both without importing the internal class.
154
+ # A placeholder means this route uses the application default. Unwrap the
155
+ # application value instead of calling the route's DefaultPlaceholder.
156
+ route_response_class = route.response_class
157
+ application_default_response_class = __request.app.router.default_response_class
118
158
  response_class: type[Response] = cast(
119
159
  "type[Response]",
120
- getattr(route.response_class, "value", route.response_class),
160
+ (
161
+ getattr(
162
+ application_default_response_class,
163
+ "value",
164
+ application_default_response_class,
165
+ )
166
+ if hasattr(route_response_class, "value")
167
+ else route_response_class
168
+ ),
121
169
  )
122
170
 
123
171
  # Convert non-Response result to Response using appropriate response_class
@@ -282,9 +330,7 @@ def cache(
282
330
  # StreamingResponse/FileResponse — cannot compute ETag; serve as-is
283
331
  current_response.headers["Cache-Control"] = cache_control
284
332
  return current_response
285
- current_etag = (
286
- f'W/"{hashlib.md5(current_body).hexdigest()}"' # noqa: S324
287
- )
333
+ current_etag = f'W/"{hashlib.md5(current_body).hexdigest()}"' # noqa: S324
288
334
 
289
335
  if client_etag == current_etag:
290
336
  # For no-cache, compare fresh data with client's ETag
@@ -343,7 +389,9 @@ def cache(
343
389
 
344
390
  # Update cache if needed
345
391
  if not cached_data or cached_data.fingerprint != current_etag:
346
- assert current_body is not None # guaranteed by early-return guards above
392
+ assert (
393
+ current_body is not None
394
+ ) # guaranteed by early-return guards above
347
395
  # Store in cache if data changed
348
396
  await cache_backend.set(
349
397
  cache_key,
@@ -1,8 +1,11 @@
1
1
  """Generic application-level cache manager for FastAPI-CacheX."""
2
2
 
3
3
  import hashlib
4
+ import inspect
4
5
  import json
5
6
  import logging
7
+ from collections.abc import Awaitable
8
+ from collections.abc import Callable
6
9
  from typing import Any
7
10
 
8
11
  from .backends.base import BaseCacheBackend
@@ -117,6 +120,67 @@ class CacheManager:
117
120
  """
118
121
  return await self.backend.get(self._cache_key(key)) is not None
119
122
 
123
+ async def get_or_set(
124
+ self,
125
+ key: str,
126
+ factory: Callable[[], Any] | Callable[[], Awaitable[Any]],
127
+ ttl: int | None = None,
128
+ ) -> Any:
129
+ """Get a cached value, computing and storing it via ``factory`` on a miss.
130
+
131
+ ``factory`` is only invoked when ``key`` is missing, expired, or its
132
+ stored content cannot be decoded; on a hit the cached value is
133
+ returned directly. This method does not provide stampede protection:
134
+ concurrent misses for the same key may each invoke ``factory``.
135
+
136
+ Args:
137
+ key: Logical cache key (without the manager's prefix).
138
+ factory: Zero-argument callable (sync or async) that produces the
139
+ JSON-serializable value to cache on a miss.
140
+ ttl: Time-to-live in seconds for a newly created value. If None,
141
+ uses ``self.default_ttl``.
142
+
143
+ Returns:
144
+ The cached value (existing or newly created).
145
+
146
+ Raises:
147
+ TypeError: If the value produced by ``factory`` is not JSON-serializable.
148
+ """
149
+ sentinel = object()
150
+ cached = await self.get(key, default=sentinel)
151
+ if cached is not sentinel:
152
+ return cached
153
+
154
+ if inspect.iscoroutinefunction(factory):
155
+ value = await factory()
156
+ else:
157
+ value = factory()
158
+
159
+ await self.set(key, value, ttl=ttl)
160
+ return value
161
+
162
+ async def clear_pattern(self, pattern: str) -> int:
163
+ """Clear all keys under this manager's namespace matching a glob pattern.
164
+
165
+ Delegates to the backend's native ``clear_pattern`` (e.g. Redis ``SCAN``),
166
+ which can be more efficient than ``clear_prefix``'s full key-space scan.
167
+ Note that backends without key-enumeration support (e.g. Memcached)
168
+ cannot honor this and will return 0 with a ``RuntimeWarning``.
169
+
170
+ Args:
171
+ pattern: Glob pattern (relative to ``self.key_prefix``) to match
172
+ against, e.g. ``"user:*"``.
173
+
174
+ Returns:
175
+ Number of cache entries cleared.
176
+ """
177
+ match_pattern = self._cache_key(pattern)
178
+ cleared = await self.backend.clear_pattern(match_pattern)
179
+ logger.debug(
180
+ "Cache CLEAR_PATTERN; pattern=%s removed=%s", match_pattern, cleared
181
+ )
182
+ return cleared
183
+
120
184
  async def clear_prefix(self, prefix: str | None = None) -> int:
121
185
  """Clear all keys under this manager's namespace matching a sub-prefix.
122
186
 
@@ -129,7 +129,9 @@ async def _get_cached_hits_handler(backend: BaseCacheBackend) -> CacheHitsRespon
129
129
  if method: # Valid cache key
130
130
  # Check if cache entry is expired
131
131
  is_expired = expiry is not None and expiry <= now
132
- ttl_remaining = max(0.0, round(expiry - now, 2)) if expiry is not None else None
132
+ ttl_remaining = (
133
+ max(0.0, round(expiry - now, 2)) if expiry is not None else None
134
+ )
133
135
 
134
136
  cached_hits.append(
135
137
  CacheHitRecord(
@@ -188,7 +190,9 @@ async def _get_cached_records_handler(
188
190
  content = entry.content
189
191
  content_size = len(content) if isinstance(content, (bytes, str)) else 0
190
192
 
191
- ttl_remaining = max(0.0, round(expiry - now, 2)) if expiry is not None else None
193
+ ttl_remaining = (
194
+ max(0.0, round(expiry - now, 2)) if expiry is not None else None
195
+ )
192
196
 
193
197
  content_preview = (
194
198
  content[:100].decode("utf-8", errors="ignore")
@@ -6,12 +6,14 @@ from .dependencies import get_session
6
6
  from .dependencies import get_session_manager
7
7
  from .dependencies import require_session
8
8
  from .manager import SessionManager
9
+ from .middleware import FastAPICacheXSessionMiddleware
9
10
  from .middleware import SessionMiddleware
10
11
  from .models import Session
11
12
  from .models import SessionUser
12
13
  from .proxy import SessionManagerProxy
13
14
 
14
15
  __all__ = [
16
+ "FastAPICacheXSessionMiddleware",
15
17
  "Session",
16
18
  "SessionConfig",
17
19
  "SessionManager",
@@ -3,13 +3,18 @@
3
3
  from typing import Literal
4
4
 
5
5
  from pydantic import BaseModel
6
+ from pydantic import ConfigDict
6
7
  from pydantic import Field
7
8
  from pydantic import SecretStr
8
9
 
10
+ SameSitePolicy = Literal["lax", "strict", "none"]
11
+
9
12
 
10
13
  class SessionConfig(BaseModel):
11
14
  """Session configuration settings."""
12
15
 
16
+ model_config = ConfigDict(extra="forbid")
17
+
13
18
  # Session lifetime
14
19
  session_ttl: int = Field(
15
20
  default=3600,
@@ -87,3 +92,33 @@ class SessionConfig(BaseModel):
87
92
  default="session:",
88
93
  description="Prefix for session keys in backend storage",
89
94
  )
95
+
96
+ # Cookie settings (FastAPICacheXSessionMiddleware only)
97
+ cookie_name: str = Field(
98
+ default="session",
99
+ description="Name of the cookie used to store the session token "
100
+ "(FastAPICacheXSessionMiddleware only)",
101
+ )
102
+ cookie_max_age: int | None = Field(
103
+ default=14 * 24 * 60 * 60,
104
+ description="Max-Age (seconds) for the session cookie; None disables "
105
+ "Max-Age/Expires (session cookie deleted when browser closes)",
106
+ )
107
+ cookie_path: str = Field(
108
+ default="/",
109
+ description="Path attribute for the session cookie",
110
+ )
111
+ cookie_same_site: SameSitePolicy = Field(
112
+ default="lax",
113
+ description="SameSite attribute for the session cookie",
114
+ )
115
+ cookie_https_only: bool = Field(
116
+ default=False,
117
+ description="Whether to set the Secure flag on the session cookie "
118
+ "(cookie only sent over HTTPS)",
119
+ )
120
+ cookie_domain: str | None = Field(
121
+ default=None,
122
+ description="Domain attribute for the session cookie; None omits the "
123
+ "Domain attribute",
124
+ )
@@ -76,7 +76,7 @@ class SessionManager:
76
76
  user: SessionUser,
77
77
  ip_address: str | None = None,
78
78
  user_agent: str | None = None,
79
- **extra_data: dict[str, object],
79
+ **extra_data: object,
80
80
  ) -> tuple[Session, str]:
81
81
  """Create a new session for an authenticated user.
82
82
 
@@ -100,7 +100,7 @@ class SessionManager:
100
100
  self,
101
101
  ip_address: str | None = None,
102
102
  user_agent: str | None = None,
103
- **extra_data: dict[str, object],
103
+ **extra_data: object,
104
104
  ) -> tuple[Session, str]:
105
105
  """Create a new session without user information."""
106
106
  return await self._create_session(
@@ -115,7 +115,7 @@ class SessionManager:
115
115
  user: SessionUser | None,
116
116
  ip_address: str | None = None,
117
117
  user_agent: str | None = None,
118
- **extra_data: dict[str, object],
118
+ **extra_data: object,
119
119
  ) -> tuple[Session, str]:
120
120
  """Internal helper to create and persist a session."""
121
121
  session = Session(
@@ -449,7 +449,9 @@ class SessionManager:
449
449
  ttl = max(ttl, 1) # Ensure at least 1 second
450
450
 
451
451
  fingerprint = self.security.hash_data(value.decode("utf-8"))
452
- await self.backend.set(key, CacheEntry(fingerprint=fingerprint, content=value), ttl=ttl)
452
+ await self.backend.set(
453
+ key, CacheEntry(fingerprint=fingerprint, content=value), ttl=ttl
454
+ )
453
455
  logger.debug("Session saved; id=%s ttl=%s", session.session_id, ttl)
454
456
 
455
457
  async def _load_session(self, session_id: str) -> Session | None: