fastapi-cachex 0.4.1__tar.gz → 0.5.1__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 (55) hide show
  1. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/PKG-INFO +17 -15
  2. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/README.md +14 -9
  3. fastapi_cachex-0.5.1/fastapi_cachex/__init__.py +83 -0
  4. fastapi_cachex-0.5.1/fastapi_cachex/_cache_control.py +150 -0
  5. fastapi_cachex-0.5.1/fastapi_cachex/_callables.py +17 -0
  6. fastapi_cachex-0.5.1/fastapi_cachex/_coalesce.py +70 -0
  7. fastapi_cachex-0.5.1/fastapi_cachex/_key_builders.py +175 -0
  8. fastapi_cachex-0.5.1/fastapi_cachex/_rendering.py +279 -0
  9. fastapi_cachex-0.5.1/fastapi_cachex/_stored_response.py +214 -0
  10. fastapi_cachex-0.5.1/fastapi_cachex/_vary.py +79 -0
  11. fastapi_cachex-0.5.1/fastapi_cachex/backends/_unavailable.py +100 -0
  12. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/fastapi_cachex/backends/base.py +79 -57
  13. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/fastapi_cachex/backends/memcached.py +60 -13
  14. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/fastapi_cachex/backends/memory.py +82 -20
  15. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/fastapi_cachex/backends/redis.py +48 -10
  16. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/fastapi_cachex/cache.py +250 -892
  17. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/fastapi_cachex/cache_key.py +1 -1
  18. fastapi_cachex-0.5.1/fastapi_cachex/cached.py +262 -0
  19. fastapi_cachex-0.5.1/fastapi_cachex/exceptions.py +64 -0
  20. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/fastapi_cachex/headers.py +1 -1
  21. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/fastapi_cachex/lock.py +18 -9
  22. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/fastapi_cachex/manager.py +49 -40
  23. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/fastapi_cachex/routes.py +43 -11
  24. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/fastapi_cachex/types.py +2 -3
  25. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/pyproject.toml +11 -20
  26. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/pyproject.toml.orig +23 -29
  27. fastapi_cachex-0.4.1/fastapi_cachex/__init__.py +0 -149
  28. fastapi_cachex-0.4.1/fastapi_cachex/_deprecation.py +0 -68
  29. fastapi_cachex-0.4.1/fastapi_cachex/exceptions.py +0 -26
  30. fastapi_cachex-0.4.1/fastapi_cachex/session/__init__.py +0 -46
  31. fastapi_cachex-0.4.1/fastapi_cachex/session/config.py +0 -349
  32. fastapi_cachex-0.4.1/fastapi_cachex/session/dependencies.py +0 -435
  33. fastapi_cachex-0.4.1/fastapi_cachex/session/exceptions.py +0 -31
  34. fastapi_cachex-0.4.1/fastapi_cachex/session/manager.py +0 -667
  35. fastapi_cachex-0.4.1/fastapi_cachex/session/middleware.py +0 -618
  36. fastapi_cachex-0.4.1/fastapi_cachex/session/models.py +0 -195
  37. fastapi_cachex-0.4.1/fastapi_cachex/session/proxy.py +0 -9
  38. fastapi_cachex-0.4.1/fastapi_cachex/session/security.py +0 -118
  39. fastapi_cachex-0.4.1/fastapi_cachex/session/token_serializers.py +0 -268
  40. fastapi_cachex-0.4.1/fastapi_cachex/state/__init__.py +0 -20
  41. fastapi_cachex-0.4.1/fastapi_cachex/state/dependencies.py +0 -27
  42. fastapi_cachex-0.4.1/fastapi_cachex/state/exceptions.py +0 -19
  43. fastapi_cachex-0.4.1/fastapi_cachex/state/manager.py +0 -315
  44. fastapi_cachex-0.4.1/fastapi_cachex/state/models.py +0 -38
  45. fastapi_cachex-0.4.1/fastapi_cachex/state/proxy.py +0 -9
  46. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/LICENSE +0 -0
  47. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/fastapi_cachex/_warnings.py +0 -0
  48. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/fastapi_cachex/backends/__init__.py +0 -0
  49. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/fastapi_cachex/backends/codec.py +0 -0
  50. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/fastapi_cachex/backends/config.py +0 -0
  51. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/fastapi_cachex/dependencies.py +0 -0
  52. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/fastapi_cachex/directives.py +0 -0
  53. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/fastapi_cachex/manager_proxy.py +0 -0
  54. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/fastapi_cachex/proxy.py +0 -0
  55. {fastapi_cachex-0.4.1 → fastapi_cachex-0.5.1}/fastapi_cachex/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-cachex
