fastapi-cachex 0.5.1__tar.gz → 0.5.3__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 (36) hide show
  1. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/PKG-INFO +18 -17
  2. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/README.md +16 -15
  3. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/__init__.py +24 -0
  4. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/_stored_response.py +3 -1
  5. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/base.py +27 -4
  6. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/codec.py +25 -7
  7. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/memcached.py +114 -76
  8. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/memory.py +9 -2
  9. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/redis.py +71 -77
  10. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/cache.py +557 -495
  11. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/cache_key.py +5 -3
  12. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/lock.py +14 -3
  13. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/manager.py +17 -9
  14. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/pyproject.toml +8 -19
  15. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/pyproject.toml.orig +12 -17
  16. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/LICENSE +0 -0
  17. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/_cache_control.py +0 -0
  18. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/_callables.py +0 -0
  19. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/_coalesce.py +0 -0
  20. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/_key_builders.py +0 -0
  21. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/_rendering.py +0 -0
  22. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/_vary.py +0 -0
  23. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/_warnings.py +0 -0
  24. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/__init__.py +0 -0
  25. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/_unavailable.py +0 -0
  26. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/config.py +0 -0
  27. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/cached.py +0 -0
  28. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/dependencies.py +0 -0
  29. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/directives.py +0 -0
  30. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/exceptions.py +0 -0
  31. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/headers.py +0 -0
  32. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/manager_proxy.py +0 -0
  33. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/proxy.py +0 -0
  34. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/py.typed +0 -0
  35. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/routes.py +0 -0
  36. {fastapi_cachex-0.5.1 → fastapi_cachex-0.5.3}/fastapi_cachex/types.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-cachex
3
- Version: 0.5.1
3
+ Version: 0.5.3
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
7
7
  Author-email: allen0099 <s96016641@gmail.com>
8
8
  License-Expression: Apache-2.0
9
9
  License-File: LICENSE
10
- Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Development Status :: 4 - Beta
11
11
  Classifier: Intended Audience :: Developers
12
12
  Classifier: Programming Language :: Python
13
13
  Classifier: Programming Language :: Python :: 3
@@ -50,11 +50,11 @@ Description-Content-Type: text/markdown
50
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
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
52
 
