fastapi-cachex 0.3.5__tar.gz → 0.3.7__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.7/PKG-INFO +135 -0
  2. fastapi_cachex-0.3.7/README.md +97 -0
  3. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/base.py +80 -3
  4. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/memcached.py +63 -10
  5. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/memory.py +35 -0
  6. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/redis.py +105 -17
  7. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/cache.py +127 -25
  8. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/dependencies.py +13 -7
  9. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/manager.py +62 -14
  10. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/proxy.py +28 -0
  11. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/routes.py +35 -17
  12. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/session/__init__.py +4 -0
  13. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/session/config.py +72 -3
  14. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/session/dependencies.py +46 -5
  15. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/session/manager.py +30 -12
  16. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/session/middleware.py +56 -15
  17. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/session/token_serializers.py +18 -0
  18. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/state/manager.py +55 -16
  19. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/pyproject.toml +8 -2
  20. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/pyproject.toml.orig +9 -2
  21. fastapi_cachex-0.3.5/PKG-INFO +0 -552
  22. fastapi_cachex-0.3.5/README.md +0 -515
  23. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/__init__.py +0 -0
  24. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/__init__.py +0 -0
  25. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/codec.py +0 -0
  26. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/config.py +0 -0
  27. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/directives.py +0 -0
  28. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/exceptions.py +0 -0
  29. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/manager_proxy.py +0 -0
  30. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/py.typed +0 -0
  31. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/session/exceptions.py +0 -0
  32. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/session/models.py +0 -0
  33. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/session/proxy.py +0 -0
  34. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/session/security.py +0 -0
  35. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/state/__init__.py +0 -0
  36. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/state/dependencies.py +0 -0
  37. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/state/exceptions.py +0 -0
  38. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/state/models.py +0 -0
  39. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/state/proxy.py +0 -0
  40. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.7}/fastapi_cachex/types.py +0 -0
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastapi-cachex
3
+ Version: 0.3.7
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/i18n/zh-TW/docs/index.md)
54
+
55
+ A high-performance caching extension for FastAPI: a server-side response cache with `Cache-Control` and `ETag` support, application-level caching, 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()` and atomic store-if-absent `add()`.
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/i18n/zh-TW/docs/index.md)
16
+
17
+ A high-performance caching extension for FastAPI: a server-side response cache with `Cache-Control` and `ETag` support, application-level caching, 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()` and atomic store-if-absent `add()`.
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.
@@ -35,6 +35,23 @@ def warn_if_path_shaped(pattern: str, cleared: int) -> None:
35
35
  )
36
36
 
37
37
 
38
+ def validate_ttl(ttl: int | None) -> int | None:
39
+ """Return ``ttl`` if it is ``None`` or a positive number of seconds.
40
+
41
+ Every backend reads ``0`` or a negative TTL differently (Memcached treats
42
+ ``0`` as "never expires", Redis rejects it, the memory backend expires the
43
+ entry at once), so the library refuses them instead of letting the
44
+ meaning depend on the backend. ``None`` is the way to say "no expiry".
45
+
46
+ Raises:
47
+ ValueError: If ``ttl`` is zero or negative
48
+ """
49
+ if ttl is not None and ttl <= 0:
50
+ msg = f"ttl must be a positive number of seconds or None, got {ttl!r}"
51
+ raise ValueError(msg)
52
+ return ttl
53
+
54
+
38
55
  class BaseCacheBackend(ABC):
39
56
  """Base class for all cache backends."""
40
57
 
@@ -44,7 +61,12 @@ class BaseCacheBackend(ABC):
44
61
 
45
62
  @abstractmethod
46
63
  async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None:
47
- """Store a response in the cache."""
64
+ """Store a response in the cache.
65
+
66
+ ``ttl`` is ``None`` (never expires) or a positive number of seconds;
67
+ implementations should pass it through ``validate_ttl`` so zero and
68
+ negative values are rejected the same way on every backend.
69
+ """
48
70
 