3
- Version: 0.4.1
3
+ Version: 0.5.1
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
@@ -16,16 +16,14 @@ Classifier: Programming Language :: Python :: 3.11
16
16
  Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Programming Language :: Python :: 3.13
18
18
  Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Programming Language :: Python :: 3.15
19
20
  Classifier: Programming Language :: Python :: 3 :: Only
20
21
  Classifier: Framework :: FastAPI
21
22
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
23
  Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
23
24
  Classifier: Typing :: Typed
24
- Requires-Dist: fastapi>=0.133.0
25
- Requires-Dist: itsdangerous>=1.1.0
25
+ Requires-Dist: fastapi>=0.128.2
26
26
  Requires-Dist: pydantic>=2.7.0
27
- Requires-Dist: starlette>=1.0.0
28
- Requires-Dist: pyjwt>=2.9.0 ; extra == 'jwt'
29
27
  Requires-Dist: pymemcache>=4.0.0 ; extra == 'memcached'
30
28
  Requires-Dist: redis[hiredis]>=5.3.0 ; extra == 'redis'
31
29
  Requires-Dist: orjson>=3.4.7 ; extra == 'redis'
@@ -34,7 +32,6 @@ Project-URL: Homepage, https://github.com/allen0099/FastAPI-CacheX
34
32
  Project-URL: Repository, https://github.com/allen0099/FastAPI-CacheX.git
35
33
  Project-URL: Issues, https://github.com/allen0099/FastAPI-CacheX/issues
36
34
  Project-URL: Documentation, https://fastapi-cachex.readthedocs.io/
37
- Provides-Extra: jwt
38
35
  Provides-Extra: memcached
39
36
  Provides-Extra: redis
40
37
  Description-Content-Type: text/markdown
@@ -64,12 +61,10 @@ A high-performance caching extension for FastAPI: a server-side response cache w
64
61
  - **HTTP caching** — a `@cache` decorator for GET routes with `Cache-Control`,
65
62
  `ETag` / `If-None-Match` (304) and per-route invalidation.
66
63
  - **Application cache** — `CacheManager` for caching arbitrary JSON values in
67
- your own code, with compute-on-miss `get_or_set()` and atomic store-if-absent `add()`.
64
+ your own code, with compute-on-miss `get_or_set()` and atomic store-if-absent `add()`,
65
+ and `@cached` for a plain function, keyed on its arguments.
68
66
  - **Backends** — in-memory, Redis and Memcached, with atomic counters,
69
67
  one-shot values and locks.
