fastapi-cachex 0.1.3__tar.gz → 0.1.5__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.3 → fastapi_cachex-0.1.5}/PKG-INFO +4 -40
- {fastapi_cachex-0.1.3 → fastapi_cachex-0.1.5}/README.md +3 -39
- fastapi_cachex-0.1.5/fastapi_cachex/__init__.py +4 -0
- {fastapi_cachex-0.1.3 → fastapi_cachex-0.1.5}/fastapi_cachex/backends/base.py +23 -0
- {fastapi_cachex-0.1.3 → fastapi_cachex-0.1.5}/fastapi_cachex/backends/memcached.py +28 -1
- {fastapi_cachex-0.1.3 → fastapi_cachex-0.1.5}/fastapi_cachex/backends/memory.py +34 -0
- {fastapi_cachex-0.1.3 → fastapi_cachex-0.1.5}/fastapi_cachex/cache.py +95 -65
- fastapi_cachex-0.1.5/fastapi_cachex/dependencies.py +14 -0
- {fastapi_cachex-0.1.3 → fastapi_cachex-0.1.5}/fastapi_cachex.egg-info/PKG-INFO +4 -40
- {fastapi_cachex-0.1.3 → fastapi_cachex-0.1.5}/fastapi_cachex.egg-info/SOURCES.txt +2 -0
- {fastapi_cachex-0.1.3 → fastapi_cachex-0.1.5}/pyproject.toml +1 -1
- {fastapi_cachex-0.1.3 → fastapi_cachex-0.1.5}/tests/test_cache.py +82 -0
- fastapi_cachex-0.1.5/tests/test_dependencies.py +38 -0
- fastapi_cachex-0.1.3/fastapi_cachex/__init__.py +0 -2
- {fastapi_cachex-0.1.3 → fastapi_cachex-0.1.5}/LICENSE +0 -0
- {fastapi_cachex-0.1.3 → fastapi_cachex-0.1.5}/fastapi_cachex/backends/__init__.py +0 -0
- {fastapi_cachex-0.1.3 → fastapi_cachex-0.1.5}/fastapi_cachex/directives.py +0 -0
- {fastapi_cachex-0.1.3 → fastapi_cachex-0.1.5}/fastapi_cachex/exceptions.py +0 -0
- {fastapi_cachex-0.1.3 → fastapi_cachex-0.1.5}/fastapi_cachex/proxy.py +0 -0
- {fastapi_cachex-0.1.3 → fastapi_cachex-0.1.5}/fastapi_cachex/py.typed +0 -0
- {fastapi_cachex-0.1.3 → fastapi_cachex-0.1.5}/fastapi_cachex/types.py +0 -0
- {fastapi_cachex-0.1.3 → fastapi_cachex-0.1.5}/fastapi_cachex.egg-info/dependency_links.txt +0 -0
- {fastapi_cachex-0.1.3 → fastapi_cachex-0.1.5}/fastapi_cachex.egg-info/requires.txt +0 -0
- {fastapi_cachex-0.1.3 → fastapi_cachex-0.1.5}/fastapi_cachex.egg-info/top_level.txt +0 -0
- {fastapi_cachex-0.1.3 → fastapi_cachex-0.1.5}/setup.cfg +0 -0
- {fastapi_cachex-0.1.3 → fastapi_cachex-0.1.5}/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.5
|
|
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
|
|
|
@@ -25,3 +25,26 @@ class BaseCacheBackend(ABC):
|
|
|
25
25
|
@abstractmethod
|
|
26
26
|
async def clear(self) -> None:
|
|
27
27
|
"""Clear all cached responses."""
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
async def clear_path(self, path: str, include_params: bool = False) -> int:
|
|
31
|
+
"""Clear cached responses for a specific path.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
path: The path to clear cache for
|
|
35
|
+
include_params: Whether to clear all parameter variations of the path
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Number of cache entries cleared
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
async def clear_pattern(self, pattern: str) -> int:
|
|
43
|
+
"""Clear cached responses matching a pattern.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
pattern: A glob pattern to match cache keys against (e.g., "/users/*")
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Number of cache entries cleared
|
|
50
|
+
"""
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import ast
|
|
2
|
+
import warnings
|
|
2
3
|
from typing import Optional
|
|
3
4
|
|
|
4
5
|
from fastapi_cachex.backends.base import BaseCacheBackend
|
|
@@ -25,7 +26,7 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
25
26
|
"pymemcache is not installed. Please install it with 'pip install pymemcache'"
|
|
26
27
|
)
|
|
27
28
|
|
|
28
|
-
self.client = HashClient(servers)
|
|
29
|
+
self.client = HashClient(servers, connect_timeout=5, timeout=5)
|
|
29
30
|
|
|
30
31
|
async def get(self, key: str) -> Optional[ETagContent]:
|
|
31
32
|
"""Get value from cache.
|
|
@@ -72,3 +73,29 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
72
73
|
async def clear(self) -> None:
|
|
73
74
|
"""Clear all values from cache."""
|
|
74
75
|
self.client.flush_all()
|
|
76
|
+
|
|
77
|
+
async def clear_path(self, path: str, include_params: bool = False) -> int:
|
|
78
|
+
"""Clear cached responses for a specific path."""
|
|
79
|
+
if include_params:
|
|
80
|
+
warnings.warn(
|
|
81
|
+
"Memcached backend does not support pattern-based key clearing. "
|
|
82
|
+
"The include_params option will have no effect.",
|
|
83
|
+
RuntimeWarning,
|
|
84
|
+
stacklevel=2,
|
|
85
|
+
)
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
# If we're not including params, we can just try to delete the exact path
|
|
89
|
+
if self.client.delete(path, noreply=False):
|
|
90
|
+
return 1
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
async def clear_pattern(self, pattern: str) -> int: # noqa: ARG002
|
|
94
|
+
"""Clear cached responses matching a pattern."""
|
|
95
|
+
warnings.warn(
|
|
96
|
+
"Memcached backend does not support pattern matching. "
|
|
97
|
+
"Pattern-based cache clearing is not available.",
|
|
98
|
+
RuntimeWarning,
|
|
99
|
+
stacklevel=2,
|
|
100
|
+
)
|
|
101
|
+
return 0
|
|
@@ -53,6 +53,40 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
53
53
|
async with self.lock:
|
|
54
54
|
self.cache.clear()
|
|
55
55
|
|
|
56
|
+
async def clear_path(self, path: str, include_params: bool = False) -> int:
|
|
57
|
+
"""Clear cached responses for a specific path."""
|
|
58
|
+
cleared_count = 0
|
|
59
|
+
async with self.lock:
|
|
60
|
+
keys_to_delete = []
|
|
61
|
+
for key in self.cache:
|
|
62
|
+
cache_path, *params = key.split(":", 1)
|
|
63
|
+
if cache_path == path and (include_params or not params):
|
|
64
|
+
keys_to_delete.append(key)
|
|
65
|
+
cleared_count += 1
|
|
66
|
+
|
|
67
|
+
for key in keys_to_delete:
|
|
68
|
+
del self.cache[key]
|
|
69
|
+
|
|
70
|
+
return cleared_count
|
|
71
|
+
|
|
72
|
+
async def clear_pattern(self, pattern: str) -> int:
|
|
73
|
+
"""Clear cached responses matching a pattern."""
|
|
74
|
+
import fnmatch
|
|
75
|
+
|
|
76
|
+
cleared_count = 0
|
|
77
|
+
async with self.lock:
|
|
78
|
+
keys_to_delete = []
|
|
79
|
+
for key in self.cache:
|
|
80
|
+
cache_path = key.split(":", 1)[0] # Get path part only
|
|
81
|
+
if fnmatch.fnmatch(cache_path, pattern):
|
|
82
|
+
keys_to_delete.append(key)
|
|
83
|
+
cleared_count += 1
|
|
84
|
+
|
|
85
|
+
for key in keys_to_delete:
|
|
86
|
+
del self.cache[key]
|
|
87
|
+
|
|
88
|
+
return cleared_count
|
|
89
|
+
|
|
56
90
|
async def _cleanup_task_impl(self) -> None:
|
|
57
91
|
try:
|
|
58
92
|
while True:
|
|
@@ -120,6 +120,43 @@ def cache( # noqa: C901
|
|
|
120
120
|
else:
|
|
121
121
|
request_name = found_request.name
|
|
122
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
|
+
|
|
123
160
|
@wraps(func)
|
|
124
161
|
async def wrapper(*args: Any, **kwargs: Any) -> Response: # noqa: C901
|
|
125
162
|
if found_request:
|
|
@@ -135,79 +172,72 @@ def cache( # noqa: C901
|
|
|
135
172
|
if req.method != "GET":
|
|
136
173
|
return await get_response(func, req, *args, **kwargs)
|
|
137
174
|
|
|
138
|
-
# Generate cache key
|
|
175
|
+
# Generate cache key and prepare headers
|
|
139
176
|
cache_key = f"{req.url.path}:{req.query_params}"
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
cached_data = await cache_backend.get(cache_key)
|
|
143
|
-
|
|
144
|
-
if cached_data and cached_data.etag == req.headers.get("if-none-match"):
|
|
145
|
-
return Response(
|
|
146
|
-
status_code=HTTP_304_NOT_MODIFIED,
|
|
147
|
-
headers={"ETag": cached_data.etag},
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
# Get the response
|
|
151
|
-
response = await get_response(func, req, *args, **kwargs)
|
|
152
|
-
|
|
153
|
-
# Generate ETag (hash based on response content)
|
|
154
|
-
etag = f'W/"{hashlib.md5(response.body).hexdigest()}"' # noqa: S324
|
|
155
|
-
|
|
156
|
-
# Add ETag to response headers
|
|
157
|
-
response.headers["ETag"] = etag
|
|
158
|
-
|
|
159
|
-
# Handle Cache-Control header
|
|
160
|
-
cache_control = CacheControl()
|
|
177
|
+
client_etag = req.headers.get("if-none-match")
|
|
178
|
+
cache_control = await get_cache_control(CacheControl())
|
|
161
179
|
|
|
162
180
|
# Handle special case: no-store (highest priority)
|
|
163
181
|
if no_store:
|
|
164
|
-
|
|
165
|
-
|
|
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)
|
|
166
186
|
return response
|
|
167
187
|
|
|
168
|
-
#
|
|
169
|
-
|
|
170
|
-
cache_control.add(DirectiveType.NO_CACHE)
|
|
171
|
-
if must_revalidate:
|
|
172
|
-
cache_control.add(DirectiveType.MUST_REVALIDATE)
|
|
173
|
-
response.headers["Cache-Control"] = str(cache_control)
|
|
174
|
-
return response
|
|
188
|
+
# Check cache and handle ETag validation
|
|
189
|
+
cached_data = await cache_backend.get(cache_key)
|
|
175
190
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
+
)
|
|
208
238
|
|
|
209
|
-
|
|
210
|
-
return
|
|
239
|
+
current_response.headers["Cache-Control"] = cache_control
|
|
240
|
+
return current_response
|
|
211
241
|
|
|
212
242
|
# Update the wrapper with the new signature
|
|
213
243
|
update_wrapper(wrapper, func)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from fastapi import Depends
|
|
4
|
+
|
|
5
|
+
from fastapi_cachex.backends.base import BaseCacheBackend
|
|
6
|
+
from fastapi_cachex.proxy import BackendProxy
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_cache_backend() -> BaseCacheBackend:
|
|
10
|
+
"""Dependency to get the current cache backend instance."""
|
|
11
|
+
return BackendProxy.get_backend()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
CacheBackend = Annotated[BaseCacheBackend, Depends(get_cache_backend)]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-cachex
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
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
|
|
|
@@ -3,6 +3,7 @@ README.md
|
|
|
3
3
|
pyproject.toml
|
|
4
4
|
fastapi_cachex/__init__.py
|
|
5
5
|
fastapi_cachex/cache.py
|
|
6
|
+
fastapi_cachex/dependencies.py
|
|
6
7
|
fastapi_cachex/directives.py
|
|
7
8
|
fastapi_cachex/exceptions.py
|
|
8
9
|
fastapi_cachex/proxy.py
|
|
@@ -18,4 +19,5 @@ fastapi_cachex/backends/base.py
|
|
|
18
19
|
fastapi_cachex/backends/memcached.py
|
|
19
20
|
fastapi_cachex/backends/memory.py
|
|
20
21
|
tests/test_cache.py
|
|
22
|
+
tests/test_dependencies.py
|
|
21
23
|
tests/test_proxybackend.py
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-cachex"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.5" # 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"
|
|
@@ -27,6 +27,28 @@ def test_default_cache():
|
|
|
27
27
|
assert "ETag" in response.headers
|
|
28
28
|
|
|
29
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
|
+
|
|
30
52
|
def test_ttl_endpoint():
|
|
31
53
|
@app.get("/ttl")
|
|
32
54
|
@cache(60)
|
|
@@ -344,3 +366,63 @@ def test_response_class_with_etag():
|
|
|
344
366
|
etag = response1.headers["ETag"]
|
|
345
367
|
response2 = client.get("/html-etag", headers={"If-None-Match": etag})
|
|
346
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
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from fastapi import FastAPI
|
|
3
|
+
from fastapi.testclient import TestClient
|
|
4
|
+
|
|
5
|
+
from fastapi_cachex import BackendProxy
|
|
6
|
+
from fastapi_cachex import CacheBackend
|
|
7
|
+
from fastapi_cachex.backends import MemoryBackend
|
|
8
|
+
from fastapi_cachex.exceptions import BackendNotFoundError
|
|
9
|
+
|
|
10
|
+
# Setup FastAPI application
|
|
11
|
+
app = FastAPI()
|
|
12
|
+
client = TestClient(app)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Define test endpoint (not a test function)
|
|
16
|
+
@app.get("/test-backend")
|
|
17
|
+
async def backend_endpoint(backend: CacheBackend):
|
|
18
|
+
return {"backend_type": backend.__class__.__name__}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Actual test functions
|
|
22
|
+
@pytest.mark.asyncio
|
|
23
|
+
async def test_get_cache_backend_no_backend():
|
|
24
|
+
"""Test that get_cache_backend raises BackendNotFoundError when no backend is set."""
|
|
25
|
+
BackendProxy.set_backend(None)
|
|
26
|
+
with pytest.raises(BackendNotFoundError):
|
|
27
|
+
client.get("/test-backend")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.asyncio
|
|
31
|
+
async def test_get_cache_backend_with_memory_backend():
|
|
32
|
+
"""Test that get_cache_backend returns the configured backend."""
|
|
33
|
+
backend = MemoryBackend()
|
|
34
|
+
BackendProxy.set_backend(backend)
|
|
35
|
+
|
|
36
|
+
response = client.get("/test-backend")
|
|
37
|
+
assert response.status_code == 200
|
|
38
|
+
assert response.json() == {"backend_type": "MemoryBackend"}
|
|
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
|