49
71
  @abstractmethod
50
72
  async def delete(self, key: str) -> None:
@@ -55,8 +77,9 @@ class BaseCacheBackend(ABC):
55
77
 
56
78
  The base implementation deletes one key at a time and reports how
57
79
  many were attempted, since ``delete`` does not say whether the key
58
- existed. The built-in backends override it with a single batched
59
- operation that counts what was actually removed.
80
+ existed. The memory and Redis backends override it with a single
81
+ batched operation that counts what was actually removed; Memcached
82
+ keeps this per-key loop.
60
83
  """
61
84
  count = 0
62
85
  for key in keys:
@@ -83,6 +106,59 @@ class BaseCacheBackend(ABC):
83
106
  await self.delete(key)
84
107
  return value
85
108
 
109
+ async def set_if_absent(
110
+ self, key: str, value: CacheEntry, ttl: int | None = None
111
+ ) -> bool:
112
+ """Store ``value`` only when ``key`` does not exist yet.
113
+
114
+ The building block for locks and slots: of several concurrent callers
115
+ exactly one stores its value and gets ``True``, every other caller
116
+ gets ``False`` and the stored value is left untouched. An expired key
117
+ counts as absent. Pair it with ``delete_if_equals`` to release only
118
+ what you still hold.
119
+
120
+ The base implementation is a best-effort, NON-atomic get-then-set
121
+ fallback for third-party backends; the built-in backends override it
122
+ with an atomic implementation.
123
+
124
+ Args:
125
+ key: Cache key to claim
126
+ value: Entry to store, typically carrying a unique owner token
127
+ ttl: Time to live in seconds (``None`` = never expires)
128
+
129
+ Returns:
130
+ Whether ``value`` was stored
131
+ """
132
+ validate_ttl(ttl)
133
+ if await self.get(key) is not None:
134
+ return False
135
+ await self.set(key, value, ttl=ttl)
136
+ return True
137
+
138
+ async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
139
+ """Remove ``key`` only while it still holds ``expected``.
140
+
141
+ Releasing a lock with a plain ``delete`` is unsafe: if the holder's
142
+ entry expired and someone else claimed the key in the meantime, the
143
+ delete removes the new holder's entry. Comparing against the value the
144
+ caller stored makes the release a no-op in that case.
145
+
146
+ The base implementation is a best-effort, NON-atomic get-compare-delete
147
+ fallback for third-party backends; the built-in backends override it
148
+ with an atomic implementation.
149
+
150
+ Args:
151
+ key: Cache key to release
152
+ expected: The entry the caller stored (compared with ``==``)
153
+
154
+ Returns:
155
+ Whether the entry was removed
156
+ """
157
+ if await self.get(key) != expected:
158
+ return False
159
+ await self.delete(key)
160
+ return True
161
+
86
162
  async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> int:
87
163
  """Atomically add ``delta`` to the integer counter stored at ``key``.
88
164
 
@@ -109,6 +185,7 @@ class BaseCacheBackend(ABC):
109
185
  Raises:
110
186
  CacheXError: If ``key`` holds a cached response instead of a counter
111
187
  """
188
+ validate_ttl(ttl)
112
189
  current = await self.get(key)
113
190
  value = delta if current is None else counter_value(current) + delta
114
191
  await self.set(key, counter_entry(value), ttl=ttl)
@@ -12,6 +12,7 @@ from fastapi_cachex.exceptions import CacheXError
12
12
  from fastapi_cachex.types import CacheEntry
13
13
 
14
14
  from .base import BaseCacheBackend
15
+ from .base import validate_ttl
15
16
 
16
17
  logger = logging.getLogger(__name__)
17
18
 
@@ -141,6 +142,7 @@ class MemcachedBackend(BaseCacheBackend):
141
142
  value: CacheEntry instance to store
142
143
  ttl: Time to live in seconds
143
144
  """
145
+ validate_ttl(ttl)
144
146
  await asyncio.to_thread(
145
147
  self.client.set, self._make_key(key), encode_entry(value), _expiry(ttl)
146
148
  )