70
- - **Sessions and OAuth state (deprecated)**: signed session tokens and one-time
71
- OAuth state tokens. Both are deprecated in 0.4.0 and removed in 0.5.0; see
72
- [where to move](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_4/#session-state-deprecated).
73
68
 
74
69
  ## Installation
75
70
 
@@ -78,15 +73,14 @@ uv add fastapi-cachex
78
73
  ```
79
74
 
80
75
  Everything in the core package works with the in-memory backend. The other
81
- backends and the optional session transports ship as extras:
76
+ backends ship as extras:
82
77
 
83
78
  | Extra | Install | Pulls in | Needed for |
84
79
  |-------|---------|----------|------------|
85
80
  | `redis` | `uv add "fastapi-cachex[redis]"` | `redis[hiredis]`, `orjson` | `AsyncRedisCacheBackend` |
86
81
  | `memcached` | `uv add "fastapi-cachex[memcached]"` | `pymemcache` | `MemcachedBackend` |
87
- | `jwt` | `uv add "fastapi-cachex[jwt]"` | `PyJWT` | `SessionConfig(token_format="jwt")` |
88
82
 
89
- Extras combine: `uv add "fastapi-cachex[redis,jwt]"`.
83
+ Extras combine: `uv add "fastapi-cachex[redis,memcached]"`.
90
84
 
91
85
  ## Quick Start
92
86
 
@@ -136,17 +130,25 @@ async def report(cache: AppCache):
136
130
  > [!WARNING]
137
131
  > The default cache key carries no user identity. Cache authenticated endpoints
138
132
  > with `private=True` or a per-user key builder plus `cache_authorized=True`
139
- > (requests with `Authorization` or a session otherwise bypass the backend) — see
133
+ > (requests with `Authorization` or non-empty `request.session` data otherwise
134
+ > bypass the backend) — see
140
135
  > [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#authenticated-endpoints).
141
136
 
142
137
  ## Documentation
143
138
 
139
+ The guides are built from `docs/` into the site linked below. Read them there:
140
+ a code block that includes one of the runnable examples renders on the site,
141
+ while GitHub shows only its `--8<--` include line (the sentence before each
142
+ block links the example file).
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
144
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
145
148
  - [HTTP caching](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/) — the `@cache` decorator, Cache-Control directives, cache keys, invalidation and monitoring routes
146
149
  - [Application cache](https://fastapi-cachex.readthedocs.io/en/latest/APP_CACHE/) — `CacheManager`
147
150
  - [Backends](https://fastapi-cachex.readthedocs.io/en/latest/BACKENDS/) — choosing and configuring a backend, atomic primitives
148
151
  - [Distributed lock](https://fastapi-cachex.readthedocs.io/en/latest/LOCK/) — `CacheLock` for multi-process mutual exclusion
149
- - Deprecated, removed in 0.5.0: [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/)
150
152
  - [Cache flow](https://fastapi-cachex.readthedocs.io/en/latest/CACHE_FLOW/) — what happens inside a cached request
151
153
  - [Runnable examples](https://github.com/allen0099/FastAPI-CacheX/tree/master/examples) — one complete app per feature, each covered by the test suite
152
154
  - [API reference](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/)
@@ -23,12 +23,10 @@ A high-performance caching extension for FastAPI: a server-side response cache w
23
23
  - **HTTP caching** — a `@cache` decorator for GET routes with `Cache-Control`,
24
24
  `ETag` / `If-None-Match` (304) and per-route invalidation.
25
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()`.
26
+ your own code, with compute-on-miss `get_or_set()` and atomic store-if-absent `add()`,
27
+ and `@cached` for a plain function, keyed on its arguments.
27
28
  - **Backends** — in-memory, Redis and Memcached, with atomic counters,
28
29
  one-shot values and locks.
29
- - **Sessions and OAuth state (deprecated)**: signed session tokens and one-time
30
- OAuth state tokens. Both are deprecated in 0.4.0 and removed in 0.5.0; see
31
- [where to move](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_4/#session-state-deprecated).
32
30
 
33
31
  ## Installation
34
32
 
@@ -37,15 +35,14 @@ uv add fastapi-cachex
37
35
  ```
38
36
 
39
37
  Everything in the core package works with the in-memory backend. The other
40
- backends and the optional session transports ship as extras:
38
+ backends ship as extras:
41
39
 
42
40
  | Extra | Install | Pulls in | Needed for |
43
41
  |-------|---------|----------|------------|
44
42
  | `redis` | `uv add "fastapi-cachex[redis]"` | `redis[hiredis]`, `orjson` | `AsyncRedisCacheBackend` |
45
43
  | `memcached` | `uv add "fastapi-cachex[memcached]"` | `pymemcache` | `MemcachedBackend` |
46
- | `jwt` | `uv add "fastapi-cachex[jwt]"` | `PyJWT` | `SessionConfig(token_format="jwt")` |
47
44
 
48
- Extras combine: `uv add "fastapi-cachex[redis,jwt]"`.
45
+ Extras combine: `uv add "fastapi-cachex[redis,memcached]"`.
49
46
 
50
47
  ## Quick Start
51
48
 
@@ -95,17 +92,25 @@ async def report(cache: AppCache):
95
92
  > [!WARNING]
96
93
  > The default cache key carries no user identity. Cache authenticated endpoints
97
94
  > 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
95
+ > (requests with `Authorization` or non-empty `request.session` data otherwise
96
+ > bypass the backend) — see
99
97
  > [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#authenticated-endpoints).
100
98
 
101
99
  ## Documentation
102
100
 
101
+ The guides are built from `docs/` into the site linked below. Read them there:
102
+ a code block that includes one of the runnable examples renders on the site,
103
+ while GitHub shows only its `--8<--` include line (the sentence before each
104
+ block links the example file).
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
103
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
104
110
  - [HTTP caching](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/) — the `@cache` decorator, Cache-Control directives, cache keys, invalidation and monitoring routes
105
111
  - [Application cache](https://fastapi-cachex.readthedocs.io/en/latest/APP_CACHE/) — `CacheManager`
106
112
  - [Backends](https://fastapi-cachex.readthedocs.io/en/latest/BACKENDS/) — choosing and configuring a backend, atomic primitives
107
113
  - [Distributed lock](https://fastapi-cachex.readthedocs.io/en/latest/LOCK/) — `CacheLock` for multi-process mutual exclusion
108
- - Deprecated, removed in 0.5.0: [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
114
  - [Cache flow](https://fastapi-cachex.readthedocs.io/en/latest/CACHE_FLOW/) — what happens inside a cached request
110
115
  - [Runnable examples](https://github.com/allen0099/FastAPI-CacheX/tree/master/examples) — one complete app per feature, each covered by the test suite
111
116
  - [API reference](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/)
@@ -0,0 +1,83 @@
1
+ """FastAPI-CacheX: A powerful and flexible caching extension for FastAPI."""
2
+
3
+ import logging
4
+ from importlib.metadata import PackageNotFoundError
5
+ from importlib.metadata import version
6
+
7
+ from .cache import build_cache_key as build_cache_key
8
+ from .cache import cache as cache
9
+ from .cache import default_key_builder as default_key_builder
10
+ from .cache import invalidate as invalidate
11
+ from .cache_key import CacheKey as CacheKey
12
+ from .cached import CachedFunction as CachedFunction
13
+ from .cached import cached as cached
14
+ from .dependencies import AppCache as AppCache
15
+ from .dependencies import CacheBackend as CacheBackend
16
+ from .dependencies import get_app_cache as get_app_cache
17
+ from .dependencies import get_cache_backend as get_cache_backend
18
+ from .exceptions import BackendNotFoundError as BackendNotFoundError
19
+ from .exceptions import BackendUnavailableError as BackendUnavailableError
20
+ from .exceptions import CacheXError as CacheXError
21
+ from .exceptions import InvalidTTLError as InvalidTTLError
22
+ from .exceptions import LockTimeoutError as LockTimeoutError
23
+ from .exceptions import ProxyNotSetError as ProxyNotSetError
24
+ from .exceptions import RequestNotFoundError as RequestNotFoundError
25
+ from .exceptions import TTLTypeError as TTLTypeError
26
+ from .exceptions import TTLValueError as TTLValueError
27
+ from .lock import CacheLock as CacheLock
28
+ from .manager import CacheManager as CacheManager
29
+ from .manager_proxy import CacheManagerProxy as CacheManagerProxy
30
+ from .proxy import BackendProxy as BackendProxy
31
+ from .routes import add_routes as add_routes
32
+ from .types import CacheKeyBuilder as CacheKeyBuilder
33
+
34
+
35
+ def _read_version() -> str:
36
+ """Return the installed distribution's version.
37
+
38
+ Importing from a source tree that was never installed leaves no metadata to
39
+ read; reporting a development version there is part of the contract, so this
40
+ lives in a function the tests can drive rather than behind a coverage pragma.
41
+ """
42
+ try:
43
+ return version("fastapi-cachex")
44
+ except PackageNotFoundError:
45
+ return "0.0.0.dev0"
46
+
47
+
48
+ __version__ = _read_version()
49
+
50
+ _package_logger = logging.getLogger("fastapi_cachex")
51
+ _package_logger.addHandler(
52
+ logging.NullHandler()
53
+ ) # Attach a NullHandler to avoid "No handler found" warnings in user applications.
54
+
55
+ __all__ = [
56
+ "AppCache",
57
+ "BackendNotFoundError",
58
+ "BackendProxy",
59
+ "BackendUnavailableError",
60
+ "CacheBackend",
61
+ "CacheKey",
62
+ "CacheKeyBuilder",
63
+ "CacheLock",
64
+ "CacheManager",
65
+ "CacheManagerProxy",
66
+ "CacheXError",
67
+ "CachedFunction",
68
+ "InvalidTTLError",
69
+ "LockTimeoutError",
70
+ "ProxyNotSetError",
71
+ "RequestNotFoundError",
72
+ "TTLTypeError",
73
+ "TTLValueError",
74
+ "__version__",
75
+ "add_routes",
76
+ "build_cache_key",
77
+ "cache",
78
+ "cached",
79
+ "default_key_builder",
80
+ "get_app_cache",
81
+ "get_cache_backend",
82
+ "invalidate",
83
+ ]
@@ -0,0 +1,150 @@
1
+ """The `Cache-Control` header `@cache` sends, and which responses stay private."""
2
+
3
+ from collections.abc import Iterable
4
+ from typing import Literal
5
+
6
+ from fastapi import Response
7
+
8
+ from .directives import DirectiveType
9
+
10
+ _NO_STORE = DirectiveType.NO_STORE.value
11
+
12
+
13
+ class CacheControl:
14
+ """Manages Cache-Control header directives."""
15
+
16
+ def __init__(self) -> None:
17
+ """Initialize an empty CacheControl instance."""
18
+ self.directives: list[str] = []
19
+
20
+ def add(self, directive: DirectiveType, value: int | None = None) -> None:
21
+ """Add a Cache-Control directive.
22
+
23
+ Args:
24
+ directive: The directive type to add
25
+ value: Optional value for the directive
26
+ """
27
+ if value is not None:
28
+ self.directives.append(f"{directive.value}={value}")
29
+ else:
30
+ self.directives.append(directive.value)
31
+
32
+ def __str__(self) -> str:
33
+ """Return the Cache-Control header value as a string."""
34
+ return ", ".join(self.directives)
35
+
36
+
37
+ def _build_cache_control(
38
+ *,
39
+ ttl: int | None,
40
+ stale: Literal["error", "revalidate"] | None,
41
+ stale_ttl: int | None,
42
+ no_cache: bool,
43
+ public: bool,
44
+ private: bool,
45
+ immutable: bool,
46
+ must_revalidate: bool,
47
+ ) -> str:
48
+ """The ``Cache-Control`` value for a ``@cache`` route's arguments.
49
+
50
+ ``no_cache`` sends only ``no-cache`` (plus ``must-revalidate``); otherwise
51
+ the directives follow in a fixed order: scope, ``max-age``,
52
+ ``must-revalidate``, the stale directive, ``immutable``.
53
+ """
54
+ cache_control = CacheControl()
55
+ if no_cache:
56
+ cache_control.add(DirectiveType.NO_CACHE)
57
+ if must_revalidate:
58
+ cache_control.add(DirectiveType.MUST_REVALIDATE)
59
+ return str(cache_control)
60
+
61
+ # 1. Access scope (public/private)
62
+ if public:
63
+ cache_control.add(DirectiveType.PUBLIC)
64
+ elif private:
65
+ cache_control.add(DirectiveType.PRIVATE)
66
+
67
+ # 2. Cache time settings
68
+ if ttl is not None:
69
+ cache_control.add(DirectiveType.MAX_AGE, ttl)
70
+
71
+ # 3. Validation related
72
+ if must_revalidate:
73
+ cache_control.add(DirectiveType.MUST_REVALIDATE)
74
+
75
+ # 4. Stale response handling (stale_ttl is validated at decoration time)
76
+ if stale == "revalidate":
77
+ cache_control.add(DirectiveType.STALE_WHILE_REVALIDATE, stale_ttl)
78
+ elif stale == "error":
79
+ cache_control.add(DirectiveType.STALE_IF_ERROR, stale_ttl)
80
+
81
+ # 5. Special flags
82
+ if immutable:
83
+ cache_control.add(DirectiveType.IMMUTABLE)
84
+
85
+ return str(cache_control)
86
+
87
+
88
+ # Response directives by which the handler says its response belongs to one
89
+ # caller (``private``) or must not be kept at all (``no-store``).
90
+ _UNSHAREABLE_DIRECTIVES = frozenset(
91
+ {DirectiveType.PRIVATE.value, DirectiveType.NO_STORE.value}
92
+ )
93
+
94
+
95
+ def _marked_unshareable(response: Response) -> bool:
96
+ """Whether the handler's own ``Cache-Control`` has ``private`` or ``no-store``.
97
+
98
+ Directive names are matched as whole tokens, case-insensitively, across
99
+ every ``Cache-Control`` field the response carries.
100
+ """
101
+ return _has_unshareable_directive(response.headers.getlist("cache-control"))
102
+
103
+
104
+ def _has_unshareable_directive(cache_control: Iterable[str]) -> bool:
105
+ """Whether any of these ``Cache-Control`` values has ``private`` or ``no-store``."""
106
+ return any(
107
+ directive.split("=", 1)[0].strip().lower() in _UNSHAREABLE_DIRECTIVES
108
+ for value in cache_control
109
+ for directive in value.split(",")
110
+ )
111
+
112
+
113
+ def _unshareable_reason(response: Response) -> str | None:
114
+ """Why a rendered response must not be stored, or None when it may be."""
115
+ if _marked_unshareable(response):
116
+ return "response Cache-Control is private or no-store"
117
+ if "set-cookie" in response.headers:
118
+ return "response sets a cookie"
119
+ return None
120
+
121
+
122
+ def _cache_control_for(
123
+ response: Response, cache_control: str, private_cache_control: str
124
+ ) -> str:
125
+ """The ``Cache-Control`` to send for a response the handler just rendered.
126
+
127
+ A handler that marked its response ``private`` or ``no-store`` keeps its own
128
+ header; the decorator's would widen what the handler allowed. A response
129
+ that sets a cookie gets ``private_cache_control``, so a shared cache in
130
+ front of the app does not store it either. When the decorator has no
131
+ directive to send (a bare ``@cache()``), the handler's own header is kept,
132
+ or none is sent (#363); a cookie still gets ``private_cache_control``.
133
+ """
134
+ if _marked_unshareable(response):
135
+ return ", ".join(response.headers.getlist("cache-control"))
136
+ if "set-cookie" in response.headers:
137
+ return private_cache_control
138
+ if not cache_control:
139
+ return ", ".join(response.headers.getlist("cache-control"))
140
+ return cache_control
141
+
142
+
143
+ def _with_cache_control(
144
+ response: Response, cache_control: str, private_cache_control: str
145
+ ) -> Response:
146
+ if not _marked_unshareable(response):
147
+ value = _cache_control_for(response, cache_control, private_cache_control)
148
+ if value:
149
+ response.headers["Cache-Control"] = value
150
+ return response
@@ -0,0 +1,17 @@
1
+ """Telling whether a callable returns a coroutine, for handlers and key builders."""
2
+
3
+ import inspect
4
+ from collections.abc import Callable
5
+
6
+
7
+ def _is_coroutine_callable(func: Callable[..., object]) -> bool:
8
+ """Report whether calling `func` returns a coroutine.
9
+
10
+ `inspect.iscoroutinefunction` already sees through `functools.partial`;
11
+ an instance with an ``async def __call__`` needs its method checked. The
12
+ method is looked up on the type, as the call itself does, so a class
13
+ (whose type is ``type``) counts as sync.
14
+ """
15
+ return inspect.iscoroutinefunction(func) or inspect.iscoroutinefunction(
16
+ type(func).__call__
17
+ )
@@ -0,0 +1,70 @@
1
+ """In-process coalescing of concurrent misses for `@cache(coalesce=True)` (#252).
2
+
3
+ The first request to miss a key leads: it runs the handler as usual. Requests
4
+ that miss the same key while it runs follow: they wait until the leader has
5
+ answered, then read the backend again. A follower that still finds no entry
6
+ (the leader's response was not stored, or the handler failed) renders its own
7
+ response without waiting again, so followers never queue behind each other.
8
+
9
+ Only requests in this process are coalesced. The registry is keyed by event
10
+ loop and backend as well as the cache key, so two apps or loops never wait on
11
+ each other.
12
+ """
13
+
14
+ import asyncio
15
+
16
+ # One future per key being rendered by a leader; done once it has answered.
17
+ _IN_FLIGHT: dict[tuple[int, int, str], asyncio.Future[None]] = {}
18
+
19
+
20
+ class _Flight:
21
+ """One request's part in coalescing: leader, follower or neither.
22
+
23
+ The wrapper creates one per request and calls ``finish()`` once the
24
+ request has been answered, however it ended, so a leader always releases
25
+ its followers and leaves nothing behind in the registry.
26
+ """
27
+
28
+ def __init__(self) -> None:
29
+ self._key: tuple[int, int, str] | None = None
30
+ self._future: asyncio.Future[None] | None = None
31
+
32
+ def join(
33
+ self, backend: object, cache_key: str, *, may_lead: bool
34
+ ) -> asyncio.Future[None] | None:
35
+ """Return the leader's future to wait on, or ``None`` to render.
36
+
37
+ With ``may_lead`` and no leader for the key, this request becomes the
38
+ leader. Without it (HEAD, whose response is never stored), it renders
39
+ without blocking anyone.
40
+ """
41
+ loop = asyncio.get_running_loop()
42
+ key = (id(loop), id(backend), cache_key)
43
+ running = _IN_FLIGHT.get(key)
44
+ if running is not None or not may_lead:
45
+ return running
46
+ self._key = key
47
+ self._future = loop.create_future()
48
+ _IN_FLIGHT[key] = self._future
49
+ return None
50
+
51
+ def finish(self) -> None:
52
+ """Release the followers, if this request led."""
53
+ if self._key is None or self._future is None:
54
+ return
55
+ # Only the leader registers or removes its key, and followers wait
56
+ # through a shield, so the future is still pending. `pop` because a
57
+ # test teardown may have cleared the registry already.
58
+ _IN_FLIGHT.pop(self._key, None)
59
+ self._future.set_result(None)
60
+ self._key = None
61
+ self._future = None
62
+
63
+
64
+ async def _wait_for(leader: asyncio.Future[None]) -> None:
65
+ """Wait until the leader has answered.
66
+
67
+ Shielded: a follower that is cancelled stops waiting without cancelling
68
+ the leader's future for the other followers.
69
+ """
70
+ await asyncio.shield(leader)
@@ -0,0 +1,175 @@
1
+ """Cache keys for `@cache`: the default key builder and custom ones."""
2
+
3
+ import inspect
4
+ import logging
5
+ from collections.abc import Callable
6
+ from collections.abc import Sequence
7
+ from functools import partial
8
+
9
+ from fastapi import Request
10
+
11
+ from ._callables import _is_coroutine_callable
12
+ from .cache_key import CacheKey
13
+ from .exceptions import CacheXError
14
+ from .types import CACHE_KEY_SEPARATOR
15
+ from .types import CacheKeyBuilder
16
+ from .types import escape_key_component
17
+
18
+ # The documented logger: every part of `@cache` logs under this name.
19
+ logger = logging.getLogger("fastapi_cachex.cache")
20
+
21
+
22
+ def build_cache_key(
23
+ request: Request, *components: str | int, sort_query: bool = True
24
+ ) -> str:
25
+ """Build the default cache key for ``request``, plus extra components.
26
+
27
+ With no ``components`` the key is ``http:v2|method|host|path|query``,
28
+ exactly what ``@cache`` uses by default. Each extra component is appended
29
+ after another separator, so a custom ``key_builder`` can add a dimension
30
+ (user ID, tenant, locale) without rebuilding the default key by hand::
31
+
32
+ def per_user_key(request: Request) -> str:
33
+ return build_cache_key(request, request.state.user_id)
34
+
35
+ The host is the ``Host`` header lower-cased, without an empty or default
36
+ port (``:80`` on http, ``:443`` on https), or ``unknown`` when there is
37
+ none. ``|`` and ``%`` in the host, the path and every extra component are
38
+ percent-encoded (see ``escape_key_component``), so none of them can
39
+ contain the separator and make one request's key equal another's. The
40
+ query string is already URL-encoded and never contains ``|``.
41
+
42
+ Keys built this way keep the tag, method, host and path in front, so
43
+ ``clear_path()`` still finds them and the monitoring routes still show
44
+ their method, host, path and query. This is
45
+ ``CacheKey.from_request(...).to_str()``; ``CacheKey.parse()`` decodes the
46
+ key again.
47
+
48
+ Args:
49
+ request: The FastAPI Request object
50
+ *components: Extra key components, appended in order. A ``str`` is
51
+ used as is and an ``int`` is written in decimal, so ``1`` and
52
+ ``"1"`` give the same key. An empty string is a component of its
53
+ own: ``build_cache_key(request, "")`` differs from
54
+ ``build_cache_key(request)``.
55
+ sort_query: Order the query parameters by name (a stable sort, so
56
+ ``?tag=b&tag=a`` stays distinct from ``?tag=a&tag=b``), so that
57
+ ``?a=1&b=2`` and ``?b=2&a=1`` give the same key. On by default,
58
+ as in ``@cache``; ``False`` keeps the order the client sent, as
59
+ ``@cache(sort_query=False)`` does.
60
+
61
+ Returns:
62
+ Generated cache key string
63
+
64
+ Raises:
65
+ TypeError: If a component is not a ``str`` or ``int`` (``bool`` is
66
+ rejected too), e.g. ``None`` from a missing user ID, which would
67
+ otherwise put every such caller under one ``"None"`` key.
68
+ """
69
+ key = CacheKey.from_request(request, *components, sort_query=sort_query).to_str()
70
+ logger.debug("Built cache key: %s", key)
71
+ return key
72
+
73
+
74
+ def _append_key_components(key: str, components: Sequence[str]) -> str:
75
+ """Append each component to ``key``, escaped, after another separator."""
76
+ return CACHE_KEY_SEPARATOR.join(
77
+ [key, *(escape_key_component(component) for component in components)]
78
+ )
79
+
80
+
81
+ def default_key_builder(request: Request) -> str:
82
+ """Default cache key builder function: ``build_cache_key(request)``.
83
+
84
+ Generates cache key in format: http:v2|method|host|path|query, with the
85
+ query parameters sorted by name.
86
+
87
+ Kept as the name ``@cache`` and ``invalidate()`` fall back to. To add
88
+ components to the default key, call ``build_cache_key`` instead.
89
+
90
+ Args:
91
+ request: The FastAPI Request object
92
+
93
+ Returns:
94
+ Generated cache key string
95
+ """
96
+ return build_cache_key(request)
97
+
98
+
99
+ def _unsorted_query_key_builder(request: Request) -> str:
100
+ """The key builder of ``@cache(sort_query=False)``."""
101
+ return build_cache_key(request, sort_query=False)
102
+
103
+
104
+ _SORT_QUERY_WITH_KEY_BUILDER_MSG = (
105
+ "sort_query only applies to the default key builder; a custom key_builder "
106
+ "builds its own key, so pass sort_query to build_cache_key() in it instead "
107
+ "(it sorts unless told otherwise)"
108
+ )
109
+
110
+
111
+ def _resolve_key_builder(
112
+ key_builder: CacheKeyBuilder | None, sort_query: object
113
+ ) -> CacheKeyBuilder:
114
+ """Pick the key builder for ``key_builder`` and ``sort_query``.
115
+
116
+ ``sort_query=None`` means it was not passed: the default key builder
117
+ sorts, and a custom one is used as is.
118
+
119
+ Raises:
120
+ CacheXError: If ``sort_query`` is not a ``bool`` or ``None``, if it
121
+ is passed with a custom ``key_builder`` (the flag would silently
122
+ do nothing), or if ``key_builder`` is an ``async`` callable.
123
+ """
124
+ if sort_query is not None and not isinstance(sort_query, bool):
125
+ msg = f"sort_query must be a bool, got {type(sort_query).__name__}"
126
+ raise CacheXError(msg)
127
+ if key_builder is not None:
128
+ if sort_query is not None:
129
+ raise CacheXError(_SORT_QUERY_WITH_KEY_BUILDER_MSG)
130
+ _validate_key_builder(key_builder)
131
+ return key_builder
132
+ return _unsorted_query_key_builder if sort_query is False else default_key_builder
133
+
134
+
135
+ _ASYNC_KEY_BUILDER_MSG = (
136
+ "key_builder must be a sync function returning str; async key builders "
137
+ "are not supported (the key is built without awaiting)"
138
+ )
139
+
140
+
141
+ def _validate_key_builder(builder: Callable[..., object]) -> None:
142
+ """Reject a key builder whose call returns a coroutine.
143
+
144
+ ``functools.partial`` layers are unwrapped first, so a partial of an async
145
+ callable object is caught as well. A builder this cannot see through
146
+ (say a sync wrapper returning a coroutine) is caught by ``_build_key``.
147
+
148
+ Raises:
149
+ CacheXError: If calling ``builder`` would return a coroutine.
150
+ """
151
+ target = builder
152
+ while isinstance(target, partial):
153
+ target = target.func
154
+ if _is_coroutine_callable(target):
155
+ raise CacheXError(_ASYNC_KEY_BUILDER_MSG)
156
+
157
+
158
+ def _build_key(builder: CacheKeyBuilder, request: Request) -> str:
159
+ """Call ``builder`` and check that it returned a ``str``.
160
+
161
+ A returned coroutine is closed, so it does not also trigger a "never
162
+ awaited" ``RuntimeWarning``. ``fail_open`` does not cover this: it is a
163
+ programming error in the route, not a backend failure.
164
+
165
+ Raises:
166
+ CacheXError: If ``builder`` returns anything but a ``str``.
167
+ """
168
+ key: object = builder(request)
169
+ if isinstance(key, str):
170
+ return key
171
+ if inspect.iscoroutine(key):
172
+ key.close()
173
+ raise CacheXError(_ASYNC_KEY_BUILDER_MSG)
174
+ msg = f"key_builder must return a str, got {type(key).__name__}"
175
+ raise CacheXError(msg)