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.

Files changed (24) hide show
  1. {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/PKG-INFO +4 -40
  2. {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/README.md +3 -39
  3. {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex/backends/memory.py +2 -2
  4. fastapi_cachex-0.1.4/fastapi_cachex/cache.py +248 -0
  5. fastapi_cachex-0.1.4/fastapi_cachex/py.typed +0 -0
  6. {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex.egg-info/PKG-INFO +4 -40
  7. {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex.egg-info/SOURCES.txt +1 -0
  8. {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/pyproject.toml +32 -1
  9. {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/tests/test_cache.py +150 -0
  10. fastapi_cachex-0.1.2/fastapi_cachex/cache.py +0 -193
  11. {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/LICENSE +0 -0
  12. {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex/__init__.py +0 -0
  13. {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex/backends/__init__.py +0 -0
  14. {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex/backends/base.py +0 -0
  15. {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex/backends/memcached.py +0 -0
  16. {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex/directives.py +0 -0
  17. {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex/exceptions.py +0 -0
  18. {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex/proxy.py +0 -0
  19. {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex/types.py +0 -0
  20. {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex.egg-info/dependency_links.txt +0 -0
  21. {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex.egg-info/requires.txt +0 -0
  22. {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/fastapi_cachex.egg-info/top_level.txt +0 -0
  23. {fastapi_cachex-0.1.2 → fastapi_cachex-0.1.4}/setup.cfg +0 -0
  24. {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.2
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
- ## Development Guide
129
+ ## Documentation
130
130
 
131
- ### Running Tests
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
- ## Development Guide
98
+ ## Documentation
99
99
 
100
- ### Running Tests
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.2
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
- ## Development Guide
129
+ ## Documentation
130
130
 
131
- ### Running Tests
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
 
@@ -6,6 +6,7 @@ fastapi_cachex/cache.py
6
6
  fastapi_cachex/directives.py
7
7
  fastapi_cachex/exceptions.py
8
8
  fastapi_cachex/proxy.py
9
+ fastapi_cachex/py.typed
9
10
  fastapi_cachex/types.py
10
11
  fastapi_cachex.egg-info/PKG-INFO
11
12
  fastapi_cachex.egg-info/SOURCES.txt
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-cachex"
3
- version = "0.1.2" # Initial release version
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