@@ -167,6 +169,52 @@ class MemcachedBackend(BaseCacheBackend):
167
169
  logger.debug("Memcached GET_AND_DELETE HIT; key=%s", key)
168
170
  return decode_entry(raw)
169
171
 
172
+ async def set_if_absent(
173
+ self, key: str, value: CacheEntry, ttl: int | None = None
174
+ ) -> bool:
175
+ """Atomically store ``value`` unless ``key`` exists (see base class).
176
+
177
+ Memcached's ``ADD`` is exactly this operation.
178
+ """
179
+ validate_ttl(ttl)
180
+ stored = await asyncio.to_thread(
181
+ self.client.add,
182
+ self._make_key(key),
183
+ encode_entry(value),
184
+ _expiry(ttl),
185
+ noreply=False,
186
+ )
187
+ logger.debug(
188
+ "Memcached SET_IF_ABSENT %s; key=%s ttl=%s",
189
+ "STORED" if stored else "EXISTS",
190
+ key,
191
+ ttl,
192
+ )
193
+ return bool(stored)
194
+
195
+ async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
196
+ """Atomically remove ``key`` while it holds ``expected`` (see base class).
197
+
198
+ The classic protocol's DELETE takes no CAS token, so the release is a
199
+ CAS write with a negative exptime, which Memcached treats as "expired
200
+ immediately": it succeeds only if nothing wrote the key since ``GETS``
201
+ read the value that was compared.
202
+ """
203
+ prefixed_key = self._make_key(key)
204
+ raw, cas_token = await asyncio.to_thread(self.client.gets, prefixed_key)
205
+ if raw is None or decode_entry(raw) != expected:
206
+ logger.debug("Memcached DELETE_IF_EQUALS MISMATCH; key=%s", key)
207
+ return False
208
+ deleted = await asyncio.to_thread(
209
+ self.client.cas, prefixed_key, b"", cas_token, -1, noreply=False
210
+ )
211
+ logger.debug(
212
+ "Memcached DELETE_IF_EQUALS %s; key=%s",
213
+ "HIT" if deleted else "LOST RACE",
214
+ key,
215
+ )
216
+ return bool(deleted)
217
+
170
218
  def _add_delta(self, prefixed_key: str, delta: int) -> int | None:
171
219
  """Apply ``delta`` with INCR/DECR; ``None`` when the key does not exist."""
172
220
  if delta < 0:
@@ -181,6 +229,7 @@ class MemcachedBackend(BaseCacheBackend):
181
229
  Memcached counters are unsigned, so a negative ``delta`` uses DECR,
182
230
  which stops at 0 instead of going negative.
183
231
  """
232
+ validate_ttl(ttl)
184
233
  from pymemcache.exceptions import MemcacheClientError
185
234
 
186
235
  prefixed_key = self._make_key(key)
@@ -215,13 +264,16 @@ class MemcachedBackend(BaseCacheBackend):
215
264
  async def clear(self) -> None:
216
265
  """Clear all values from cache.
217
266
 
218
- Note: Memcached's flush_all affects the entire server.
219
- Consider using clear_path() with your specific keys instead.
267
+ Note: Memcached's flush_all affects the entire server, including
268
+ other applications' keys. Memcached cannot enumerate keys, so there
269
+ is no way to clear only this namespace; delete keys you know by name
270
+ with ``delete()``/``delete_many()`` instead.
220
271
  """
221
272
  warnings.warn(
222
273
  "Memcached.clear() flushes ALL cached data from the server, "
223
- "affecting other applications. Consider using clear_path() instead "
224
- "to selectively remove only this namespace's keys.",
274
+ "affecting other applications. Memcached cannot enumerate keys, so "
275
+ "this namespace cannot be cleared on its own; delete known keys "
276
+ "with delete() or delete_many() instead.",
225
277
  RuntimeWarning,
226
278
  stacklevel=2,
227
279
  )
@@ -231,14 +283,15 @@ class MemcachedBackend(BaseCacheBackend):
231
283
  async def clear_path(self, path: str, include_params: bool = False) -> int:
232
284
  """Clear cached responses for a specific path.
