fastapi-cachex 0.3.4__tar.gz → 0.3.6__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 (40) hide show
  1. fastapi_cachex-0.3.6/PKG-INFO +135 -0
  2. fastapi_cachex-0.3.6/README.md +97 -0
  3. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/__init__.py +19 -0
  4. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/base.py +92 -2
  5. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/codec.py +12 -2
  6. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/memcached.py +94 -6
  7. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/memory.py +47 -12
  8. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/redis.py +50 -0
  9. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/cache.py +232 -23
  10. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/dependencies.py +21 -3
  11. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/proxy.py +30 -2
  12. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/session/config.py +49 -1
  13. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/session/manager.py +1 -1
  14. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/session/middleware.py +48 -46
  15. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/session/security.py +9 -2
  16. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/session/token_serializers.py +22 -4
  17. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/state/manager.py +2 -2
  18. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/types.py +12 -1
  19. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/pyproject.toml +14 -4
  20. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/pyproject.toml.orig +12 -4
  21. fastapi_cachex-0.3.4/PKG-INFO +0 -351
  22. fastapi_cachex-0.3.4/README.md +0 -313
  23. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/__init__.py +0 -0
  24. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/config.py +0 -0
  25. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/directives.py +0 -0
  26. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/exceptions.py +0 -0
  27. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/manager.py +0 -0
  28. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/manager_proxy.py +0 -0
  29. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/py.typed +0 -0
  30. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/routes.py +0 -0
  31. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/session/__init__.py +0 -0
  32. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/session/dependencies.py +0 -0
  33. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/session/exceptions.py +0 -0
  34. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/session/models.py +0 -0
  35. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/session/proxy.py +0 -0
  36. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/state/__init__.py +0 -0
  37. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/state/dependencies.py +0 -0
  38. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/state/exceptions.py +0 -0
  39. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/state/models.py +0 -0
  40. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.6}/fastapi_cachex/state/proxy.py +0 -0
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastapi-cachex
3
+ Version: 0.3.6
4
+ Summary: A caching library for FastAPI with support for Cache-Control, ETag, and multiple backends.
5
+ Keywords: fastapi,cache,etag,cache-control,redis,memcached,in-memory
6
+ Author: allen0099
7
+ Author-email: allen0099 <s96016641@gmail.com>
8
+ License-Expression: Apache-2.0
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Framework :: FastAPI
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
22
+ Requires-Dist: fastapi
23
+ Requires-Dist: itsdangerous
24
+ Requires-Dist: pydantic
25
+ Requires-Dist: pyjwt>=2.9.0 ; extra == 'jwt'
26
+ Requires-Dist: pymemcache ; extra == 'memcache'
27
+ Requires-Dist: redis[hiredis]>=5.3.0 ; extra == 'redis'
28
+ Requires-Dist: orjson ; extra == 'redis'
29
+ Requires-Python: >=3.10
30
+ Project-URL: Homepage, https://github.com/allen0099/FastAPI-CacheX
31
+ Project-URL: Repository, https://github.com/allen0099/FastAPI-CacheX.git
32
+ Project-URL: Issues, https://github.com/allen0099/FastAPI-CacheX/issues
33
+ Project-URL: Documentation, https://fastapi-cachex.readthedocs.io/
34
+ Provides-Extra: jwt
35
+ Provides-Extra: memcache
36
+ Provides-Extra: redis
37
+ Description-Content-Type: text/markdown
38
+
39
+ # FastAPI-Cache X
40
+
41
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
42
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
43
+ [![Tests](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml/badge.svg)](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml)
44
+ [![Coverage Status](https://raw.githubusercontent.com/allen0099/FastAPI-CacheX/coverage-badge/coverage.svg)](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/coverage.yml)
45
+
46
+ [![Downloads](https://static.pepy.tech/badge/fastapi-cachex)](https://pepy.tech/project/fastapi-cachex)
47
+ [![Weekly downloads](https://static.pepy.tech/badge/fastapi-cachex/week)](https://pepy.tech/project/fastapi-cachex)
48
+ [![Monthly downloads](https://static.pepy.tech/badge/fastapi-cachex/month)](https://pepy.tech/project/fastapi-cachex)
49
+
50
+ [![PyPI version](https://img.shields.io/pypi/v/fastapi-cachex.svg?logo=pypi&logoColor=gold&label=PyPI)](https://pypi.org/project/fastapi-cachex)
51
+ [![Python Versions](https://img.shields.io/pypi/pyversions/fastapi-cachex.svg?logo=python&label=Python&logoColor=gold)](https://pypi.org/project/fastapi-cachex/)
52
+
53
+ [English](https://github.com/allen0099/FastAPI-CacheX/blob/master/README.md) | [繁體中文](https://github.com/allen0099/FastAPI-CacheX/blob/master/docs/README.zh-TW.md)
54
+
55
+ A high-performance caching extension for FastAPI, providing comprehensive HTTP caching support and optional session management.
56
+
57
+ **Documentation:** <https://fastapi-cachex.readthedocs.io/en/latest/> — guides and the full API reference.
58
+
59
+ ## Features
60
+
61
+ - **HTTP caching** — a `@cache` decorator for GET routes with `Cache-Control`,
62
+ `ETag` / `If-None-Match` (304) and per-route invalidation.
63
+ - **Application cache** — `CacheManager` for caching arbitrary JSON values in
64
+ your own code, with compute-on-miss `get_or_set()`.
65
+ - **Backends** — in-memory, Redis and Memcached, with atomic counters,
66
+ one-shot values and locks.
67
+ - **Sessions (optional)** — HMAC-signed or JWT session tokens over headers,
68
+ bearer tokens or cookies, with sliding expiration and IP/User-Agent binding.
69
+ - **OAuth state** — one-time state tokens for CSRF protection in OAuth/OIDC flows.
70
+
71
+ ## Installation
72
+
73
+ ```bash
74
+ uv add fastapi-cachex
75
+ ```
76
+
77
+ Everything in the core package works with the in-memory backend. The other
78
+ backends and the optional session transports ship as extras:
79
+
80
+ | Extra | Install | Pulls in | Needed for |
81
+ |-------|---------|----------|------------|
82
+ | `redis` | `uv add "fastapi-cachex[redis]"` | `redis[hiredis]`, `orjson` | `AsyncRedisCacheBackend` |
83
+ | `memcache` | `uv add "fastapi-cachex[memcache]"` | `pymemcache` | `MemcachedBackend` (note: `memcache`, not `memcached`) |
84
+ | `jwt` | `uv add "fastapi-cachex[jwt]"` | `PyJWT` | `SessionConfig(token_format="jwt")` |
85
+
86
+ Extras combine: `uv add "fastapi-cachex[redis,jwt]"`.
87
+
88
+ ## Quick Start
89
+
90
+ ```python
91
+ from fastapi import FastAPI
92
+
93
+ from fastapi_cachex import AppCache, BackendProxy, cache
94
+ from fastapi_cachex.backends import MemoryBackend
95
+
96
+ app = FastAPI()
97
+ BackendProxy.set(MemoryBackend()) # or AsyncRedisCacheBackend / MemcachedBackend
98
+
99
+
100
+ @app.get("/items/{item_id}")
101
+ @cache(ttl=60) # served from the cache for 60 seconds, with ETag revalidation
102
+ async def read_item(item_id: int):
103
+ return {"item_id": item_id}
104
+
105
+
106
+ def build_report() -> dict:
107
+ return {"total": 42} # stands in for something slow
108
+
109
+
110
+ @app.get("/report")
111
+ async def report(cache: AppCache):
112
+ # Cache any JSON value in your own code.
113
+ return await cache.get_or_set("report", build_report, ttl=300)
114
+ ```
115
+
116
+ > [!WARNING]
117
+ > The default cache key carries no user identity. Cache authenticated endpoints
118
+ > with `private=True` or a per-user key builder — see
119
+ > [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#authenticated-endpoints).
120
+
121
+ ## Documentation
122
+
123
+ - [HTTP caching](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/) — the `@cache` decorator, Cache-Control directives, cache keys, invalidation and monitoring routes
124
+ - [Cache flow](https://fastapi-cachex.readthedocs.io/en/latest/CACHE_FLOW/) — what happens inside a cached request
125
+ - [Application cache](https://fastapi-cachex.readthedocs.io/en/latest/APP_CACHE/) — `CacheManager`
126
+ - [Backends](https://fastapi-cachex.readthedocs.io/en/latest/BACKENDS/) — choosing and configuring a backend, atomic primitives
127
+ - [Session management](https://fastapi-cachex.readthedocs.io/en/latest/SESSION/) and [JWT claims](https://fastapi-cachex.readthedocs.io/en/latest/JWT_CLAIMS/)
128
+ - [OAuth state](https://fastapi-cachex.readthedocs.io/en/latest/STATE/) — one-shot OAuth/CSRF state tokens
129
+ - [API reference](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/)
130
+ - [Development guide](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/) and [contributing](https://fastapi-cachex.readthedocs.io/en/latest/CONTRIBUTING/)
131
+ - [Changelog](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md) · [Known limitations and planned work](https://github.com/allen0099/FastAPI-CacheX/issues)
132
+
133
+ ## License
134
+
135
+ This project is licensed under the Apache License 2.0 - see the [LICENSE](https://github.com/allen0099/FastAPI-CacheX/blob/master/LICENSE) file for details.
@@ -0,0 +1,97 @@
1
+ # FastAPI-Cache X
2
+
3
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
4
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
5
+ [![Tests](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml/badge.svg)](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml)
6
+ [![Coverage Status](https://raw.githubusercontent.com/allen0099/FastAPI-CacheX/coverage-badge/coverage.svg)](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/coverage.yml)
7
+
8
+ [![Downloads](https://static.pepy.tech/badge/fastapi-cachex)](https://pepy.tech/project/fastapi-cachex)
9
+ [![Weekly downloads](https://static.pepy.tech/badge/fastapi-cachex/week)](https://pepy.tech/project/fastapi-cachex)
10
+ [![Monthly downloads](https://static.pepy.tech/badge/fastapi-cachex/month)](https://pepy.tech/project/fastapi-cachex)
11
+
12
+ [![PyPI version](https://img.shields.io/pypi/v/fastapi-cachex.svg?logo=pypi&logoColor=gold&label=PyPI)](https://pypi.org/project/fastapi-cachex)
13
+ [![Python Versions](https://img.shields.io/pypi/pyversions/fastapi-cachex.svg?logo=python&label=Python&logoColor=gold)](https://pypi.org/project/fastapi-cachex/)
14
+
15
+ [English](https://github.com/allen0099/FastAPI-CacheX/blob/master/README.md) | [繁體中文](https://github.com/allen0099/FastAPI-CacheX/blob/master/docs/README.zh-TW.md)
16
+
17
+ A high-performance caching extension for FastAPI, providing comprehensive HTTP caching support and optional session management.
18
+
19
+ **Documentation:** <https://fastapi-cachex.readthedocs.io/en/latest/> — guides and the full API reference.
20
+
21
+ ## Features
22
+
23
+ - **HTTP caching** — a `@cache` decorator for GET routes with `Cache-Control`,
24
+ `ETag` / `If-None-Match` (304) and per-route invalidation.
25
+ - **Application cache** — `CacheManager` for caching arbitrary JSON values in
26
+ your own code, with compute-on-miss `get_or_set()`.
27
+ - **Backends** — in-memory, Redis and Memcached, with atomic counters,
28
+ one-shot values and locks.
29
+ - **Sessions (optional)** — HMAC-signed or JWT session tokens over headers,
30
+ bearer tokens or cookies, with sliding expiration and IP/User-Agent binding.
31
+ - **OAuth state** — one-time state tokens for CSRF protection in OAuth/OIDC flows.
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ uv add fastapi-cachex
37
+ ```
38
+
39
+ Everything in the core package works with the in-memory backend. The other
40
+ backends and the optional session transports ship as extras:
41
+
42
+ | Extra | Install | Pulls in | Needed for |
43
+ |-------|---------|----------|------------|
44
+ | `redis` | `uv add "fastapi-cachex[redis]"` | `redis[hiredis]`, `orjson` | `AsyncRedisCacheBackend` |
45
+ | `memcache` | `uv add "fastapi-cachex[memcache]"` | `pymemcache` | `MemcachedBackend` (note: `memcache`, not `memcached`) |
46
+ | `jwt` | `uv add "fastapi-cachex[jwt]"` | `PyJWT` | `SessionConfig(token_format="jwt")` |
47
+
48
+ Extras combine: `uv add "fastapi-cachex[redis,jwt]"`.
49
+
50
+ ## Quick Start
51
+
52
+ ```python
53
+ from fastapi import FastAPI
54
+
55
+ from fastapi_cachex import AppCache, BackendProxy, cache
56
+ from fastapi_cachex.backends import MemoryBackend
57
+
58
+ app = FastAPI()
59
+ BackendProxy.set(MemoryBackend()) # or AsyncRedisCacheBackend / MemcachedBackend
60
+
61
+
62
+ @app.get("/items/{item_id}")
63
+ @cache(ttl=60) # served from the cache for 60 seconds, with ETag revalidation
64
+ async def read_item(item_id: int):
65
+ return {"item_id": item_id}
66
+
67
+
68
+ def build_report() -> dict:
69
+ return {"total": 42} # stands in for something slow
70
+
71
+
72
+ @app.get("/report")
73
+ async def report(cache: AppCache):
74
+ # Cache any JSON value in your own code.
75
+ return await cache.get_or_set("report", build_report, ttl=300)
76
+ ```
77
+
78
+ > [!WARNING]
79
+ > The default cache key carries no user identity. Cache authenticated endpoints
80
+ > with `private=True` or a per-user key builder — see
81
+ > [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#authenticated-endpoints).
82
+
83
+ ## Documentation
84
+
85
+ - [HTTP caching](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/) — the `@cache` decorator, Cache-Control directives, cache keys, invalidation and monitoring routes
86
+ - [Cache flow](https://fastapi-cachex.readthedocs.io/en/latest/CACHE_FLOW/) — what happens inside a cached request
87
+ - [Application cache](https://fastapi-cachex.readthedocs.io/en/latest/APP_CACHE/) — `CacheManager`
88
+ - [Backends](https://fastapi-cachex.readthedocs.io/en/latest/BACKENDS/) — choosing and configuring a backend, atomic primitives
89
+ - [Session management](https://fastapi-cachex.readthedocs.io/en/latest/SESSION/) and [JWT claims](https://fastapi-cachex.readthedocs.io/en/latest/JWT_CLAIMS/)
90
+ - [OAuth state](https://fastapi-cachex.readthedocs.io/en/latest/STATE/) — one-shot OAuth/CSRF state tokens
91
+ - [API reference](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/)
92
+ - [Development guide](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/) and [contributing](https://fastapi-cachex.readthedocs.io/en/latest/CONTRIBUTING/)
93
+ - [Changelog](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md) · [Known limitations and planned work](https://github.com/allen0099/FastAPI-CacheX/issues)
94
+
95
+ ## License
96
+
97
+ This project is licensed under the Apache License 2.0 - see the [LICENSE](https://github.com/allen0099/FastAPI-CacheX/blob/master/LICENSE) file for details.
@@ -1,6 +1,8 @@
1
1
  """FastAPI-CacheX: A powerful and flexible caching extension for FastAPI."""
2
2
 
3
3
  import logging
4
+ from importlib.metadata import PackageNotFoundError
5
+ from importlib.metadata import version
4
6
 
5
7
  from .cache import cache as cache
6
8
  from .cache import default_key_builder as default_key_builder
@@ -41,6 +43,22 @@ from .state import StateManagerProxy as StateManagerProxy
41
43
  from .state import get_state_manager as get_state_manager
42
44
  from .types import CacheKeyBuilder as CacheKeyBuilder
43
45
 
46
+
47
+ def _read_version() -> str:
48
+ """Return the installed distribution's version.
49
+
50
+ Importing from a source tree that was never installed leaves no metadata to
51
+ read; reporting a development version there is part of the contract, so this
52
+ lives in a function the tests can drive rather than behind a coverage pragma.
53
+ """
54
+ try:
55
+ return version("fastapi-cachex")
56
+ except PackageNotFoundError:
57
+ return "0.0.0.dev0"
58
+
59
+
60
+ __version__ = _read_version()
61
+
44
62
  _package_logger = logging.getLogger("fastapi_cachex")
45
63
  _package_logger.addHandler(
46
64
  logging.NullHandler()
@@ -74,6 +92,7 @@ __all__ = [
74
92
  "StateManager",
75
93
  "StateManagerDep",
76
94
  "StateManagerProxy",
95
+ "__version__",
77
96
  "add_routes",
78
97
  "cache",
79
98
  "default_key_builder",
@@ -1,15 +1,40 @@
1
1
  """Base cache backend interface and abstract implementation."""
2
2
 
3
+ import warnings
3
4
  from abc import ABC
4
5
  from abc import abstractmethod
5
6
  from collections.abc import Iterable
6
7
  from typing import Any
7
8
 
9
+ from fastapi_cachex.types import CACHE_KEY_SEPARATOR
8
10
  from fastapi_cachex.types import CacheEntry
9
11
  from fastapi_cachex.types import counter_entry
10
12
  from fastapi_cachex.types import counter_value
11
13
 
12
14
 
15
+ def warn_if_path_shaped(pattern: str, cleared: int) -> None:
16
+ """Warn when a ``clear_pattern`` that cleared nothing was written as a path.
17
+
18
+ Matching happens against the whole key, so a pattern like ``/users/*``
19
+ cannot match an HTTP cache entry and the call quietly reports zero cleared
20
+ — indistinguishable from a cache that was already empty, which is exactly
21
+ the outcome the caller was trying to avoid. Only the combination of "looks
22
+ like a bare path" and "matched nothing" warns, so keys that really are
23
+ paths (stored directly through ``set``) stay silent when they work.
24
+ """
25
+ if cleared == 0 and pattern.startswith("/") and CACHE_KEY_SEPARATOR not in pattern:
26
+ warnings.warn(
27
+ f"clear_pattern({pattern!r}) cleared nothing. Patterns match whole "
28
+ f"cache keys, which look like 'method{CACHE_KEY_SEPARATOR}host"
29
+ f"{CACHE_KEY_SEPARATOR}path{CACHE_KEY_SEPARATOR}query', so a bare "
30
+ "path matches no HTTP cache entry. Use clear_path(path, "
31
+ "include_params=True) to clear by path, or write the whole key out "
32
+ f"as 'GET{CACHE_KEY_SEPARATOR}*{CACHE_KEY_SEPARATOR}{pattern}'.",
33
+ RuntimeWarning,
34
+ stacklevel=3,
35
+ )
36
+
37
+
13
38
  class BaseCacheBackend(ABC):
14
39
  """Base class for all cache backends."""
15
40
 
@@ -58,6 +83,58 @@ class BaseCacheBackend(ABC):
58
83
  await self.delete(key)
59
84
  return value
60
85
 
86
+ async def set_if_absent(
87
+ self, key: str, value: CacheEntry, ttl: int | None = None
88
+ ) -> bool:
89
+ """Store ``value`` only when ``key`` does not exist yet.
90
+
91
+ The building block for locks and slots: of several concurrent callers
92
+ exactly one stores its value and gets ``True``, every other caller
93
+ gets ``False`` and the stored value is left untouched. An expired key
94
+ counts as absent. Pair it with ``delete_if_equals`` to release only
95
+ what you still hold.
96
+
97
+ The base implementation is a best-effort, NON-atomic get-then-set
98
+ fallback for third-party backends; the built-in backends override it
99
+ with an atomic implementation.
100
+
101
+ Args:
102
+ key: Cache key to claim
103
+ value: Entry to store, typically carrying a unique owner token
104
+ ttl: Time to live in seconds (``None`` = never expires)
105
+
106
+ Returns:
107
+ Whether ``value`` was stored
108
+ """
109
+ if await self.get(key) is not None:
110
+ return False
111
+ await self.set(key, value, ttl=ttl)
112
+ return True
113
+
114
+ async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
115
+ """Remove ``key`` only while it still holds ``expected``.
116
+
117
+ Releasing a lock with a plain ``delete`` is unsafe: if the holder's
118
+ entry expired and someone else claimed the key in the meantime, the
119
+ delete removes the new holder's entry. Comparing against the value the
120
+ caller stored makes the release a no-op in that case.
121
+
122
+ The base implementation is a best-effort, NON-atomic get-compare-delete
123
+ fallback for third-party backends; the built-in backends override it
124
+ with an atomic implementation.
125
+
126
+ Args:
127
+ key: Cache key to release
128
+ expected: The entry the caller stored (compared with ``==``)
129
+
130
+ Returns:
131
+ Whether the entry was removed
132
+ """
133
+ if await self.get(key) != expected:
134
+ return False
135
+ await self.delete(key)
136
+ return True
137
+
61
138
  async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> int:
62
139
  """Atomically add ``delta`` to the integer counter stored at ``key``.
63
140
 
@@ -107,10 +184,23 @@ class BaseCacheBackend(ABC):
107
184
 
108
185
  @abstractmethod
109
186
  async def clear_pattern(self, pattern: str) -> int:
110
- """Clear cached responses matching a pattern.
187
+ """Clear cached entries whose key matches a glob pattern.
188
+
189
+ The pattern is matched against the whole logical key — the key as the
190
+ caller sees it, without whatever prefix the backend adds internally.
191
+ HTTP cache keys are ``method|||host|||path|||query``, so matching a
192
+ path means writing the other components out::
193
+
194
+ await backend.clear_pattern("GET|||*|||/users/*")
195
+ await backend.clear_pattern("cache:user:*") # a CacheManager key
196
+
197
+ To clear by path, prefer ``clear_path(path, include_params=...)``: it
198
+ is built for exactly that and needs no knowledge of the key layout.
199
+ Implementations report a path written here through
200
+ ``warn_if_path_shaped`` rather than silently clearing nothing.
111
201
 
112
202
  Args:
113
- pattern: A glob pattern to match cache keys against (e.g., "/users/*")
203
+ pattern: A glob pattern to match whole cache keys against
114
204
 
115
205
  Returns:
116
206
  Number of cache entries cleared
@@ -4,14 +4,17 @@ Both backends store a ``CacheEntry`` as a JSON document; ``orjson`` is used when
4
4
  it is installed and the standard library ``json`` module otherwise.
5
5
  """
6
6
 
7
+ from fastapi_cachex.types import DEFAULT_STATUS_CODE
7
8
  from fastapi_cachex.types import CacheEntry
8
9
  from fastapi_cachex.types import counter_entry
9
10
 
10
11
  try:
11
12
  import orjson as json
12
13
 
13
- except ImportError: # pragma: no cover
14
- import json # type: ignore[no-redef] # pragma: no cover
14
+ # Resolved once at import time; whichever branch this interpreter takes, the
15
+ # other one cannot be reached again in the same process.
16
+ except ImportError: # pragma: no cover - import-time, environment dependent
17
+ import json # type: ignore[no-redef]
15
18
 
16
19
  # ``json.loads`` (either implementation) raises ``ValueError`` subclasses for bad
17
20
  # JSON; ``KeyError``/``TypeError``/``AttributeError`` cover documents whose shape
@@ -30,6 +33,8 @@ def encode_entry(entry: CacheEntry) -> bytes:
30
33
  "fingerprint": entry.fingerprint,
31
34
  "content": entry.content.decode("latin-1"),
32
35
  "media_type": entry.media_type,
36
+ "status_code": entry.status_code,
37
+ "headers": entry.headers,
33
38
  },
34
39
  )
35
40
  # orjson returns bytes, stdlib json returns str
@@ -52,6 +57,9 @@ def decode_entry(raw: str | bytes | None) -> CacheEntry | None:
52
57
  written by ``encode_entry`` (corrupt JSON, missing fields, non-string
53
58
  content) yields ``None``, so callers can treat every malformed value as a
54
59
  cache miss.
60
+
61
+ Documents written before entries carried a status code and headers simply
62
+ lack those keys and decode to a plain ``200`` with no extra headers.
55
63
  """
56
64
  if raw is None:
57
65
  return None
@@ -64,6 +72,8 @@ def decode_entry(raw: str | bytes | None) -> CacheEntry | None:
64
72
  fingerprint=data["fingerprint"],
65
73
  content=data["content"].encode("latin-1"),
66
74
  media_type=data.get("media_type"),
75
+ status_code=data.get("status_code", DEFAULT_STATUS_CODE),
76
+ headers=data.get("headers"),
67
77
  )
68
78
  except _DECODE_ERRORS:
69
79
  return None
@@ -1,7 +1,9 @@
1
1
  """Memcached cache backend implementation."""
2
2
 
3
3
  import asyncio
4
+ import hashlib
4
5
  import logging
6
+ import time
5
7
  import warnings
6
8
 
7
9
  from fastapi_cachex.backends.codec import decode_entry
@@ -16,6 +18,30 @@ logger = logging.getLogger(__name__)
16
18
  # Default Memcached key prefix for fastapi-cachex
17
19
  DEFAULT_MEMCACHE_PREFIX = "fastapi_cachex:"
18
20
 
21
+ # Memcached accepts keys of at most 250 bytes, and pymemcache rejects any key
22
+ # containing whitespace, control characters or non-ASCII bytes. What is left is
23
+ # the printable ASCII range with the space removed.
24
+ _MAX_KEY_BYTES = 250
25
+ _LEGAL_KEY_BYTES = frozenset(range(0x21, 0x7F))
26
+
27
+ # An exptime above 30 days is read by Memcached as an absolute Unix timestamp,
28
+ # not as a duration, so a longer TTL has to be converted before it is sent.
29
+ _MAX_RELATIVE_TTL = 30 * 24 * 60 * 60
30
+
31
+
32
+ def _expiry(ttl: int | None) -> int:
33
+ """Convert a TTL in seconds to the exptime Memcached expects.
34
+
35
+ Anything past the 30-day boundary is sent as an absolute timestamp;
36
+ passing it through as a duration would have Memcached read it as a moment
37
+ in 1970 and expire the entry immediately. ``None`` means no expiry.
38
+ """
39
+ if ttl is None:
40
+ return 0
41
+ if ttl > _MAX_RELATIVE_TTL:
42
+ return int(time.time()) + ttl
43
+ return ttl
44
+
19
45
 
20
46
  class MemcachedBackend(BaseCacheBackend):
21
47
  """Memcached backend implementation.
@@ -67,8 +93,26 @@ class MemcachedBackend(BaseCacheBackend):
67
93
  self.key_prefix = key_prefix
68
94
 
69
95
  def _make_key(self, key: str) -> str:
70
- """Add prefix to cache key."""
71
- return f"{self.key_prefix}{key}"
96
+ """Namespace a cache key, hashing it when Memcached would refuse it.
97
+
98
+ pymemcache raises ``MemcacheIllegalInputError`` for a key over 250
99
+ bytes or carrying whitespace, control characters or non-ASCII bytes,
100
+ and nothing catches it on the way out — so ordinary traffic could turn
101
+ into a 500. ASGI percent-decodes the path, so `/foo%20bar` alone builds
102
+ a key with a literal space in it, and a long query string easily runs
103
+ past 250 bytes.
104
+
105
+ A key Memcached would accept is returned byte-for-byte, which keeps
106
+ entries written by earlier versions readable; only the rest collapse to
107
+ a SHA-256 digest of the whole namespaced key.
108
+ """
109
+ prefixed = f"{self.key_prefix}{key}"
110
+ encoded = prefixed.encode("utf-8")
111
+ if len(encoded) <= _MAX_KEY_BYTES and _LEGAL_KEY_BYTES.issuperset(encoded):
112
+ return prefixed
113
+ digest = hashlib.sha256(encoded).hexdigest()
114
+ logger.debug("Memcached key hashed; key=%s digest=%s", key, digest)
115
+ return f"{self.key_prefix}{digest}"
72
116
 
73
117
  async def get(self, key: str) -> CacheEntry | None:
74
118
  """Get value from cache.
@@ -97,9 +141,8 @@ class MemcachedBackend(BaseCacheBackend):
97
141
  value: CacheEntry instance to store
98
142
  ttl: Time to live in seconds
99
143
  """
100
- expire = ttl if ttl is not None else 0
101
144
  await asyncio.to_thread(
102
- self.client.set, self._make_key(key), encode_entry(value), expire
145
+ self.client.set, self._make_key(key), encode_entry(value), _expiry(ttl)
103
146
  )
104
147
  logger.debug("Memcached SET; key=%s ttl=%s", key, ttl)
105
148
 
@@ -124,6 +167,51 @@ class MemcachedBackend(BaseCacheBackend):
124
167
  logger.debug("Memcached GET_AND_DELETE HIT; key=%s", key)
125
168
  return decode_entry(raw)
126
169
 
170
+ async def set_if_absent(
171
+ self, key: str, value: CacheEntry, ttl: int | None = None
172
+ ) -> bool:
173
+ """Atomically store ``value`` unless ``key`` exists (see base class).
174
+
175
+ Memcached's ``ADD`` is exactly this operation.
176
+ """
177
+ stored = await asyncio.to_thread(
178
+ self.client.add,
179
+ self._make_key(key),
180
+ encode_entry(value),
181
+ _expiry(ttl),
182
+ noreply=False,
183
+ )
184
+ logger.debug(
185
+ "Memcached SET_IF_ABSENT %s; key=%s ttl=%s",
186
+ "STORED" if stored else "EXISTS",
187
+ key,
188
+ ttl,
189
+ )
190
+ return bool(stored)
191
+
192
+ async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
193
+ """Atomically remove ``key`` while it holds ``expected`` (see base class).
194
+
195
+ The classic protocol's DELETE takes no CAS token, so the release is a
196
+ CAS write with a negative exptime, which Memcached treats as "expired
197
+ immediately": it succeeds only if nothing wrote the key since ``GETS``
198
+ read the value that was compared.
199
+ """
200
+ prefixed_key = self._make_key(key)
201
+ raw, cas_token = await asyncio.to_thread(self.client.gets, prefixed_key)
202
+ if raw is None or decode_entry(raw) != expected:
203
+ logger.debug("Memcached DELETE_IF_EQUALS MISMATCH; key=%s", key)
204
+ return False
205
+ deleted = await asyncio.to_thread(
206
+ self.client.cas, prefixed_key, b"", cas_token, -1, noreply=False
207
+ )
208
+ logger.debug(
209
+ "Memcached DELETE_IF_EQUALS %s; key=%s",
210
+ "HIT" if deleted else "LOST RACE",
211
+ key,
212
+ )
213
+ return bool(deleted)
214
+
127
215
  def _add_delta(self, prefixed_key: str, delta: int) -> int | None:
128
216
  """Apply ``delta`` with INCR/DECR; ``None`` when the key does not exist."""
129
217
  if delta < 0:
@@ -147,13 +235,13 @@ class MemcachedBackend(BaseCacheBackend):
147
235
  # No counter yet: ADD is atomic and a no-op when a concurrent
148
236
  # call created it first, so the retry always finds a counter.
149
237
  await asyncio.to_thread(
150
- self.client.add, prefixed_key, b"0", ttl or 0, noreply=False
238
+ self.client.add, prefixed_key, b"0", _expiry(ttl), noreply=False
151
239
  )
152
240
  value = await asyncio.to_thread(self._add_delta, prefixed_key, delta)
153
241
  except MemcacheClientError as e:
154
242
  msg = "Cache key holds a value that is not a counter"
155
243
  raise CacheXError(msg) from e
156
- if value is None: # pragma: no cover - the counter expired mid-call
244
+ if value is None:
157
245
  msg = "Counter vanished between ADD and INCR"
158
246
  raise CacheXError(msg)
159
247
  logger.debug("Memcached INCREMENT; key=%s value=%s ttl=%s", key, value, ttl)