fastapi-cachex 0.3.8__tar.gz → 0.3.9__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.
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/PKG-INFO +26 -8
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/README.md +25 -7
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/__init__.py +2 -0
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/backends/base.py +36 -2
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/backends/codec.py +13 -1
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/backends/config.py +8 -1
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/backends/memcached.py +84 -25
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/backends/memory.py +3 -1
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/backends/redis.py +115 -27
- fastapi_cachex-0.3.9/fastapi_cachex/cache.py +1458 -0
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/exceptions.py +2 -2
- fastapi_cachex-0.3.9/fastapi_cachex/headers.py +27 -0
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/lock.py +4 -1
- fastapi_cachex-0.3.9/fastapi_cachex/manager.py +581 -0
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/proxy.py +12 -1
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/routes.py +71 -26
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/session/__init__.py +2 -0
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/session/config.py +82 -4
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/session/dependencies.py +159 -18
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/session/manager.py +22 -4
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/session/middleware.py +235 -28
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/session/token_serializers.py +23 -6
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/state/exceptions.py +1 -1
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/state/manager.py +25 -9
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/types.py +20 -0
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/pyproject.toml +1 -1
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/pyproject.toml.orig +1 -1
- fastapi_cachex-0.3.8/fastapi_cachex/cache.py +0 -810
- fastapi_cachex-0.3.8/fastapi_cachex/manager.py +0 -253
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/LICENSE +0 -0
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/backends/__init__.py +0 -0
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/dependencies.py +0 -0
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/directives.py +0 -0
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/manager_proxy.py +0 -0
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/py.typed +0 -0
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/session/exceptions.py +0 -0
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/session/models.py +0 -0
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/session/proxy.py +0 -0
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/session/security.py +0 -0
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/state/__init__.py +0 -0
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/state/dependencies.py +0 -0
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/state/models.py +0 -0
- {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/state/proxy.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-cachex
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.9
|
|
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
|
|
@@ -114,27 +114,45 @@ def build_report() -> dict:
|
|
|
114
114
|
|
|
115
115
|
@app.get("/report")
|
|
116
116
|
async def report(cache: AppCache):
|
|
117
|
-
# Cache any JSON value in your own code.
|
|
118
|
-
|
|
117
|
+
# Cache any JSON value in your own code. lock=True runs build_report once
|
|
118
|
+
# for concurrent misses (the default from 0.4.0).
|
|
119
|
+
return await cache.get_or_set("report", build_report, ttl=300, lock=True)
|
|
119
120
|
```
|
|
120
121
|
|
|
122
|
+
> [!IMPORTANT]
|
|
123
|
+
> Put `@cache` **below** the route decorator. FastAPI registers whatever function
|
|
124
|
+
> reaches `@app.get(...)`; with `@cache` on top, FastAPI registers the
|
|
125
|
+
> undecorated handler, so the route works but nothing is cached and nothing warns
|
|
126
|
+
> (see [Decorator order](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#decorator-order)).
|
|
127
|
+
>
|
|
128
|
+
> ```python
|
|
129
|
+
> @app.get("/items") # ✅ route decorator first,
|
|
130
|
+
> @cache(ttl=60) # @cache directly above the function
|
|
131
|
+
> async def items(): ...
|
|
132
|
+
>
|
|
133
|
+
> @cache(ttl=60) # ❌ never called: nothing is cached
|
|
134
|
+
> @app.get("/items")
|
|
135
|
+
> async def items(): ...
|
|
136
|
+
> ```
|
|
137
|
+
|
|
121
138
|
> [!WARNING]
|
|
122
139
|
> The default cache key carries no user identity. Cache authenticated endpoints
|
|
123
|
-
> with `private=True` or a per-user key builder
|
|
140
|
+
> with `private=True` or a per-user key builder plus `cache_authorized=True`
|
|
141
|
+
> (requests with `Authorization` or a session otherwise bypass the backend) — see
|
|
124
142
|
> [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#authenticated-endpoints).
|
|
125
143
|
|
|
126
144
|
## Documentation
|
|
127
145
|
|
|
146
|
+
- [Migrating to 0.4.0](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_4/) — what 0.4.0 changes and how to act on the 0.3.9 warnings now
|
|
128
147
|
- [HTTP caching](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/) — the `@cache` decorator, Cache-Control directives, cache keys, invalidation and monitoring routes
|
|
129
|
-
- [Cache flow](https://fastapi-cachex.readthedocs.io/en/latest/CACHE_FLOW/) — what happens inside a cached request
|
|
130
148
|
- [Application cache](https://fastapi-cachex.readthedocs.io/en/latest/APP_CACHE/) — `CacheManager`
|
|
131
149
|
- [Backends](https://fastapi-cachex.readthedocs.io/en/latest/BACKENDS/) — choosing and configuring a backend, atomic primitives
|
|
132
|
-
- [Session management](https://fastapi-cachex.readthedocs.io/en/latest/SESSION/) and [JWT claims](https://fastapi-cachex.readthedocs.io/en/latest/JWT_CLAIMS/)
|
|
133
|
-
- [OAuth state](https://fastapi-cachex.readthedocs.io/en/latest/STATE/) — one-shot OAuth/CSRF state tokens
|
|
134
150
|
- [Distributed lock](https://fastapi-cachex.readthedocs.io/en/latest/LOCK/) — `CacheLock` for multi-process mutual exclusion
|
|
151
|
+
- [Session management](https://fastapi-cachex.readthedocs.io/en/latest/SESSION/), [OAuth state](https://fastapi-cachex.readthedocs.io/en/latest/STATE/) (one-shot OAuth/CSRF state tokens) and [JWT claims](https://fastapi-cachex.readthedocs.io/en/latest/JWT_CLAIMS/)
|
|
152
|
+
- [Cache flow](https://fastapi-cachex.readthedocs.io/en/latest/CACHE_FLOW/) — what happens inside a cached request
|
|
135
153
|
- [Runnable examples](https://github.com/allen0099/FastAPI-CacheX/tree/master/examples) — one complete app per feature, each covered by the test suite
|
|
136
154
|
- [API reference](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/)
|
|
137
|
-
- [Development guide](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/) and [contributing](https://fastapi-cachex.readthedocs.io/en/latest/CONTRIBUTING/)
|
|
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
|
|
138
156
|
- [Changelog](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md) · [Known limitations and planned work](https://github.com/allen0099/FastAPI-CacheX/issues)
|
|
139
157
|
|
|
140
158
|
## License
|
|
@@ -71,27 +71,45 @@ def build_report() -> dict:
|
|
|
71
71
|
|
|
72
72
|
@app.get("/report")
|
|
73
73
|
async def report(cache: AppCache):
|
|
74
|
-
# Cache any JSON value in your own code.
|
|
75
|
-
|
|
74
|
+
# Cache any JSON value in your own code. lock=True runs build_report once
|
|
75
|
+
# for concurrent misses (the default from 0.4.0).
|
|
76
|
+
return await cache.get_or_set("report", build_report, ttl=300, lock=True)
|
|
76
77
|
```
|
|
77
78
|
|
|
79
|
+
> [!IMPORTANT]
|
|
80
|
+
> Put `@cache` **below** the route decorator. FastAPI registers whatever function
|
|
81
|
+
> reaches `@app.get(...)`; with `@cache` on top, FastAPI registers the
|
|
82
|
+
> undecorated handler, so the route works but nothing is cached and nothing warns
|
|
83
|
+
> (see [Decorator order](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#decorator-order)).
|
|
84
|
+
>
|
|
85
|
+
> ```python
|
|
86
|
+
> @app.get("/items") # ✅ route decorator first,
|
|
87
|
+
> @cache(ttl=60) # @cache directly above the function
|
|
88
|
+
> async def items(): ...
|
|
89
|
+
>
|
|
90
|
+
> @cache(ttl=60) # ❌ never called: nothing is cached
|
|
91
|
+
> @app.get("/items")
|
|
92
|
+
> async def items(): ...
|
|
93
|
+
> ```
|
|
94
|
+
|
|
78
95
|
> [!WARNING]
|
|
79
96
|
> The default cache key carries no user identity. Cache authenticated endpoints
|
|
80
|
-
> with `private=True` or a per-user key builder
|
|
97
|
+
> with `private=True` or a per-user key builder plus `cache_authorized=True`
|
|
98
|
+
> (requests with `Authorization` or a session otherwise bypass the backend) — see
|
|
81
99
|
> [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#authenticated-endpoints).
|
|
82
100
|
|
|
83
101
|
## Documentation
|
|
84
102
|
|
|
103
|
+
- [Migrating to 0.4.0](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_4/) — what 0.4.0 changes and how to act on the 0.3.9 warnings now
|
|
85
104
|
- [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
105
|
- [Application cache](https://fastapi-cachex.readthedocs.io/en/latest/APP_CACHE/) — `CacheManager`
|
|
88
106
|
- [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
107
|
- [Distributed lock](https://fastapi-cachex.readthedocs.io/en/latest/LOCK/) — `CacheLock` for multi-process mutual exclusion
|
|
108
|
+
- [Session management](https://fastapi-cachex.readthedocs.io/en/latest/SESSION/), [OAuth state](https://fastapi-cachex.readthedocs.io/en/latest/STATE/) (one-shot OAuth/CSRF state tokens) and [JWT claims](https://fastapi-cachex.readthedocs.io/en/latest/JWT_CLAIMS/)
|
|
109
|
+
- [Cache flow](https://fastapi-cachex.readthedocs.io/en/latest/CACHE_FLOW/) — what happens inside a cached request
|
|
92
110
|
- [Runnable examples](https://github.com/allen0099/FastAPI-CacheX/tree/master/examples) — one complete app per feature, each covered by the test suite
|
|
93
111
|
- [API reference](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/)
|
|
94
|
-
- [Development guide](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/) and [contributing](https://fastapi-cachex.readthedocs.io/en/latest/CONTRIBUTING/)
|
|
112
|
+
- [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
|
|
95
113
|
- [Changelog](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md) · [Known limitations and planned work](https://github.com/allen0099/FastAPI-CacheX/issues)
|
|
96
114
|
|
|
97
115
|
## License
|
|
@@ -4,6 +4,7 @@ import logging
|
|
|
4
4
|
from importlib.metadata import PackageNotFoundError
|
|
5
5
|
from importlib.metadata import version
|
|
6
6
|
|
|
7
|
+
from .cache import build_cache_key as build_cache_key
|
|
7
8
|
from .cache import cache as cache
|
|
8
9
|
from .cache import default_key_builder as default_key_builder
|
|
9
10
|
from .cache import invalidate as invalidate
|
|
@@ -107,6 +108,7 @@ __all__ = [
|
|
|
107
108
|
"StateManagerProxy",
|
|
108
109
|
"__version__",
|
|
109
110
|
"add_routes",
|
|
111
|
+
"build_cache_key",
|
|
110
112
|
"cache",
|
|
111
113
|
"default_key_builder",
|
|
112
114
|
"get_app_cache",
|
|
@@ -4,6 +4,8 @@ import warnings
|
|
|
4
4
|
from abc import ABC
|
|
5
5
|
from abc import abstractmethod
|
|
6
6
|
from collections.abc import Iterable
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
7
9
|
from typing import Any
|
|
8
10
|
|
|
9
11
|
from fastapi_cachex.types import CACHE_KEY_SEPARATOR
|
|
@@ -11,6 +13,11 @@ from fastapi_cachex.types import CacheEntry
|
|
|
11
13
|
from fastapi_cachex.types import counter_entry
|
|
12
14
|
from fastapi_cachex.types import counter_value
|
|
13
15
|
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
# Type-only: typing.Self is 3.11+, and typing_extensions is not a runtime
|
|
18
|
+
# dependency.
|
|
19
|
+
from typing_extensions import Self
|
|
20
|
+
|
|
14
21
|
|
|
15
22
|
def warn_if_path_shaped(pattern: str, cleared: int) -> None:
|
|
16
23
|
"""Warn when a ``clear_pattern`` that cleared nothing was written as a path.
|
|
@@ -89,7 +96,33 @@ def validate_delta(delta: int) -> int:
|
|
|
89
96
|
|
|
90
97
|
|
|
91
98
|
class BaseCacheBackend(ABC):
|
|
92
|
-
"""Base class for all cache backends.
|
|
99
|
+
"""Base class for all cache backends.
|
|
100
|
+
|
|
101
|
+
Every backend is an async context manager: ``async with`` returns the
|
|
102
|
+
backend itself and calls ``aclose()`` on the way out.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
async def aclose(self) -> None: # noqa: B027 - a no-op default, not abstract
|
|
106
|
+
"""Release what the backend holds open: connections, background tasks.
|
|
107
|
+
|
|
108
|
+
Call it once on shutdown, typically at the end of a FastAPI lifespan.
|
|
109
|
+
It is safe to call more than once. The base implementation does
|
|
110
|
+
nothing, for backends with nothing to release; the built-in backends
|
|
111
|
+
override it.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
async def __aenter__(self) -> "Self":
|
|
115
|
+
"""Return the backend itself."""
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
async def __aexit__(
|
|
119
|
+
self,
|
|
120
|
+
exc_type: type[BaseException] | None,
|
|
121
|
+
exc_value: BaseException | None,
|
|
122
|
+
traceback: TracebackType | None,
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Close the backend with ``aclose()``."""
|
|
125
|
+
await self.aclose()
|
|
93
126
|
|
|
94
127
|
@abstractmethod
|
|
95
128
|
async def get(self, key: str) -> CacheEntry | None:
|
|
@@ -246,7 +279,8 @@ class BaseCacheBackend(ABC):
|
|
|
246
279
|
Raises:
|
|
247
280
|
CacheXError: If ``key`` holds a cached response instead of a counter
|
|
248
281
|
TypeError: If ``delta`` or ``ttl`` is not an ``int``
|
|
249
|
-
ValueError: If ``ttl`` is out of range
|
|
282
|
+
ValueError: If ``ttl`` is out of range, or ``delta`` does not fit
|
|
283
|
+
in a signed 64-bit integer
|
|
250
284
|
"""
|
|
251
285
|
validate_delta(delta)
|
|
252
286
|
validate_ttl(ttl)
|
|
@@ -4,6 +4,8 @@ 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
|
+
import math
|
|
8
|
+
|
|
7
9
|
from fastapi_cachex.types import COUNTER_FINGERPRINT
|
|
8
10
|
from fastapi_cachex.types import DEFAULT_STATUS_CODE
|
|
9
11
|
from fastapi_cachex.types import CacheEntry
|
|
@@ -45,12 +47,20 @@ def encode_entry(entry: CacheEntry) -> bytes:
|
|
|
45
47
|
"media_type": entry.media_type,
|
|
46
48
|
"status_code": entry.status_code,
|
|
47
49
|
"headers": entry.headers,
|
|
50
|
+
"stored_at": entry.stored_at,
|
|
48
51
|
},
|
|
49
52
|
)
|
|
50
53
|
# orjson returns bytes, stdlib json returns str
|
|
51
54
|
return serialized if isinstance(serialized, bytes) else serialized.encode("utf-8")
|
|
52
55
|
|
|
53
56
|
|
|
57
|
+
def _stored_at(value: object) -> float | None:
|
|
58
|
+
"""``stored_at`` from a document; anything but a finite number is unknown."""
|
|
59
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
60
|
+
return None
|
|
61
|
+
return float(value) if math.isfinite(value) else None
|
|
62
|
+
|
|
63
|
+
|
|
54
64
|
def decode_entry(raw: str | bytes | None) -> CacheEntry | None:
|
|
55
65
|
"""Rebuild a ``CacheEntry`` from a stored value.
|
|
56
66
|
|
|
@@ -61,7 +71,8 @@ def decode_entry(raw: str | bytes | None) -> CacheEntry | None:
|
|
|
61
71
|
cache miss.
|
|
62
72
|
|
|
63
73
|
Documents written before entries carried a status code and headers simply
|
|
64
|
-
lack those keys and decode to a plain ``200`` with no extra headers
|
|
74
|
+
lack those keys and decode to a plain ``200`` with no extra headers; those
|
|
75
|
+
written before entries carried ``stored_at`` decode with ``None``.
|
|
65
76
|
"""
|
|
66
77
|
if raw is None:
|
|
67
78
|
return None
|
|
@@ -76,6 +87,7 @@ def decode_entry(raw: str | bytes | None) -> CacheEntry | None:
|
|
|
76
87
|
media_type=data.get("media_type"),
|
|
77
88
|
status_code=data.get("status_code", DEFAULT_STATUS_CODE),
|
|
78
89
|
headers=data.get("headers"),
|
|
90
|
+
stored_at=_stored_at(data.get("stored_at")),
|
|
79
91
|
)
|
|
80
92
|
except _DECODE_ERRORS:
|
|
81
93
|
return None
|
|
@@ -16,7 +16,14 @@ class RedisConfig(BaseModel):
|
|
|
16
16
|
default=None, description="Redis server password"
|
|
17
17
|
)
|
|
18
18
|
db: int = Field(default=0, ge=0, description="Redis database number")
|
|
19
|
-
encoding: str = Field(
|
|
19
|
+
encoding: str = Field(
|
|
20
|
+
default="utf-8",
|
|
21
|
+
description=(
|
|
22
|
+
"Deprecated, removed in 0.4.0: leave it unset. Character encoding "
|
|
23
|
+
"the client decodes replies with; setting it emits a "
|
|
24
|
+
"DeprecationWarning in load_from_config()."
|
|
25
|
+
),
|
|
26
|
+
)
|
|
20
27
|
socket_timeout: float = Field(
|
|
21
28
|
default=1.0, description="Timeout for socket operations in seconds"
|
|
22
29
|
)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import hashlib
|
|
5
|
+
import inspect
|
|
5
6
|
import logging
|
|
6
7
|
import time
|
|
7
8
|
import warnings
|
|
@@ -68,6 +69,26 @@ def _expiry(ttl: int | None) -> int:
|
|
|
68
69
|
return ttl
|
|
69
70
|
|
|
70
71
|
|
|
72
|
+
def _caller_stacklevel() -> int:
|
|
73
|
+
"""Return the ``stacklevel`` of the first frame outside fastapi_cachex.
|
|
74
|
+
|
|
75
|
+
For a ``warnings.warn`` in the method that calls this, so a warning raised
|
|
76
|
+
through ``CacheManager`` names the application's line, not manager.py.
|
|
77
|
+
"""
|
|
78
|
+
frame = inspect.currentframe()
|
|
79
|
+
if frame is None or frame.f_back is None: # no frame support
|
|
80
|
+
return 2
|
|
81
|
+
# Level 1 is the method that warns; start at its caller.
|
|
82
|
+
level, frame = 2, frame.f_back.f_back
|
|
83
|
+
while frame is not None:
|
|
84
|
+
module = frame.f_globals.get("__name__", "")
|
|
85
|
+
if module != "fastapi_cachex" and not module.startswith("fastapi_cachex."):
|
|
86
|
+
break
|
|
87
|
+
frame = frame.f_back
|
|
88
|
+
level += 1
|
|
89
|
+
return level
|
|
90
|
+
|
|
91
|
+
|
|
71
92
|
class MemcachedBackend(BaseCacheBackend):
|
|
72
93
|
"""Memcached backend implementation.
|
|
73
94
|
|
|
@@ -78,7 +99,10 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
78
99
|
conflicts with other applications.
|
|
79
100
|
|
|
80
101
|
Limitations:
|
|
81
|
-
-
|
|
102
|
+
- Memcached cannot enumerate keys: clear_pattern clears nothing (returns
|
|
103
|
+
0) and get_all_keys/get_cache_data return empty results, each with a
|
|
104
|
+
RuntimeWarning; clear_path only deletes a key named exactly as the path
|
|
105
|
+
- clear() issues flush_all, which empties the whole server
|
|
82
106
|
- Operations are wrapped to appear async but use blocking sync client internally
|
|
83
107
|
"""
|
|
84
108
|
|
|
@@ -126,6 +150,15 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
126
150
|
)
|
|
127
151
|
self.key_prefix = key_prefix
|
|
128
152
|
|
|
153
|
+
async def aclose(self) -> None:
|
|
154
|
+
"""Close every pooled connection to every server.
|
|
155
|
+
|
|
156
|
+
Safe to call more than once. The pymemcache client reconnects on the
|
|
157
|
+
next call, so a backend used after ``aclose()`` opens new sockets that
|
|
158
|
+
need another ``aclose()``.
|
|
159
|
+
"""
|
|
160
|
+
await asyncio.to_thread(self.client.close)
|
|
161
|
+
|
|
129
162
|
def _make_key(self, key: str) -> str:
|
|
130
163
|
"""Namespace a cache key, hashing it when Memcached would refuse it.
|
|
131
164
|
|
|
@@ -307,20 +340,42 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
307
340
|
return None if result is None else int(result)
|
|
308
341
|
|
|
309
342
|
def _increment(self, prefixed_key: str, delta: int, exptime: int) -> int | None:
|
|
310
|
-
"""Run ``increment``'s INCR, ADD and retried INCR in one worker thread.
|
|
343
|
+
"""Run ``increment``'s INCR, ADD and retried INCR in one worker thread.
|
|
344
|
+
|
|
345
|
+
Returns ``None`` only when the counter vanished after every one of
|
|
346
|
+
``_CAS_MAX_RETRIES`` ADD + INCR attempts.
|
|
347
|
+
"""
|
|
311
348
|
value = self._add_delta(prefixed_key, delta)
|
|
312
|
-
if value is None:
|
|
349
|
+
if value is not None:
|
|
350
|
+
return value
|
|
351
|
+
for _ in range(_CAS_MAX_RETRIES):
|
|
313
352
|
# No counter yet: ADD is atomic and a no-op when a concurrent
|
|
314
|
-
# call created it first
|
|
353
|
+
# call created it first.
|
|
315
354
|
self.client.add(prefixed_key, b"0", exptime, noreply=False)
|
|
316
355
|
value = self._add_delta(prefixed_key, delta)
|
|
317
|
-
|
|
356
|
+
if value is not None:
|
|
357
|
+
return value
|
|
358
|
+
# Memcached keeps time in whole seconds, so a counter created with
|
|
359
|
+
# a short ttl can expire before the INCR that follows its ADD. It
|
|
360
|
+
# expired inside its own window, so the next window starts again
|
|
361
|
+
# at ``delta``.
|
|
362
|
+
logger.debug("Memcached INCREMENT RETRY; key=%s", prefixed_key)
|
|
363
|
+
return None
|
|
318
364
|
|
|
319
365
|
async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> int:
|
|
320
366
|
"""Atomically add ``delta`` to the counter at ``key`` (see base class).
|
|
321
367
|
|
|
322
368
|
Memcached counters are unsigned, so a negative ``delta`` uses DECR,
|
|
323
369
|
which stops at 0 instead of going negative.
|
|
370
|
+
|
|
371
|
+
Creating a counter takes an ADD and then an INCR. If the new counter
|
|
372
|
+
expires in between (Memcached's clock has one-second resolution, so a
|
|
373
|
+
``ttl=1`` counter can live for well under a second), the ADD + INCR
|
|
374
|
+
pair is retried, starting a new window at ``delta``.
|
|
375
|
+
|
|
376
|
+
Raises:
|
|
377
|
+
CacheXError: If the key holds a value that is not a counter, or
|
|
378
|
+
the counter vanished after every ADD + INCR attempt.
|
|
324
379
|
"""
|
|
325
380
|
validate_delta(delta)
|
|
326
381
|
validate_ttl(ttl)
|
|
@@ -339,7 +394,7 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
339
394
|
msg = "Cache key holds a value that is not a counter"
|
|
340
395
|
raise CacheXError(msg) from e
|
|
341
396
|
if value is None:
|
|
342
|
-
msg = "Counter vanished between ADD and INCR"
|
|
397
|
+
msg = f"Counter vanished between ADD and INCR on each of {_CAS_MAX_RETRIES} attempts"
|
|
343
398
|
raise CacheXError(msg)
|
|
344
399
|
logger.debug("Memcached INCREMENT; key=%s value=%s ttl=%s", key, value, ttl)
|
|
345
400
|
return value
|
|
@@ -392,36 +447,38 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
392
447
|
"this namespace cannot be cleared on its own; delete known keys "
|
|
393
448
|
"with delete() or delete_many() instead.",
|
|
394
449
|
RuntimeWarning,
|
|
395
|
-
stacklevel=
|
|
450
|
+
stacklevel=_caller_stacklevel(),
|
|
396
451
|
)
|
|
397
452
|
await asyncio.to_thread(self.client.flush_all)
|
|
398
453
|
logger.debug("Memcached CLEAR; flush_all issued")
|
|
399
454
|
|
|
400
455
|
async def clear_path(self, path: str, include_params: bool = False) -> int:
|
|
401
|
-
"""
|
|
456
|
+
"""Delete the key that is exactly ``path``; warns on every call.
|
|
402
457
|
|
|
403
|
-
|
|
404
|
-
only deletes
|
|
405
|
-
|
|
406
|
-
|
|
458
|
+
Memcached cannot enumerate keys, so this cannot find HTTP route keys
|
|
459
|
+
(``method|||host|||path|||query``): it only deletes a key stored under
|
|
460
|
+
the literal name ``path``, and ``include_params`` has no effect. It
|
|
461
|
+
warns every time, because on this backend ``clear_path()`` after a write
|
|
462
|
+
would otherwise leave the cached response in place without a sign. Use
|
|
463
|
+
``invalidate(request)`` to drop a ``@cache`` route's entry, or the Redis
|
|
464
|
+
or memory backend for path-based clearing.
|
|
407
465
|
|
|
408
466
|
Args:
|
|
409
467
|
path: The exact key to delete
|
|
410
|
-
include_params: Unsupported;
|
|
411
|
-
otherwise ignored
|
|
468
|
+
include_params: Unsupported; ignored
|
|
412
469
|
|
|
413
470
|
Returns:
|
|
414
471
|
Number of cache entries cleared (0 or 1 for exact match only)
|
|
415
472
|
"""
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
473
|
+
warnings.warn(
|
|
474
|
+
"Memcached backend does not support pattern-based key clearing, so "
|
|
475
|
+
"clear_path() cannot remove HTTP cache entries "
|
|
476
|
+
"(method|||host|||path|||query): it only deletes a key named "
|
|
477
|
+
"exactly as the path, and include_params has no effect. Use "
|
|
478
|
+
"invalidate(request) to drop a cached route's entry.",
|
|
479
|
+
RuntimeWarning,
|
|
480
|
+
stacklevel=_caller_stacklevel(),
|
|
481
|
+
)
|
|
425
482
|
|
|
426
483
|
# Try to delete the prefixed key (exact match only)
|
|
427
484
|
prefixed_key = self._make_key(path)
|
|
@@ -454,7 +511,7 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
454
511
|
"Consider using Redis backend for pattern support, "
|
|
455
512
|
"or track keys manually in your application logic.",
|
|
456
513
|
RuntimeWarning,
|
|
457
|
-
stacklevel=
|
|
514
|
+
stacklevel=_caller_stacklevel(),
|
|
458
515
|
)
|
|
459
516
|
logger.debug("Memcached CLEAR_PATTERN unsupported; pattern=%s", pattern)
|
|
460
517
|
return 0
|
|
@@ -476,7 +533,7 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
476
533
|
"Consider using Redis backend if you need cache monitoring, "
|
|
477
534
|
"or track keys manually in your application.",
|
|
478
535
|
RuntimeWarning,
|
|
479
|
-
stacklevel=
|
|
536
|
+
stacklevel=_caller_stacklevel(),
|
|
480
537
|
)
|
|
481
538
|
logger.debug("Memcached GET_ALL_KEYS unsupported; returning empty list")
|
|
482
539
|
return []
|
|
@@ -495,6 +552,8 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
495
552
|
"get_cache_data() returns an empty dictionary. "
|
|
496
553
|
"Consider using Redis backend if you need cache monitoring.",
|
|
497
554
|
RuntimeWarning,
|
|
555
|
+
# Called by the monitoring route, whose caller is FastAPI itself:
|
|
556
|
+
# routes.py names the source better than any frame outside it.
|
|
498
557
|
stacklevel=2,
|
|
499
558
|
)
|
|
500
559
|
logger.debug("Memcached GET_CACHE_DATA unsupported; returning empty dict")
|
|
@@ -31,8 +31,10 @@ def _split_http_key(key: str) -> tuple[str, bool] | None:
|
|
|
31
31
|
|
|
32
32
|
Keys without separators (CacheManager/StateManager keys or custom key
|
|
33
33
|
builders) are not HTTP keys and are matched on their raw value instead.
|
|
34
|
+
Components after the query string (see ``build_cache_key``) do not count
|
|
35
|
+
as query params.
|
|
34
36
|
"""
|
|
35
|
-
parts = key.split(CACHE_KEY_SEPARATOR
|
|
37
|
+
parts = key.split(CACHE_KEY_SEPARATOR)
|
|
36
38
|
if len(parts) <= _PATH_INDEX:
|
|
37
39
|
return None
|
|
38
40
|
has_params = len(parts) > _QUERY_INDEX and bool(parts[_QUERY_INDEX])
|