53
- [English](https://fastapi-cachex.readthedocs.io/en/latest/) | [繁體中文](https://fastapi-cachex.readthedocs.io/zh-tw/latest/)
53
+ [English](https://fastapi-cachex.readthedocs.io/en/stable/) | [繁體中文](https://fastapi-cachex.readthedocs.io/zh-tw/stable/)
54
54
 
55
55
  A high-performance caching extension for FastAPI: a server-side response cache with `Cache-Control` and `ETag` support, and application-level caching.
56
56
 
57
- **Documentation:** <https://fastapi-cachex.readthedocs.io/en/latest/> — guides and the full API reference.
57
+ **Documentation:** <https://fastapi-cachex.readthedocs.io/en/stable/> — guides and the full API reference.
58
58
 
59
59
  ## Features
60
60
 
@@ -115,7 +115,7 @@ async def report(cache: AppCache):
115
115
  > Put `@cache` **below** the route decorator. FastAPI registers whatever function
116
116
  > reaches `@app.get(...)`; with `@cache` on top, FastAPI registers the
117
117
  > undecorated handler, so the route works but nothing is cached and nothing warns
118
- > (see [Decorator order](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#decorator-order)).
118
+ > (see [Decorator order](https://fastapi-cachex.readthedocs.io/en/stable/HTTP_CACHING/#decorator-order)).
119
119
  >
120
120
  > ```python
121
121
  > @app.get("/items") # ✅ route decorator first,
@@ -132,7 +132,7 @@ async def report(cache: AppCache):
132
132
  > with `private=True` or a per-user key builder plus `cache_authorized=True`
133
133
  > (requests with `Authorization` or non-empty `request.session` data otherwise
134
134
  > bypass the backend) — see
135
- > [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#authenticated-endpoints).
135
+ > [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/stable/HTTP_CACHING/#authenticated-endpoints).
136
136
 
137
137
  ## Documentation
138
138
 
@@ -141,18 +141,19 @@ a code block that includes one of the runnable examples renders on the site,
141
141
  while GitHub shows only its `--8<--` include line (the sentence before each
142
142
  block links the example file).
143
143
 
144
- - [Migrating to 0.5.0](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_5/) — what 0.5.0 removes and how to upgrade from 0.4.x
145
- - [Migrating to 0.4.0](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_4/) — what 0.4.0 changes and how to upgrade from 0.3.x
146
- - [Migrating from fastapi-cache2](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_FROM_FASTAPI_CACHE2/) — its API mapped onto this one, and the behaviour that differs
147
- - [When to use it](https://fastapi-cachex.readthedocs.io/en/latest/COMPARISON/) — how it compares with fastapi-cache2, cashews, aiocache and a CDN, and when another one fits better
148
- - [HTTP caching](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/) — the `@cache` decorator, Cache-Control directives, cache keys, invalidation and monitoring routes
149
- - [Application cache](https://fastapi-cachex.readthedocs.io/en/latest/APP_CACHE/) — `CacheManager`
150
- - [Backends](https://fastapi-cachex.readthedocs.io/en/latest/BACKENDS/) — choosing and configuring a backend, atomic primitives
151
- - [Distributed lock](https://fastapi-cachex.readthedocs.io/en/latest/LOCK/) — `CacheLock` for multi-process mutual exclusion
152
- - [Cache flow](https://fastapi-cachex.readthedocs.io/en/latest/CACHE_FLOW/) — what happens inside a cached request
144
+ - [Migrating to 0.6.0](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_6/) — what 0.6.0 will change, starting with the end of Python 3.10 support
145
+ - [Migrating to 0.5.0](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_5/) — what 0.5.0 removes and how to upgrade from 0.4.x
146
+ - [Migrating to 0.4.0](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_4/) — what 0.4.0 changes and how to upgrade from 0.3.x
147
+ - [Migrating from fastapi-cache2](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_FROM_FASTAPI_CACHE2/) — its API mapped onto this one, and the behaviour that differs
148
+ - [When to use it](https://fastapi-cachex.readthedocs.io/en/stable/COMPARISON/) — how it compares with fastapi-cache2, cashews, aiocache and a CDN, and when another one fits better
149
+ - [HTTP caching](https://fastapi-cachex.readthedocs.io/en/stable/HTTP_CACHING/) — the `@cache` decorator, Cache-Control directives, cache keys, invalidation and monitoring routes
150
+ - [Application cache](https://fastapi-cachex.readthedocs.io/en/stable/APP_CACHE/) — `CacheManager`
151
+ - [Backends](https://fastapi-cachex.readthedocs.io/en/stable/BACKENDS/) — choosing and configuring a backend, atomic primitives
152
+ - [Distributed lock](https://fastapi-cachex.readthedocs.io/en/stable/LOCK/) — `CacheLock` for multi-process mutual exclusion
153
+ - [Cache flow](https://fastapi-cachex.readthedocs.io/en/stable/CACHE_FLOW/) — what happens inside a cached request
153
154
  - [Runnable examples](https://github.com/allen0099/FastAPI-CacheX/tree/master/examples) — one complete app per feature, each covered by the test suite
154
- - [API reference](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/)
155
- - [Development guide](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/) and [contributing](https://fastapi-cachex.readthedocs.io/en/latest/CONTRIBUTING/) · [Security policy](https://github.com/allen0099/FastAPI-CacheX/blob/master/SECURITY.md) — report vulnerabilities privately, not in public issues
155
+ - [API reference](https://fastapi-cachex.readthedocs.io/en/stable/api/http-caching/)
156
+ - [Development guide](https://fastapi-cachex.readthedocs.io/en/stable/DEVELOPMENT/) and [contributing](https://fastapi-cachex.readthedocs.io/en/stable/CONTRIBUTING/) · [Security policy](https://github.com/allen0099/FastAPI-CacheX/blob/master/SECURITY.md) — report vulnerabilities privately, not in public issues
156
157
  - [Changelog](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md) · [Known limitations and planned work](https://github.com/allen0099/FastAPI-CacheX/issues)
157
158
 
158
159
  ## License
@@ -12,11 +12,11 @@
12
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
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
14
 
15
- [English](https://fastapi-cachex.readthedocs.io/en/latest/) | [繁體中文](https://fastapi-cachex.readthedocs.io/zh-tw/latest/)
15
+ [English](https://fastapi-cachex.readthedocs.io/en/stable/) | [繁體中文](https://fastapi-cachex.readthedocs.io/zh-tw/stable/)
16
16
 
17
17
  A high-performance caching extension for FastAPI: a server-side response cache with `Cache-Control` and `ETag` support, and application-level caching.
18
18
 
19
- **Documentation:** <https://fastapi-cachex.readthedocs.io/en/latest/> — guides and the full API reference.
19
+ **Documentation:** <https://fastapi-cachex.readthedocs.io/en/stable/> — guides and the full API reference.
20
20
 
21
21
  ## Features
22
22
 
@@ -77,7 +77,7 @@ async def report(cache: AppCache):
77
77
  > Put `@cache` **below** the route decorator. FastAPI registers whatever function
78
78
  > reaches `@app.get(...)`; with `@cache` on top, FastAPI registers the
79
79
  > undecorated handler, so the route works but nothing is cached and nothing warns
80
- > (see [Decorator order](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#decorator-order)).
80
+ > (see [Decorator order](https://fastapi-cachex.readthedocs.io/en/stable/HTTP_CACHING/#decorator-order)).
81
81
  >
82
82
  > ```python
83
83
  > @app.get("/items") # ✅ route decorator first,
@@ -94,7 +94,7 @@ async def report(cache: AppCache):
94
94
  > with `private=True` or a per-user key builder plus `cache_authorized=True`
95
95
  > (requests with `Authorization` or non-empty `request.session` data otherwise
96
96
  > bypass the backend) — see
97
- > [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#authenticated-endpoints).
97
+ > [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/stable/HTTP_CACHING/#authenticated-endpoints).
98
98
 
99
99
  ## Documentation
100
100
 
@@ -103,18 +103,19 @@ a code block that includes one of the runnable examples renders on the site,
103
103
  while GitHub shows only its `--8<--` include line (the sentence before each
104
104
  block links the example file).
105
105
 
106
- - [Migrating to 0.5.0](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_5/) — what 0.5.0 removes and how to upgrade from 0.4.x
107
- - [Migrating to 0.4.0](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_4/) — what 0.4.0 changes and how to upgrade from 0.3.x
108
- - [Migrating from fastapi-cache2](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_FROM_FASTAPI_CACHE2/) — its API mapped onto this one, and the behaviour that differs
109
- - [When to use it](https://fastapi-cachex.readthedocs.io/en/latest/COMPARISON/) — how it compares with fastapi-cache2, cashews, aiocache and a CDN, and when another one fits better
110
- - [HTTP caching](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/) — the `@cache` decorator, Cache-Control directives, cache keys, invalidation and monitoring routes
111
- - [Application cache](https://fastapi-cachex.readthedocs.io/en/latest/APP_CACHE/) — `CacheManager`
112
- - [Backends](https://fastapi-cachex.readthedocs.io/en/latest/BACKENDS/) — choosing and configuring a backend, atomic primitives
113
- - [Distributed lock](https://fastapi-cachex.readthedocs.io/en/latest/LOCK/) — `CacheLock` for multi-process mutual exclusion
114
- - [Cache flow](https://fastapi-cachex.readthedocs.io/en/latest/CACHE_FLOW/) — what happens inside a cached request
106
+ - [Migrating to 0.6.0](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_6/) — what 0.6.0 will change, starting with the end of Python 3.10 support
107
+ - [Migrating to 0.5.0](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_5/) — what 0.5.0 removes and how to upgrade from 0.4.x
108
+ - [Migrating to 0.4.0](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_4/) — what 0.4.0 changes and how to upgrade from 0.3.x
109
+ - [Migrating from fastapi-cache2](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_FROM_FASTAPI_CACHE2/) — its API mapped onto this one, and the behaviour that differs
110
+ - [When to use it](https://fastapi-cachex.readthedocs.io/en/stable/COMPARISON/) — how it compares with fastapi-cache2, cashews, aiocache and a CDN, and when another one fits better
111
+ - [HTTP caching](https://fastapi-cachex.readthedocs.io/en/stable/HTTP_CACHING/) — the `@cache` decorator, Cache-Control directives, cache keys, invalidation and monitoring routes
112
+ - [Application cache](https://fastapi-cachex.readthedocs.io/en/stable/APP_CACHE/) — `CacheManager`
113
+ - [Backends](https://fastapi-cachex.readthedocs.io/en/stable/BACKENDS/) — choosing and configuring a backend, atomic primitives
114
+ - [Distributed lock](https://fastapi-cachex.readthedocs.io/en/stable/LOCK/) — `CacheLock` for multi-process mutual exclusion
115
+ - [Cache flow](https://fastapi-cachex.readthedocs.io/en/stable/CACHE_FLOW/) — what happens inside a cached request
115
116
  - [Runnable examples](https://github.com/allen0099/FastAPI-CacheX/tree/master/examples) — one complete app per feature, each covered by the test suite
116
- - [API reference](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/)
117
- - [Development guide](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/) and [contributing](https://fastapi-cachex.readthedocs.io/en/latest/CONTRIBUTING/) · [Security policy](https://github.com/allen0099/FastAPI-CacheX/blob/master/SECURITY.md) — report vulnerabilities privately, not in public issues
117
+ - [API reference](https://fastapi-cachex.readthedocs.io/en/stable/api/http-caching/)
118
+ - [Development guide](https://fastapi-cachex.readthedocs.io/en/stable/DEVELOPMENT/) and [contributing](https://fastapi-cachex.readthedocs.io/en/stable/CONTRIBUTING/) · [Security policy](https://github.com/allen0099/FastAPI-CacheX/blob/master/SECURITY.md) — report vulnerabilities privately, not in public issues
118
119
  - [Changelog](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md) · [Known limitations and planned work](https://github.com/allen0099/FastAPI-CacheX/issues)
119
120
 
120
121
  ## License
@@ -1,6 +1,8 @@
1
1
  """FastAPI-CacheX: A powerful and flexible caching extension for FastAPI."""
2
2
 
3
3
  import logging
4
+ import sys
5
+ import warnings
4
6
  from importlib.metadata import PackageNotFoundError
5
7
  from importlib.metadata import version
6
8
 
@@ -47,6 +49,28 @@ def _read_version() -> str:
47
49
 
48
50
  __version__ = _read_version()
49
51
 
52
+ _PYTHON_310_NOTICE = (
53
+ "fastapi-cachex 0.6.0 will require Python 3.11 or later; Python 3.10 "
54
+ "reached its end of life in October 2026. Upgrade Python; see "
55
+ "https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_6/#python-311"
56
+ )
57
+
58
+
59
+ def _warn_python_310(python: tuple[int, int] = sys.version_info[:2]) -> None:
60
+ """Warn on import under Python 3.10, which 0.6.0 drops (#491).
61
+
62
+ ``FutureWarning``, not ``DeprecationWarning``: it is shown by default.
63
+ ``stacklevel=3`` skips this function and the package module; ``warnings``
64
+ skips the import machinery's frames itself, so the warning names the line
65
+ that imported ``fastapi_cachex`` (for ``importlib.import_module()`` it
66
+ names ``importlib/__init__.py``; 3.10 has no ``skip_file_prefixes``).
67
+ """
68
+ if python < (3, 11):
69
+ warnings.warn(_PYTHON_310_NOTICE, FutureWarning, stacklevel=3)
70
+
71
+
72
+ _warn_python_310()
73
+
50
74
  _package_logger = logging.getLogger("fastapi_cachex")
51
75
  _package_logger.addHandler(
52
76
  logging.NullHandler()
@@ -121,7 +121,9 @@ def _get_response_body(response: Response) -> bytes | None:
121
121
 
122
122
 
123
123
  def _etag_for(body: bytes) -> str:
124
- return f'W/"{hashlib.md5(body).hexdigest()}"' # noqa: S324
124
+ # A validator, not a security control: say so, or a FIPS-mode OpenSSL
125
+ # refuses MD5 and every render fails (#496).
126
+ return f'W/"{hashlib.md5(body, usedforsecurity=False).hexdigest()}"'
125
127
 
126
128
 
127
129
  def _weak_etag(etag: str) -> str:
@@ -7,7 +7,6 @@ from collections.abc import Iterable
7
7
  from datetime import timedelta
8
8
  from types import TracebackType
9
9
  from typing import TYPE_CHECKING
10
- from typing import Any
11
10
  from typing import overload
12
11
 
13
12
  from fastapi_cachex.exceptions import CacheXError
@@ -102,6 +101,24 @@ def validate_ttl(ttl: int | timedelta | None) -> int | None:
102
101
  return ttl
103
102
 
104
103
 
104
+ def require_ttl(ttl: int | timedelta) -> int:
105
+ """``validate_ttl`` for a TTL that must be given, such as a lock lease.
106
+
107
+ ``expire_if_equals`` renews a lease; "no expiry" there would make a lock
108
+ permanent, and each backend used to read ``None`` its own way (#499).
109
+
110
+ Raises:
111
+ TTLTypeError: If ``ttl`` is ``None``, or anything ``validate_ttl``
112
+ refuses with it
113
+ TTLValueError: As ``validate_ttl``
114
+ """
115
+ if ttl is None:
116
+ # Unreachable for type checkers; guards untyped callers.
117
+ msg = "ttl must be an int number of seconds or a timedelta, got None" # type: ignore[unreachable]
118
+ raise TTLTypeError(msg)
119
+ return validate_ttl(ttl)
120
+
121
+
105
122
  def _whole_seconds(value: timedelta, name: str) -> int:
106
123
  """The whole number of seconds in ``value``.
107
124
 
@@ -305,12 +322,18 @@ class BaseCacheBackend(ABC):
305
322
  Args:
306
323
  key: Cache key to update expiry for
307
324
  expected: The entry the caller stored (compared with ``==``)
308
- ttl: Time to live in seconds
325
+ ttl: Time to live in seconds or as a ``timedelta``; required
309
326
 
310
327
  Returns:
311
328
  Whether the expiry was updated
329
+
330
+ Raises:
331
+ TTLTypeError: If ``ttl`` is ``None`` or not an ``int`` or a
332
+ ``timedelta``
333
+ TTLValueError: If ``ttl`` is zero, negative or larger than
334
+ ``MAX_TTL``
312
335
  """
313
- ttl = validate_ttl(ttl)
336
+ ttl = require_ttl(ttl)
314
337
  if await self.get(key) != expected:
315
338
  return False
316
339
  await self.set(key, expected, ttl=ttl)
@@ -442,7 +465,7 @@ class BaseCacheBackend(ABC):
442
465
  """
443
466
 
444
467
  @abstractmethod
445
- async def get_cache_data(self) -> dict[str, tuple[Any, float | None]]:
468
+ async def get_cache_data(self) -> dict[str, tuple[CacheEntry, float | None]]:
446
469
  """Get all cache data with expiry information.
447
470
 
448
471
  This method is primarily used for cache monitoring and statistics.
@@ -22,8 +22,10 @@ except ImportError: # pragma: no cover - import-time, environment dependent
22
22
 
23
23
  # ``json.loads`` (either implementation) raises ``ValueError`` subclasses for bad
24
24
  # JSON; ``KeyError``/``TypeError``/``AttributeError`` cover documents whose shape
25
- # is not the one ``encode_entry`` writes (missing fields, non-string content).
26
- _DECODE_ERRORS = (ValueError, KeyError, TypeError, AttributeError)
25
+ # is not the one ``encode_entry`` writes (missing fields, non-string content);
26
+ # fields of another type are checked in ``decode_entry``.
27
+ # The standard library parser raises ``RecursionError`` on deep nesting.
28
+ _DECODE_ERRORS = (ValueError, KeyError, TypeError, AttributeError, RecursionError)
27
29
 
28
30
 
29
31
  def encode_entry(entry: CacheEntry) -> bytes:
@@ -54,6 +56,11 @@ def encode_entry(entry: CacheEntry) -> bytes:
54
56
  return serialized if isinstance(serialized, bytes) else serialized.encode("utf-8")
55
57
 
56
58
 
59
+ # The status codes HTTP defines; anything else cannot be replayed.
60
+ _MIN_STATUS = 100
61
+ _MAX_STATUS = 599
62
+
63
+
57
64
  def _stored_at(value: object) -> float | None:
58
65
  """``stored_at`` from a document; anything but a finite number is unknown."""
59
66
  if isinstance(value, bool) or not isinstance(value, (int, float)):
@@ -66,8 +73,8 @@ def decode_entry(raw: str | bytes | None) -> CacheEntry | None:
66
73
 
67
74
  A bare integer (what the server-side ``INCR`` family leaves behind for
68
75
  ``increment``) becomes a counter entry. Anything else that is not a document
69
- written by ``encode_entry`` (corrupt JSON, missing fields, non-string
70
- content) yields ``None``, so callers can treat every malformed value as a
76
+ written by ``encode_entry`` (corrupt JSON, missing fields, a field of the
77
+ wrong type, a status code outside 100-599) yields ``None``, so callers can treat every malformed value as a
71
78
  cache miss.
72
79
 
73
80
  Documents written before entries carried a status code and headers simply
@@ -83,11 +90,22 @@ def decode_entry(raw: str | bytes | None) -> CacheEntry | None:
83
90
  return counter_entry(counter)
84
91
  try:
85
92
  data = json.loads(raw)
93
+ fingerprint = data["fingerprint"]
94
+ media_type = data.get("media_type")
95
+ status_code = data.get("status_code", DEFAULT_STATUS_CODE)
96
+ if (
97
+ not isinstance(fingerprint, str)
98
+ or not isinstance(media_type, (str, type(None)))
99
+ or isinstance(status_code, bool)
100
+ or not isinstance(status_code, int)
101
+ or not _MIN_STATUS <= status_code <= _MAX_STATUS
102
+ ):
103
+ return None
86
104
  return CacheEntry(
87
- fingerprint=data["fingerprint"],
105
+ fingerprint=fingerprint,
88
106
  content=data["content"].encode("latin-1"),
89
- media_type=data.get("media_type"),
90
- status_code=data.get("status_code", DEFAULT_STATUS_CODE),
107
+ media_type=media_type,
108
+ status_code=status_code,
91
109
  headers=data.get("headers"),
92
110
  stored_at=_stored_at(data.get("stored_at")),
93
111
  )
@@ -5,6 +5,7 @@ import hashlib
5
5
  import logging
6
6
  import time
7
7
  import warnings
8
+ from collections.abc import Callable
8
9
  from collections.abc import Iterable
9
10
  from datetime import timedelta
10
11
 
@@ -14,9 +15,11 @@ from fastapi_cachex.backends.codec import encode_entry
14
15
  from fastapi_cachex.exceptions import CacheXError
15
16
  from fastapi_cachex.exceptions import TTLValueError
16
17
  from fastapi_cachex.types import CacheEntry
18
+ from fastapi_cachex.types import log_ref
17
19
 
18
20
  from ._unavailable import translate_unavailable
19
21
  from .base import BaseCacheBackend
22
+ from .base import require_ttl
20
23
  from .base import validate_delta
21
24
  from .base import validate_ttl
22
25
 
@@ -57,6 +60,32 @@ _LEGAL_KEY_BYTES = frozenset(range(0x21, 0x7F))
57
60
  _MAX_PREFIX_BYTES = _MAX_KEY_BYTES - 64
58
61
 
59
62
 
63
+ # What `connect_timeout` and `timeout` are while they are not passed. 0.6.0
64
+ # makes it 1 second, as on the Redis backend (#516).
65
+ _DEFAULT_TIMEOUT = 5.0
66
+
67
+ _TIMEOUT_DEFAULT_NOTICE = (
68
+ "MemcachedBackend timeouts will default to 1 second in fastapi-cachex "
69
+ "0.6.0; they default to 5 seconds now. Pass connect_timeout and timeout "
70
+ "to choose the values and silence this warning; see "
71
+ "https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_6/#memcached-timeouts"
72
+ )
73
+
74
+
75
+ def _validate_timeout(name: str, value: float) -> None:
76
+ """Reject a timeout that is not a positive, finite number of seconds.
77
+
78
+ Zero would make the sockets non-blocking, and pymemcache reports a
79
+ negative one only when the first command connects.
80
+ """
81
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
82
+ msg = f"{name} must be a number of seconds, got {type(value).__name__}"
83
+ raise TypeError(msg)
84
+ if not 0 < value < float("inf"):
85
+ msg = f"{name} must be greater than zero and finite, got {value!r}"
86
+ raise ValueError(msg)
87
+
88
+
60
89
  def _validate_key_prefix(key_prefix: str) -> None:
61
90
  """Reject a ``key_prefix`` that would make keys Memcached refuses (#239).
62
91
 
@@ -153,19 +182,48 @@ class MemcachedBackend(BaseCacheBackend):
153
182
  self,
154
183
  servers: list[str],
155
184
  key_prefix: str = DEFAULT_MEMCACHE_PREFIX,
185
+ *,
186
+ connect_timeout: float | None = None,
187
+ timeout: float | None = None,
156
188
  ) -> None:
157
189
  """Initialize the Memcached backend.
158
190
 
159
191
  Args:
160
192
  servers: List of Memcached servers in format ["host:port", ...]
161
193
  key_prefix: Prefix for all cache keys (default: 'fastapi_cachex:')
194
+ connect_timeout: Seconds to wait for a connection to a server.
195
+ ``None`` (not passed) is 5 seconds, and 1 second from 0.6.0;
196
+ it does not disable the timeout.
197
+ timeout: Seconds to wait for each reply from a server. ``None``
198
+ (not passed) is 5 seconds, and 1 second from 0.6.0; it does
199
+ not disable the timeout. Every
200
+ call runs in a worker thread of the default executor and
201
+ holds it until it is answered or times out, so while a
202
+ server is unreachable a lower value frees those threads
203
+ sooner.
204
+
205
+ Warns:
206
+ FutureWarning: If ``connect_timeout`` or ``timeout`` is not
207
+ passed, since their default changes in 0.6.0 (#516).
162
208
 
163
209
  Raises:
164
210
  CacheXError: If pymemcache is not installed
211
+ TypeError: If ``connect_timeout`` or ``timeout`` is not a number
165
212
  ValueError: If ``key_prefix`` holds whitespace, control
166
- characters or non-ASCII, or is 250 bytes or longer
213
+ characters or non-ASCII, or is 250 bytes or longer, or if
214
+ ``connect_timeout`` or ``timeout`` is not greater than zero
167
215
  """
168
216
  _validate_key_prefix(key_prefix)
217
+ if connect_timeout is None or timeout is None:
218
+ warnings.warn(
219
+ _TIMEOUT_DEFAULT_NOTICE, FutureWarning, stacklevel=caller_stacklevel()
220
+ )
221
+ connect_timeout = (
222
+ _DEFAULT_TIMEOUT if connect_timeout is None else connect_timeout
223
+ )
224
+ timeout = _DEFAULT_TIMEOUT if timeout is None else timeout
225
+ _validate_timeout("connect_timeout", connect_timeout)
226
+ _validate_timeout("timeout", timeout)
169
227
  try:
170
228
  from pymemcache import HashClient
171
229
  except ImportError as exc:
@@ -185,8 +243,8 @@ class MemcachedBackend(BaseCacheBackend):
185
243
  # ``_DEAD_TIMEOUT`` seconds.
186
244
  self.client = HashClient(
187
245
  servers,
188
- connect_timeout=5,
189
- timeout=5,
246
+ connect_timeout=connect_timeout,
247
+ timeout=timeout,
190
248
  use_pooling=True,
191
249
  default_noreply=False,
192
250
  retry_attempts=0,
@@ -298,7 +356,15 @@ class MemcachedBackend(BaseCacheBackend):
298
356
  return None
299
357
  logger.debug("Memcached GET_AND_DELETE RETRY; key=%s", key)
300
358
 
301
- msg = f"Memcached get_and_delete exceeded {_CAS_MAX_RETRIES} attempts on key {key!r}"
359
+ # The message reaches error logs and trackers: a digest, not the key.
360
+ key_ref = log_ref(key)
361
+ logger.debug(
362
+ "Memcached GET_AND_DELETE GAVE UP; key_ref=%s key=%s", key_ref, key
363
+ )
364
+ msg = (
365
+ f"Memcached get_and_delete exceeded {_CAS_MAX_RETRIES} attempts "
366
+ f"(key_ref={key_ref})"
367
+ )
302
368
  raise CacheXError(msg)
303
369
 
304
370
  @_unavailable
@@ -325,31 +391,45 @@ class MemcachedBackend(BaseCacheBackend):
325
391
  )
326
392
  return bool(stored)
327
393
 
328
- @_unavailable
329
- async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
330
- """Atomically remove ``key`` while it holds ``expected`` (see base class).
394
+ def _cas_if_equals(
395
+ self,
396
+ operation: str,
397
+ key: str,
398
+ expected: CacheEntry,
399
+ new: Callable[[bytes], bytes],
400
+ exptime: int,
401
+ ) -> bool:
402
+ """GETS, compare and CAS in one worker thread.
331
403
 
332
- The classic protocol's DELETE takes no CAS token, so the release is a
333
- CAS write with a negative exptime, which Memcached treats as "expired
334
- immediately": it succeeds only if nothing wrote the key since ``GETS``
335
- read the value that was compared.
404
+ Writes ``new(raw)``, given the bytes just read, with ``exptime`` only
405
+ if the key holds ``expected`` and nothing wrote or deleted it since
406
+ ``GETS`` read the value that was compared. ``new`` is called only once
407
+ the comparison has passed.
336
408
  """
337
- return await asyncio.to_thread(self._delete_if_equals, key, expected)
338
-
339
- def _delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
340
- """Run ``delete_if_equals``'s GETS, compare and CAS in one worker thread."""
341
409
  prefixed_key = self._make_key(key)
342
410
  raw, cas_token = self.client.gets(prefixed_key)
343
411
  if raw is None or decode_entry(raw) != expected:
344
- logger.debug("Memcached DELETE_IF_EQUALS MISMATCH; key=%s", key)
412
+ logger.debug("Memcached %s MISMATCH; key=%s", operation, key)
345
413
  return False
346
- deleted = self.client.cas(prefixed_key, b"", cas_token, -1, noreply=False)
414
+ done = self.client.cas(
415
+ prefixed_key, new(raw), cas_token, exptime, noreply=False
416
+ )
347
417
  logger.debug(
348
- "Memcached DELETE_IF_EQUALS %s; key=%s",
349
- "HIT" if deleted else "LOST RACE",
350
- key,
418
+ "Memcached %s %s; key=%s", operation, "HIT" if done else "LOST RACE", key
419
+ )
420
+ return bool(done)
421
+
422
+ @_unavailable
423
+ async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
424
+ """Atomically remove ``key`` while it holds ``expected`` (see base class).
425
+
426
+ The classic protocol's DELETE takes no CAS token, so the release is a
427
+ CAS write with a negative exptime, which Memcached treats as "expired
428
+ immediately".
429
+ """
430
+ return await asyncio.to_thread(
431
+ self._cas_if_equals, "DELETE_IF_EQUALS", key, expected, lambda _: b"", -1
351
432
  )
352
- return bool(deleted)
353
433
 
354
434
  @_unavailable
355
435
  async def expire_if_equals(
@@ -360,30 +440,16 @@ class MemcachedBackend(BaseCacheBackend):
360
440
  TOUCH in the Memcached protocol takes no CAS token, so the renewal is a
361
441
  CAS write with the same bytes read by GETS and the new exptime.
362
442
  """
363
- ttl = validate_ttl(ttl)
364
443
  # Converted up front so a ttl Memcached cannot store fails before I/O.
365
- exptime = _expiry(ttl)
444
+ exptime = _expiry(require_ttl(ttl))
366
445
  return await asyncio.to_thread(
367
- self._expire_if_equals, key, expected, ttl, exptime
368
- )
369
-
370
- def _expire_if_equals(
371
- self, key: str, expected: CacheEntry, ttl: int, exptime: int
372
- ) -> bool:
373
- """Run ``expire_if_equals``'s GETS, compare and CAS in one worker thread."""
374
- prefixed_key = self._make_key(key)
375
- raw, cas_token = self.client.gets(prefixed_key)
376
- if raw is None or decode_entry(raw) != expected:
377
- logger.debug("Memcached EXPIRE_IF_EQUALS MISMATCH; key=%s", key)
378
- return False
379
- updated = self.client.cas(prefixed_key, raw, cas_token, exptime, noreply=False)
380
- logger.debug(
381
- "Memcached EXPIRE_IF_EQUALS %s; key=%s ttl=%s",
382
- "HIT" if updated else "LOST RACE",
446
+ self._cas_if_equals,
447
+ "EXPIRE_IF_EQUALS",
383
448
  key,
384
- ttl,
449
+ expected,
450
+ lambda raw: raw,
451
+ exptime,
385
452
  )
386
- return bool(updated)
387
453
 
388
454
  @_unavailable
389
455
  async def set_if_equals(
@@ -393,42 +459,17 @@ class MemcachedBackend(BaseCacheBackend):
393
459
  value: CacheEntry,
394
460
  ttl: int | timedelta | None = None,
395
461
  ) -> bool:
396
- """Atomically store ``value`` while ``key`` holds ``expected`` (see base class).
397
-
398
- GETS reads the value to compare along with its CAS token, and the CAS
399
- write succeeds only if nothing wrote or deleted the key since.
400
- """
401
- ttl = validate_ttl(ttl)
462
+ """Atomically store ``value`` while ``key`` holds ``expected`` (see base class)."""
402
463
  # Converted up front so a ttl Memcached cannot store fails before I/O.
403
- exptime = _expiry(ttl)
464
+ exptime = _expiry(validate_ttl(ttl))
404
465
  return await asyncio.to_thread(
405
- self._set_if_equals, key, expected, value, ttl, exptime
406
- )
407
-
408
- def _set_if_equals(
409
- self,
410
- key: str,
411
- expected: CacheEntry,
412
- value: CacheEntry,
413
- ttl: int | None,
414
- exptime: int,
415
- ) -> bool:
416
- """Run ``set_if_equals``'s GETS, compare and CAS in one worker thread."""
417
- prefixed_key = self._make_key(key)
418
- raw, cas_token = self.client.gets(prefixed_key)
419
- if raw is None or decode_entry(raw) != expected:
420
- logger.debug("Memcached SET_IF_EQUALS MISMATCH; key=%s", key)
421
- return False
422
- stored = self.client.cas(
423
- prefixed_key, encode_entry(value), cas_token, exptime, noreply=False
424
- )
425
- logger.debug(
426
- "Memcached SET_IF_EQUALS %s; key=%s ttl=%s",
427
- "HIT" if stored else "LOST RACE",
466
+ self._cas_if_equals,
467
+ "SET_IF_EQUALS",
428
468
  key,
429
- ttl,
469
+ expected,
470
+ lambda _: encode_entry(value),
471
+ exptime,
430
472
  )
431
- return bool(stored)
432
473
 
433
474
  def _add_delta(self, prefixed_key: str, delta: int) -> int | None:
434
475
  """Apply ``delta`` with INCR/DECR; ``None`` when the key does not exist.
@@ -623,7 +664,6 @@ class MemcachedBackend(BaseCacheBackend):
623
664
  )
624
665
  return removed
625
666
 
626
- @_unavailable
627
667
  async def clear_pattern(self, pattern: str) -> int:
628
668
  """Clear cached responses matching a pattern.
629
669
 
@@ -647,7 +687,6 @@ class MemcachedBackend(BaseCacheBackend):
647
687
  logger.debug("Memcached CLEAR_PATTERN unsupported; pattern=%s", pattern)
648
688
  return 0
649
689
 
650
- @_unavailable
651
690
  async def get_all_keys(self) -> list[str]:
652
691
  """Get all cache keys in the backend.
653
692
 
@@ -670,7 +709,6 @@ class MemcachedBackend(BaseCacheBackend):
670
709
  logger.debug("Memcached GET_ALL_KEYS unsupported; returning empty list")
671
710
  return []
672
711
 
673
- @_unavailable
674
712
  async def get_cache_data(self) -> dict[str, tuple[CacheEntry, float | None]]:
675
713
  """Get all cache data with expiry information.
676
714