233
285
 
234
- Note: Memcached does not support pattern-based queries.
235
- This method can only delete keys if the exact key is provided,
236
- or will try to match keys in memory if include_params=True.
237
- For better pattern support, consider using Redis backend.
286
+ Note: Memcached does not support pattern-based queries, so this
287
+ only deletes the key that is exactly ``path``. HTTP route keys
288
+ (``method|||host|||path|||query``) are not matched. For path-based
289
+ clearing, use the Redis or memory backend.
238
290
 
239
291
  Args:
240
- path: The path to clear cache for
241
- include_params: Currently unsupported (Memcached limitation)
292
+ path: The exact key to delete
293
+ include_params: Unsupported; emits a ``RuntimeWarning`` and is
294
+ otherwise ignored
242
295
 
243
296
  Returns:
244
297
  Number of cache entries cleared (0 or 1 for exact match only)
@@ -14,6 +14,7 @@ from fastapi_cachex.types import counter_entry
14
14
  from fastapi_cachex.types import counter_value
15
15
 
16
16
  from .base import BaseCacheBackend
17
+ from .base import validate_ttl
17
18
  from .base import warn_if_path_shaped
18
19
 
19
20
  logger = logging.getLogger(__name__)
@@ -123,6 +124,7 @@ class MemoryBackend(BaseCacheBackend):
123
124
  value: Content to cache
124
125
  ttl: Time to live in seconds (None = never expires)
125
126
  """
127
+ validate_ttl(ttl)
126
128
  self._ensure_cleanup_started()
127
129
 
128
130
  async with self.lock:
@@ -157,12 +159,45 @@ class MemoryBackend(BaseCacheBackend):
157
159
  logger.debug("Memory cache GET_AND_DELETE HIT; key=%s", key)
158
160
  return item.value
159
161
 
162
+ async def set_if_absent(
163
+ self, key: str, value: CacheEntry, ttl: int | None = None
164
+ ) -> bool:
165
+ """Atomically store ``value`` unless ``key`` exists (see base class)."""
166
+ validate_ttl(ttl)
167
+ self._ensure_cleanup_started()
168
+
169
+ async with self.lock:
170
+ now = time.time()
171
+ item = self.cache.get(key)
172
+ if item is not None and _is_live(item, now):
173
+ logger.debug("Memory cache SET_IF_ABSENT EXISTS; key=%s", key)
174
+ return False
175
+ expiry = now + ttl if ttl is not None else None
176
+ self.cache[key] = CacheItem(value=value, expiry=expiry)
177
+ logger.debug("Memory cache SET_IF_ABSENT STORED; key=%s ttl=%s", key, ttl)
178
+ return True
179
+
180
+ async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
181
+ """Atomically remove ``key`` while it holds ``expected`` (see base class)."""
182
+ async with self.lock:
183
+ item = self.cache.get(key)
184
+ if item is None or not _is_live(item, time.time()):
185
+ logger.debug("Memory cache DELETE_IF_EQUALS MISS; key=%s", key)
186
+ return False
187
+ if item.value != expected:
188
+ logger.debug("Memory cache DELETE_IF_EQUALS MISMATCH; key=%s", key)
189
+ return False
190
+ del self.cache[key]
191
+ logger.debug("Memory cache DELETE_IF_EQUALS HIT; key=%s", key)
192
+ return True
193
+
160
194
  async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> int:
161
195
  """Atomically add ``delta`` to the counter at ``key`` (see base class).
162
196
 
163
197
  The read-modify-write happens under the backend lock, so concurrent
164
198
  callers on the same event loop never lose an increment.
165
199
  """
200
+ validate_ttl(ttl)
166
201
  self._ensure_cleanup_started()
167
202
 
168
203
  async with self.lock: