fastapi-cachex 0.1.2__tar.gz → 0.1.4__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.
Potentially problematic release.
This version of fastapi-cachex might be problematic. Click here for more details.
- {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/PKG-INFO +4 -40
- {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/README.md +3 -39
- {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex/backends/memory.py +2 -2
- fastapi_cachex-0.1.4/fastapi_cachex/cache.py +248 -0
- fastapi_cachex-0.1.4/fastapi_cachex/py.typed +0 -0
- {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex.egg-info/PKG-INFO +4 -40
- {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex.egg-info/SOURCES.txt +1 -0
- {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/pyproject.toml +32 -1
- {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/tests/test_cache.py +150 -0
- fastapi_cachex-0.1.2/fastapi_cachex/cache.py +0 -193
- {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/LICENSE +0 -0
- {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex/__init__.py +0 -0
- {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex/backends/__init__.py +0 -0
- {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex/backends/base.py +0 -0
- {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex/backends/memcached.py +0 -0
- {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex/directives.py +0 -0
- {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex/exceptions.py +0 -0
- {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex/proxy.py +0 -0
- {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex/types.py +0 -0
- {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex.egg-info/dependency_links.txt +0 -0
- {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex.egg-info/requires.txt +0 -0
- {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex.egg-info/top_level.txt +0 -0
- {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/setup.cfg +0 -0
- {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/tests/test_proxybackend.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-cachex
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: A caching library for FastAPI with support for Cache-Control, ETag, and multiple backends.
|
|
5
5
|
Author-email: Allen <s96016641@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -126,46 +126,10 @@ BackendProxy.set_backend(backend)
|
|
|
126
126
|
|
|
127
127
|
Redis support is under development and will be available in future releases.
|
|
128
128
|
|
|
129
|
-
##
|
|
129
|
+
## Documentation
|
|
130
130
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
1. Run unit tests:
|
|
134
|
-
|
|
135
|
-
```bash
|
|
136
|
-
pytest
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
2. Run tests with coverage report:
|
|
140
|
-
|
|
141
|
-
```bash
|
|
142
|
-
pytest --cov=fastapi_cachex
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
### Using tox
|
|
146
|
-
|
|
147
|
-
tox ensures the code works across different Python versions (3.10-3.13).
|
|
148
|
-
|
|
149
|
-
1. Install all Python versions
|
|
150
|
-
2. Run tox:
|
|
151
|
-
|
|
152
|
-
```bash
|
|
153
|
-
tox
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
To run for a specific Python version:
|
|
157
|
-
|
|
158
|
-
```bash
|
|
159
|
-
tox -e py310 # only run for Python 3.10
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
## Contributing
|
|
163
|
-
|
|
164
|
-
1. Fork the project
|
|
165
|
-
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
|
|
166
|
-
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
|
|
167
|
-
4. Push to the branch (`git push origin feature/AmazingFeature`)
|
|
168
|
-
5. Open a Pull Request
|
|
131
|
+
- [Development Guide](docs/DEVELOPMENT.md)
|
|
132
|
+
- [Contributing Guidelines](docs/CONTRIBUTING.md)
|
|
169
133
|
|
|
170
134
|
## License
|
|
171
135
|
|
|
@@ -95,46 +95,10 @@ BackendProxy.set_backend(backend)
|
|
|
95
95
|
|
|
96
96
|
Redis support is under development and will be available in future releases.
|
|
97
97
|
|
|
98
|
-
##
|
|
98
|
+
## Documentation
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
1. Run unit tests:
|
|
103
|
-
|
|
104
|
-
```bash
|
|
105
|
-
pytest
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
2. Run tests with coverage report:
|
|
109
|
-
|
|
110
|
-
```bash
|
|
111
|
-
pytest --cov=fastapi_cachex
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
### Using tox
|
|
115
|
-
|
|
116
|
-
tox ensures the code works across different Python versions (3.10-3.13).
|
|
117
|
-
|
|
118
|
-
1. Install all Python versions
|
|
119
|
-
2. Run tox:
|
|
120
|
-
|
|
121
|
-
```bash
|
|
122
|
-
tox
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
To run for a specific Python version:
|
|
126
|
-
|
|
127
|
-
```bash
|
|
128
|
-
tox -e py310 # only run for Python 3.10
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
## Contributing
|
|
132
|
-
|
|
133
|
-
1. Fork the project
|
|
134
|
-
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
|
|
135
|
-
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
|
|
136
|
-
4. Push to the branch (`git push origin feature/AmazingFeature`)
|
|
137
|
-
5. Open a Pull Request
|
|
100
|
+
- [Development Guide](docs/DEVELOPMENT.md)
|
|
101
|
+
- [Contributing Guidelines](docs/CONTRIBUTING.md)
|
|
138
102
|
|
|
139
103
|
## License
|
|
140
104
|
|
|
@@ -15,7 +15,7 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
15
15
|
self.cache: dict[str, CacheItem] = {}
|
|
16
16
|
self.lock = asyncio.Lock()
|
|
17
17
|
self.cleanup_interval = 60
|
|
18
|
-
self._cleanup_task: Optional[asyncio.Task] = None
|
|
18
|
+
self._cleanup_task: Optional[asyncio.Task[None]] = None
|
|
19
19
|
|
|
20
20
|
def start_cleanup(self) -> None:
|
|
21
21
|
"""Start the cleanup task if it's not already running."""
|
|
@@ -42,7 +42,7 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
42
42
|
self, key: str, value: ETagContent, ttl: Optional[int] = None
|
|
43
43
|
) -> None:
|
|
44
44
|
async with self.lock:
|
|
45
|
-
expiry = time.time() + ttl if ttl is not None else None
|
|
45
|
+
expiry: Optional[int] = int(time.time() + ttl) if ttl is not None else None
|
|
46
46
|
self.cache[key] = CacheItem(value=value, expiry=expiry)
|
|
47
47
|
|
|
48
48
|
async def delete(self, key: str) -> None:
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import inspect
|
|
3
|
+
from collections.abc import Awaitable
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from functools import update_wrapper
|
|
6
|
+
from functools import wraps
|
|
7
|
+
from inspect import Parameter
|
|
8
|
+
from inspect import Signature
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
from typing import Any
|
|
11
|
+
from typing import Literal
|
|
12
|
+
from typing import Optional
|
|
13
|
+
from typing import TypeVar
|
|
14
|
+
from typing import Union
|
|
15
|
+
|
|
16
|
+
from fastapi import Request
|
|
17
|
+
from fastapi import Response
|
|
18
|
+
from fastapi.datastructures import DefaultPlaceholder
|
|
19
|
+
from starlette.status import HTTP_304_NOT_MODIFIED
|
|
20
|
+
|
|
21
|
+
from fastapi_cachex.backends import MemoryBackend
|
|
22
|
+
from fastapi_cachex.directives import DirectiveType
|
|
23
|
+
from fastapi_cachex.exceptions import BackendNotFoundError
|
|
24
|
+
from fastapi_cachex.exceptions import CacheXError
|
|
25
|
+
from fastapi_cachex.exceptions import RequestNotFoundError
|
|
26
|
+
from fastapi_cachex.proxy import BackendProxy
|
|
27
|
+
from fastapi_cachex.types import ETagContent
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from fastapi.routing import APIRoute
|
|
31
|
+
|
|
32
|
+
T = TypeVar("T", bound=Response)
|
|
33
|
+
AsyncCallable = Callable[..., Awaitable[T]]
|
|
34
|
+
SyncCallable = Callable[..., T]
|
|
35
|
+
AnyCallable = Union[AsyncCallable[T], SyncCallable[T]] # noqa: UP007
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CacheControl:
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self.directives: list[str] = []
|
|
41
|
+
|
|
42
|
+
def add(self, directive: DirectiveType, value: Optional[int] = None) -> None:
|
|
43
|
+
if value is not None:
|
|
44
|
+
self.directives.append(f"{directive.value}={value}")
|
|
45
|
+
else:
|
|
46
|
+
self.directives.append(directive.value)
|
|
47
|
+
|
|
48
|
+
def __str__(self) -> str:
|
|
49
|
+
return ", ".join(self.directives)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def get_response(
|
|
53
|
+
__func: AnyCallable[Response], __request: Request, *args: Any, **kwargs: Any
|
|
54
|
+
) -> Response:
|
|
55
|
+
"""Get the response from the function."""
|
|
56
|
+
if inspect.iscoroutinefunction(__func):
|
|
57
|
+
result = await __func(*args, **kwargs)
|
|
58
|
+
else:
|
|
59
|
+
result = __func(*args, **kwargs)
|
|
60
|
+
|
|
61
|
+
# If already a Response object, return it directly
|
|
62
|
+
if isinstance(result, Response):
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
# Get response_class from route if available
|
|
66
|
+
route: APIRoute | None = __request.scope.get("route")
|
|
67
|
+
if route is None: # pragma: no cover
|
|
68
|
+
raise CacheXError("Route not found in request scope")
|
|
69
|
+
|
|
70
|
+
if isinstance(route.response_class, DefaultPlaceholder):
|
|
71
|
+
response_class: type[Response] = route.response_class.value
|
|
72
|
+
|
|
73
|
+
else:
|
|
74
|
+
response_class = route.response_class
|
|
75
|
+
|
|
76
|
+
# Convert non-Response result to Response using appropriate response_class
|
|
77
|
+
return response_class(content=result)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cache( # noqa: C901
|
|
81
|
+
ttl: Optional[int] = None,
|
|
82
|
+
stale_ttl: Optional[int] = None,
|
|
83
|
+
stale: Literal["error", "revalidate"] | None = None,
|
|
84
|
+
no_cache: bool = False,
|
|
85
|
+
no_store: bool = False,
|
|
86
|
+
public: bool = False,
|
|
87
|
+
private: bool = False,
|
|
88
|
+
immutable: bool = False,
|
|
89
|
+
must_revalidate: bool = False,
|
|
90
|
+
) -> Callable[[AnyCallable[Response]], AsyncCallable[Response]]:
|
|
91
|
+
def decorator(func: AnyCallable[Response]) -> AsyncCallable[Response]: # noqa: C901
|
|
92
|
+
try:
|
|
93
|
+
cache_backend = BackendProxy.get_backend()
|
|
94
|
+
except BackendNotFoundError:
|
|
95
|
+
# Fallback to memory backend if no backend is set
|
|
96
|
+
cache_backend = MemoryBackend()
|
|
97
|
+
BackendProxy.set_backend(cache_backend)
|
|
98
|
+
|
|
99
|
+
# Analyze the original function's signature
|
|
100
|
+
sig: Signature = inspect.signature(func)
|
|
101
|
+
params: list[Parameter] = list(sig.parameters.values())
|
|
102
|
+
|
|
103
|
+
# Check if Request is already in the parameters
|
|
104
|
+
found_request: Parameter | None = next(
|
|
105
|
+
(param for param in params if param.annotation == Request), None
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Add Request parameter if it's not present
|
|
109
|
+
if not found_request:
|
|
110
|
+
request_name: str = "__cachex_request"
|
|
111
|
+
|
|
112
|
+
request_param = inspect.Parameter(
|
|
113
|
+
request_name,
|
|
114
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
115
|
+
annotation=Request,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
sig = sig.replace(parameters=[*params, request_param])
|
|
119
|
+
|
|
120
|
+
else:
|
|
121
|
+
request_name = found_request.name
|
|
122
|
+
|
|
123
|
+
async def get_cache_control(cache_control: CacheControl) -> str: # noqa: C901
|
|
124
|
+
# Set Cache-Control headers
|
|
125
|
+
if no_cache:
|
|
126
|
+
cache_control.add(DirectiveType.NO_CACHE)
|
|
127
|
+
if must_revalidate:
|
|
128
|
+
cache_control.add(DirectiveType.MUST_REVALIDATE)
|
|
129
|
+
else:
|
|
130
|
+
# Handle normal cache control cases
|
|
131
|
+
# 1. Access scope (public/private)
|
|
132
|
+
if public:
|
|
133
|
+
cache_control.add(DirectiveType.PUBLIC)
|
|
134
|
+
elif private:
|
|
135
|
+
cache_control.add(DirectiveType.PRIVATE)
|
|
136
|
+
|
|
137
|
+
# 2. Cache time settings
|
|
138
|
+
if ttl is not None:
|
|
139
|
+
cache_control.add(DirectiveType.MAX_AGE, ttl)
|
|
140
|
+
|
|
141
|
+
# 3. Validation related
|
|
142
|
+
if must_revalidate:
|
|
143
|
+
cache_control.add(DirectiveType.MUST_REVALIDATE)
|
|
144
|
+
|
|
145
|
+
# 4. Stale response handling
|
|
146
|
+
if stale is not None and stale_ttl is None:
|
|
147
|
+
raise CacheXError("stale_ttl must be set if stale is used")
|
|
148
|
+
|
|
149
|
+
if stale == "revalidate":
|
|
150
|
+
cache_control.add(DirectiveType.STALE_WHILE_REVALIDATE, stale_ttl)
|
|
151
|
+
elif stale == "error":
|
|
152
|
+
cache_control.add(DirectiveType.STALE_IF_ERROR, stale_ttl)
|
|
153
|
+
|
|
154
|
+
# 5. Special flags
|
|
155
|
+
if immutable:
|
|
156
|
+
cache_control.add(DirectiveType.IMMUTABLE)
|
|
157
|
+
|
|
158
|
+
return str(cache_control)
|
|
159
|
+
|
|
160
|
+
@wraps(func)
|
|
161
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Response: # noqa: C901
|
|
162
|
+
if found_request:
|
|
163
|
+
req: Request | None = kwargs.get(request_name)
|
|
164
|
+
else:
|
|
165
|
+
req = kwargs.pop(request_name, None)
|
|
166
|
+
|
|
167
|
+
if not req: # pragma: no cover
|
|
168
|
+
# Skip coverage for this case, as it should not happen
|
|
169
|
+
raise RequestNotFoundError()
|
|
170
|
+
|
|
171
|
+
# Only cache GET requests
|
|
172
|
+
if req.method != "GET":
|
|
173
|
+
return await get_response(func, req, *args, **kwargs)
|
|
174
|
+
|
|
175
|
+
# Generate cache key and prepare headers
|
|
176
|
+
cache_key = f"{req.url.path}:{req.query_params}"
|
|
177
|
+
client_etag = req.headers.get("if-none-match")
|
|
178
|
+
cache_control = await get_cache_control(CacheControl())
|
|
179
|
+
|
|
180
|
+
# Handle special case: no-store (highest priority)
|
|
181
|
+
if no_store:
|
|
182
|
+
response = await get_response(func, req, *args, **kwargs)
|
|
183
|
+
cc = CacheControl()
|
|
184
|
+
cc.add(DirectiveType.NO_STORE)
|
|
185
|
+
response.headers["Cache-Control"] = str(cc)
|
|
186
|
+
return response
|
|
187
|
+
|
|
188
|
+
# Check cache and handle ETag validation
|
|
189
|
+
cached_data = await cache_backend.get(cache_key)
|
|
190
|
+
|
|
191
|
+
current_response = None
|
|
192
|
+
current_etag = None
|
|
193
|
+
|
|
194
|
+
if client_etag:
|
|
195
|
+
if no_cache:
|
|
196
|
+
# Get fresh response first if using no-cache
|
|
197
|
+
current_response = await get_response(func, req, *args, **kwargs)
|
|
198
|
+
current_etag = (
|
|
199
|
+
f'W/"{hashlib.md5(current_response.body).hexdigest()}"' # noqa: S324
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if client_etag == current_etag:
|
|
203
|
+
# For no-cache, compare fresh data with client's ETag
|
|
204
|
+
return Response(
|
|
205
|
+
status_code=HTTP_304_NOT_MODIFIED,
|
|
206
|
+
headers={
|
|
207
|
+
"ETag": current_etag,
|
|
208
|
+
"Cache-Control": cache_control,
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Compare with cached ETag
|
|
213
|
+
elif (
|
|
214
|
+
cached_data and client_etag == cached_data.etag
|
|
215
|
+
): # pragma: no branch
|
|
216
|
+
return Response(
|
|
217
|
+
status_code=HTTP_304_NOT_MODIFIED,
|
|
218
|
+
headers={
|
|
219
|
+
"ETag": cached_data.etag,
|
|
220
|
+
"Cache-Control": cache_control,
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if not current_response or not current_etag:
|
|
225
|
+
# Retrieve the current response if not already done
|
|
226
|
+
current_response = await get_response(func, req, *args, **kwargs)
|
|
227
|
+
current_etag = f'W/"{hashlib.md5(current_response.body).hexdigest()}"' # noqa: S324
|
|
228
|
+
|
|
229
|
+
# Set ETag header
|
|
230
|
+
current_response.headers["ETag"] = current_etag
|
|
231
|
+
|
|
232
|
+
# Update cache if needed
|
|
233
|
+
if not cached_data or cached_data.etag != current_etag:
|
|
234
|
+
# Store in cache if data changed
|
|
235
|
+
await cache_backend.set(
|
|
236
|
+
cache_key, ETagContent(current_etag, current_response.body), ttl=ttl
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
current_response.headers["Cache-Control"] = cache_control
|
|
240
|
+
return current_response
|
|
241
|
+
|
|
242
|
+
# Update the wrapper with the new signature
|
|
243
|
+
update_wrapper(wrapper, func)
|
|
244
|
+
wrapper.__signature__ = sig # type: ignore
|
|
245
|
+
|
|
246
|
+
return wrapper
|
|
247
|
+
|
|
248
|
+
return decorator
|
|
File without changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-cachex
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: A caching library for FastAPI with support for Cache-Control, ETag, and multiple backends.
|
|
5
5
|
Author-email: Allen <s96016641@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -126,46 +126,10 @@ BackendProxy.set_backend(backend)
|
|
|
126
126
|
|
|
127
127
|
Redis support is under development and will be available in future releases.
|
|
128
128
|
|
|
129
|
-
##
|
|
129
|
+
## Documentation
|
|
130
130
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
1. Run unit tests:
|
|
134
|
-
|
|
135
|
-
```bash
|
|
136
|
-
pytest
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
2. Run tests with coverage report:
|
|
140
|
-
|
|
141
|
-
```bash
|
|
142
|
-
pytest --cov=fastapi_cachex
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
### Using tox
|
|
146
|
-
|
|
147
|
-
tox ensures the code works across different Python versions (3.10-3.13).
|
|
148
|
-
|
|
149
|
-
1. Install all Python versions
|
|
150
|
-
2. Run tox:
|
|
151
|
-
|
|
152
|
-
```bash
|
|
153
|
-
tox
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
To run for a specific Python version:
|
|
157
|
-
|
|
158
|
-
```bash
|
|
159
|
-
tox -e py310 # only run for Python 3.10
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
## Contributing
|
|
163
|
-
|
|
164
|
-
1. Fork the project
|
|
165
|
-
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
|
|
166
|
-
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
|
|
167
|
-
4. Push to the branch (`git push origin feature/AmazingFeature`)
|
|
168
|
-
5. Open a Pull Request
|
|
131
|
+
- [Development Guide](docs/DEVELOPMENT.md)
|
|
132
|
+
- [Contributing Guidelines](docs/CONTRIBUTING.md)
|
|
169
133
|
|
|
170
134
|
## License
|
|
171
135
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-cachex"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.4" # Initial release version
|
|
4
4
|
description = "A caching library for FastAPI with support for Cache-Control, ETag, and multiple backends."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -36,6 +36,7 @@ Issues = "https://github.com/allen0099/FastAPI-CacheX/issues"
|
|
|
36
36
|
[dependency-groups]
|
|
37
37
|
dev = [
|
|
38
38
|
"coverage>=7.8.0",
|
|
39
|
+
"mypy>=1.15.0",
|
|
39
40
|
"pre-commit>=4.2.0",
|
|
40
41
|
"pymemcache>=4.0.0",
|
|
41
42
|
"pytest>=8.3.5",
|
|
@@ -48,6 +49,9 @@ dev = [
|
|
|
48
49
|
[project.optional-dependencies]
|
|
49
50
|
memcache = ["pymemcache"]
|
|
50
51
|
|
|
52
|
+
[tool.setuptools]
|
|
53
|
+
package-data = {"fastapi_cachex" = ["py.typed"]}
|
|
54
|
+
|
|
51
55
|
[tool.pytest.ini_options]
|
|
52
56
|
pythonpath = [
|
|
53
57
|
"."
|
|
@@ -125,3 +129,30 @@ exclude_lines = [
|
|
|
125
129
|
]
|
|
126
130
|
fail_under = 90
|
|
127
131
|
show_missing = true
|
|
132
|
+
|
|
133
|
+
[tool.mypy]
|
|
134
|
+
python_version = "3.10"
|
|
135
|
+
plugins = [
|
|
136
|
+
"pydantic.mypy"
|
|
137
|
+
]
|
|
138
|
+
warn_redundant_casts = true
|
|
139
|
+
warn_unused_ignores = true
|
|
140
|
+
disallow_any_generics = true
|
|
141
|
+
check_untyped_defs = true
|
|
142
|
+
disallow_untyped_defs = true
|
|
143
|
+
disallow_incomplete_defs = true
|
|
144
|
+
disallow_untyped_decorators = true
|
|
145
|
+
no_implicit_optional = true
|
|
146
|
+
warn_return_any = true
|
|
147
|
+
warn_unreachable = true
|
|
148
|
+
strict_optional = true
|
|
149
|
+
strict_equality = true
|
|
150
|
+
|
|
151
|
+
[[tool.mypy.overrides]]
|
|
152
|
+
module = ["tests.*"]
|
|
153
|
+
disallow_untyped_defs = false
|
|
154
|
+
disallow_incomplete_defs = false
|
|
155
|
+
|
|
156
|
+
[[tool.mypy.overrides]]
|
|
157
|
+
module = ["pymemcache.*"]
|
|
158
|
+
ignore_missing_imports = true
|
|
@@ -2,7 +2,9 @@ from fastapi import FastAPI
|
|
|
2
2
|
from fastapi import Response
|
|
3
3
|
from fastapi.testclient import TestClient
|
|
4
4
|
from starlette.requests import Request
|
|
5
|
+
from starlette.responses import HTMLResponse
|
|
5
6
|
from starlette.responses import JSONResponse
|
|
7
|
+
from starlette.responses import PlainTextResponse
|
|
6
8
|
|
|
7
9
|
from fastapi_cachex.cache import cache
|
|
8
10
|
|
|
@@ -25,6 +27,28 @@ def test_default_cache():
|
|
|
25
27
|
assert "ETag" in response.headers
|
|
26
28
|
|
|
27
29
|
|
|
30
|
+
def test_cache_with_ttl():
|
|
31
|
+
@app.get("/cache-with-ttl")
|
|
32
|
+
@cache(ttl=3)
|
|
33
|
+
async def cache_with_ttl_endpoint():
|
|
34
|
+
return Response(
|
|
35
|
+
content=b'{"message": "This endpoint has a TTL of 30 seconds"}',
|
|
36
|
+
media_type="application/json",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
response = client.get("/cache-with-ttl")
|
|
40
|
+
assert response.status_code == 200
|
|
41
|
+
assert response.headers["Cache-Control"] == "max-age=3"
|
|
42
|
+
assert "ETag" in response.headers
|
|
43
|
+
|
|
44
|
+
response2 = client.get(
|
|
45
|
+
"/cache-with-ttl", headers={"If-None-Match": response.headers["ETag"]}
|
|
46
|
+
)
|
|
47
|
+
assert response2.status_code == 304
|
|
48
|
+
assert response2.headers["Cache-Control"] == "max-age=3"
|
|
49
|
+
assert response2.headers["ETag"] == response.headers["ETag"]
|
|
50
|
+
|
|
51
|
+
|
|
28
52
|
def test_ttl_endpoint():
|
|
29
53
|
@app.get("/ttl")
|
|
30
54
|
@cache(60)
|
|
@@ -276,3 +300,129 @@ def test_post_should_not_cache():
|
|
|
276
300
|
response = client.post("/post")
|
|
277
301
|
assert response.status_code == 200
|
|
278
302
|
assert "cache-control" not in response.headers
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def test_use_default_response_class():
|
|
306
|
+
@app.get("/")
|
|
307
|
+
@cache()
|
|
308
|
+
async def default_response_class_endpoint():
|
|
309
|
+
return {"message": "This endpoint uses the default response class"}
|
|
310
|
+
|
|
311
|
+
response = client.get("/")
|
|
312
|
+
assert response.status_code == 200
|
|
313
|
+
assert response.headers["content-type"] == "application/json"
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def test_response_class_html():
|
|
317
|
+
@app.get("/html", response_class=HTMLResponse)
|
|
318
|
+
@cache(ttl=60)
|
|
319
|
+
async def html_endpoint():
|
|
320
|
+
return "<h1>Hello World</h1>"
|
|
321
|
+
|
|
322
|
+
response = client.get("/html")
|
|
323
|
+
assert response.status_code == 200
|
|
324
|
+
assert response.headers["content-type"] == "text/html; charset=utf-8"
|
|
325
|
+
assert response.text == "<h1>Hello World</h1>"
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def test_response_class_plain_text():
|
|
329
|
+
@app.get("/text", response_class=PlainTextResponse)
|
|
330
|
+
@cache(ttl=60)
|
|
331
|
+
async def text_endpoint():
|
|
332
|
+
return "Hello World"
|
|
333
|
+
|
|
334
|
+
response = client.get("/text")
|
|
335
|
+
assert response.status_code == 200
|
|
336
|
+
assert response.headers["content-type"] == "text/plain; charset=utf-8"
|
|
337
|
+
assert response.text == "Hello World"
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def test_response_class_json_with_raw_dict():
|
|
341
|
+
@app.get("/json-dict", response_class=JSONResponse)
|
|
342
|
+
@cache(ttl=60)
|
|
343
|
+
async def json_dict_endpoint():
|
|
344
|
+
return {"message": "Hello World"}
|
|
345
|
+
|
|
346
|
+
response = client.get("/json-dict")
|
|
347
|
+
assert response.status_code == 200
|
|
348
|
+
assert response.headers["content-type"] == "application/json"
|
|
349
|
+
assert response.json() == {"message": "Hello World"}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def test_response_class_with_etag():
|
|
353
|
+
"""Test that different response classes still generate and handle ETags correctly"""
|
|
354
|
+
|
|
355
|
+
@app.get("/html-etag", response_class=HTMLResponse)
|
|
356
|
+
@cache()
|
|
357
|
+
async def html_etag_endpoint():
|
|
358
|
+
return "<h1>Hello World</h1>"
|
|
359
|
+
|
|
360
|
+
# First request
|
|
361
|
+
response1 = client.get("/html-etag")
|
|
362
|
+
assert response1.status_code == 200
|
|
363
|
+
assert "ETag" in response1.headers
|
|
364
|
+
|
|
365
|
+
# Second request with ETag
|
|
366
|
+
etag = response1.headers["ETag"]
|
|
367
|
+
response2 = client.get("/html-etag", headers={"If-None-Match": etag})
|
|
368
|
+
assert response2.status_code == 304 # Not Modified
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def test_no_cache_with_unchanged_data():
|
|
372
|
+
"""Test no-cache behavior when data hasn't changed."""
|
|
373
|
+
counter = 0
|
|
374
|
+
|
|
375
|
+
@app.get("/no-cache-unchanged")
|
|
376
|
+
@cache(no_cache=True)
|
|
377
|
+
async def no_cache_unchanged_endpoint():
|
|
378
|
+
return {"message": "This endpoint uses no-cache", "counter": counter}
|
|
379
|
+
|
|
380
|
+
# First request should return 200
|
|
381
|
+
response1 = client.get("/no-cache-unchanged")
|
|
382
|
+
assert response1.status_code == 200
|
|
383
|
+
assert response1.json() == {"message": "This endpoint uses no-cache", "counter": 0}
|
|
384
|
+
etag1 = response1.headers["ETag"]
|
|
385
|
+
assert "no-cache" in response1.headers["Cache-Control"].lower()
|
|
386
|
+
|
|
387
|
+
# Second request with ETag should return 304 as data hasn't changed
|
|
388
|
+
response2 = client.get("/no-cache-unchanged", headers={"If-None-Match": etag1})
|
|
389
|
+
assert response2.status_code == 304
|
|
390
|
+
assert "ETag" in response2.headers
|
|
391
|
+
assert response2.headers["ETag"] == etag1
|
|
392
|
+
|
|
393
|
+
# Third request without ETag should return 200 but same data
|
|
394
|
+
response3 = client.get("/no-cache-unchanged")
|
|
395
|
+
assert response3.status_code == 200
|
|
396
|
+
assert response3.json() == {"message": "This endpoint uses no-cache", "counter": 0}
|
|
397
|
+
assert response3.headers["ETag"] == etag1
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def test_no_cache_with_changing_data():
|
|
401
|
+
"""Test no-cache behavior when data changes between requests."""
|
|
402
|
+
counter = {"value": 0}
|
|
403
|
+
|
|
404
|
+
@app.get("/no-cache-changing")
|
|
405
|
+
@cache(no_cache=True)
|
|
406
|
+
async def no_cache_changing_endpoint():
|
|
407
|
+
counter["value"] += 1
|
|
408
|
+
return {"message": "This endpoint uses no-cache", "counter": counter["value"]}
|
|
409
|
+
|
|
410
|
+
# First request
|
|
411
|
+
response1 = client.get("/no-cache-changing")
|
|
412
|
+
assert response1.status_code == 200
|
|
413
|
+
assert response1.json() == {"message": "This endpoint uses no-cache", "counter": 1}
|
|
414
|
+
etag1 = response1.headers["ETag"]
|
|
415
|
+
assert "no-cache" in response1.headers["Cache-Control"].lower()
|
|
416
|
+
|
|
417
|
+
# Second request with previous ETag should still return 200 with new data
|
|
418
|
+
response2 = client.get("/no-cache-changing", headers={"If-None-Match": etag1})
|
|
419
|
+
assert response2.status_code == 200 # Not 304 because data changed
|
|
420
|
+
assert response2.json() == {"message": "This endpoint uses no-cache", "counter": 2}
|
|
421
|
+
etag2 = response2.headers["ETag"]
|
|
422
|
+
assert etag2 != etag1 # ETags should be different as content changed
|
|
423
|
+
|
|
424
|
+
# Third request with latest ETag
|
|
425
|
+
response3 = client.get("/no-cache-changing", headers={"If-None-Match": etag2})
|
|
426
|
+
assert response3.status_code == 200
|
|
427
|
+
assert response3.json() == {"message": "This endpoint uses no-cache", "counter": 3}
|
|
428
|
+
assert response3.headers["ETag"] != etag2 # ETag should change again
|
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
import hashlib
|
|
2
|
-
import inspect
|
|
3
|
-
from collections.abc import Callable
|
|
4
|
-
from functools import wraps
|
|
5
|
-
from inspect import Parameter
|
|
6
|
-
from inspect import Signature
|
|
7
|
-
from typing import Any
|
|
8
|
-
from typing import Literal
|
|
9
|
-
from typing import Optional
|
|
10
|
-
|
|
11
|
-
from fastapi import Request
|
|
12
|
-
from fastapi import Response
|
|
13
|
-
from fastapi.responses import JSONResponse
|
|
14
|
-
from starlette.status import HTTP_304_NOT_MODIFIED
|
|
15
|
-
|
|
16
|
-
from fastapi_cachex.backends import MemoryBackend
|
|
17
|
-
from fastapi_cachex.directives import DirectiveType
|
|
18
|
-
from fastapi_cachex.exceptions import BackendNotFoundError
|
|
19
|
-
from fastapi_cachex.exceptions import CacheXError
|
|
20
|
-
from fastapi_cachex.exceptions import RequestNotFoundError
|
|
21
|
-
from fastapi_cachex.proxy import BackendProxy
|
|
22
|
-
from fastapi_cachex.types import ETagContent
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class CacheControl:
|
|
26
|
-
def __init__(self) -> None:
|
|
27
|
-
self.directives = []
|
|
28
|
-
|
|
29
|
-
def add(self, directive: DirectiveType, value: Optional[int] = None) -> None:
|
|
30
|
-
if value is not None:
|
|
31
|
-
self.directives.append(f"{directive.value}={value}")
|
|
32
|
-
else:
|
|
33
|
-
self.directives.append(directive.value)
|
|
34
|
-
|
|
35
|
-
def __str__(self) -> str:
|
|
36
|
-
return ", ".join(self.directives)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
async def get_response(func: Callable, *args: Any, **kwargs: Any) -> Response:
|
|
40
|
-
"""Get the response from the function."""
|
|
41
|
-
if inspect.iscoroutinefunction(func):
|
|
42
|
-
return await func(*args, **kwargs)
|
|
43
|
-
else:
|
|
44
|
-
return func(*args, **kwargs)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def cache( # noqa: C901
|
|
48
|
-
ttl: Optional[int] = None,
|
|
49
|
-
stale_ttl: Optional[int] = None,
|
|
50
|
-
stale: Literal["error", "revalidate"] | None = None,
|
|
51
|
-
no_cache: bool = False,
|
|
52
|
-
no_store: bool = False,
|
|
53
|
-
public: bool = False,
|
|
54
|
-
private: bool = False,
|
|
55
|
-
immutable: bool = False,
|
|
56
|
-
must_revalidate: bool = False,
|
|
57
|
-
) -> Callable:
|
|
58
|
-
def decorator(func: Callable) -> Callable: # noqa: C901
|
|
59
|
-
try:
|
|
60
|
-
cache_backend = BackendProxy.get_backend()
|
|
61
|
-
except BackendNotFoundError:
|
|
62
|
-
# Fallback to memory backend if no backend is set
|
|
63
|
-
cache_backend = MemoryBackend()
|
|
64
|
-
BackendProxy.set_backend(cache_backend)
|
|
65
|
-
|
|
66
|
-
# Analyze the original function's signature
|
|
67
|
-
sig: Signature = inspect.signature(func)
|
|
68
|
-
params: list[Parameter] = list(sig.parameters.values())
|
|
69
|
-
|
|
70
|
-
# Check if Request is already in the parameters
|
|
71
|
-
found_request: Parameter | None = next(
|
|
72
|
-
(param for param in params if param.annotation == Request), None
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
# Add Request parameter if it's not present
|
|
76
|
-
if not found_request:
|
|
77
|
-
request_name: str = "__cachex_request"
|
|
78
|
-
|
|
79
|
-
request_param = inspect.Parameter(
|
|
80
|
-
request_name,
|
|
81
|
-
inspect.Parameter.KEYWORD_ONLY,
|
|
82
|
-
annotation=Request,
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
sig = sig.replace(parameters=[*params, request_param])
|
|
86
|
-
|
|
87
|
-
else:
|
|
88
|
-
request_name = found_request.name
|
|
89
|
-
|
|
90
|
-
func.__signature__ = sig
|
|
91
|
-
|
|
92
|
-
@wraps(func)
|
|
93
|
-
async def wrapper(*args: Any, **kwargs: Any) -> Response: # noqa: C901
|
|
94
|
-
if found_request:
|
|
95
|
-
request: Request | None = kwargs.get(request_name)
|
|
96
|
-
else:
|
|
97
|
-
request: Request | None = kwargs.pop(request_name, None)
|
|
98
|
-
|
|
99
|
-
if not request: # pragma: no cover
|
|
100
|
-
# Skip coverage for this case, as it should not happen
|
|
101
|
-
raise RequestNotFoundError()
|
|
102
|
-
|
|
103
|
-
# Only cache GET requests
|
|
104
|
-
if request.method != "GET":
|
|
105
|
-
return await get_response(func, *args, **kwargs)
|
|
106
|
-
|
|
107
|
-
# Generate cache key
|
|
108
|
-
cache_key = f"{request.url.path}:{request.query_params}"
|
|
109
|
-
|
|
110
|
-
# Check if the data is already in the cache
|
|
111
|
-
cached_data = await cache_backend.get(cache_key)
|
|
112
|
-
|
|
113
|
-
if cached_data and cached_data.etag == (
|
|
114
|
-
request.headers.get("if-none-match")
|
|
115
|
-
):
|
|
116
|
-
return Response(
|
|
117
|
-
status_code=HTTP_304_NOT_MODIFIED,
|
|
118
|
-
headers={"ETag": cached_data.etag},
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
# Get the response
|
|
122
|
-
response = await get_response(func, *args, **kwargs)
|
|
123
|
-
|
|
124
|
-
# Generate ETag (hash based on response content)
|
|
125
|
-
if isinstance(response, JSONResponse):
|
|
126
|
-
content = response.body
|
|
127
|
-
else:
|
|
128
|
-
content = (
|
|
129
|
-
response.body
|
|
130
|
-
if hasattr(response, "body")
|
|
131
|
-
else str(response).encode()
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
# Calculate ETag
|
|
135
|
-
etag = f'W/"{hashlib.md5(content).hexdigest()}"' # noqa: S324
|
|
136
|
-
|
|
137
|
-
# Add ETag to response headers
|
|
138
|
-
response.headers["ETag"] = etag
|
|
139
|
-
|
|
140
|
-
# Handle Cache-Control header
|
|
141
|
-
cache_control = CacheControl()
|
|
142
|
-
|
|
143
|
-
# Handle special case: no-store (highest priority)
|
|
144
|
-
if no_store:
|
|
145
|
-
cache_control.add(DirectiveType.NO_STORE)
|
|
146
|
-
response.headers["Cache-Control"] = str(cache_control)
|
|
147
|
-
return response
|
|
148
|
-
|
|
149
|
-
# Handle special case: no-cache
|
|
150
|
-
if no_cache:
|
|
151
|
-
cache_control.add(DirectiveType.NO_CACHE)
|
|
152
|
-
if must_revalidate:
|
|
153
|
-
cache_control.add(DirectiveType.MUST_REVALIDATE)
|
|
154
|
-
response.headers["Cache-Control"] = str(cache_control)
|
|
155
|
-
return response
|
|
156
|
-
|
|
157
|
-
# Handle normal cache control cases
|
|
158
|
-
# 1. Access scope (public/private)
|
|
159
|
-
if public:
|
|
160
|
-
cache_control.add(DirectiveType.PUBLIC)
|
|
161
|
-
elif private:
|
|
162
|
-
cache_control.add(DirectiveType.PRIVATE)
|
|
163
|
-
|
|
164
|
-
# 2. Cache time settings
|
|
165
|
-
if ttl is not None:
|
|
166
|
-
cache_control.add(DirectiveType.MAX_AGE, ttl)
|
|
167
|
-
|
|
168
|
-
# 3. Validation related
|
|
169
|
-
if must_revalidate:
|
|
170
|
-
cache_control.add(DirectiveType.MUST_REVALIDATE)
|
|
171
|
-
|
|
172
|
-
# 4. Stale response handling
|
|
173
|
-
if stale is not None and stale_ttl is None:
|
|
174
|
-
raise CacheXError("stale_ttl must be set if stale is used")
|
|
175
|
-
|
|
176
|
-
if stale == "revalidate":
|
|
177
|
-
cache_control.add(DirectiveType.STALE_WHILE_REVALIDATE, stale_ttl)
|
|
178
|
-
elif stale == "error":
|
|
179
|
-
cache_control.add(DirectiveType.STALE_IF_ERROR, stale_ttl)
|
|
180
|
-
|
|
181
|
-
# 5. Special flags
|
|
182
|
-
if immutable:
|
|
183
|
-
cache_control.add(DirectiveType.IMMUTABLE)
|
|
184
|
-
|
|
185
|
-
# Store the data in the cache
|
|
186
|
-
await cache_backend.set(cache_key, ETagContent(etag, content), ttl=ttl)
|
|
187
|
-
|
|
188
|
-
response.headers["Cache-Control"] = str(cache_control)
|
|
189
|
-
return response
|
|
190
|
-
|
|
191
|
-
return wrapper
|
|
192
|
-
|
|
193
|
-
return decorator
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|