fastapi-cachex 0.1.2__py3-none-any.whl → 0.1.4__py3-none-any.whl

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.

@@ -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:
fastapi_cachex/cache.py CHANGED
@@ -1,16 +1,21 @@
1
1
  import hashlib
2
2
  import inspect
3
+ from collections.abc import Awaitable
3
4
  from collections.abc import Callable
5
+ from functools import update_wrapper
4
6
  from functools import wraps
5
7
  from inspect import Parameter
6
8
  from inspect import Signature
9
+ from typing import TYPE_CHECKING
7
10
  from typing import Any
8
11
  from typing import Literal
9
12
  from typing import Optional
13
+ from typing import TypeVar
14
+ from typing import Union
10
15
 
11
16
  from fastapi import Request
12
17
  from fastapi import Response
13
- from fastapi.responses import JSONResponse
18
+ from fastapi.datastructures import DefaultPlaceholder
14
19
  from starlette.status import HTTP_304_NOT_MODIFIED
15
20
 
16
21
  from fastapi_cachex.backends import MemoryBackend
@@ -21,10 +26,18 @@ from fastapi_cachex.exceptions import RequestNotFoundError
21
26
  from fastapi_cachex.proxy import BackendProxy
22
27
  from fastapi_cachex.types import ETagContent
23
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
+
24
37
 
25
38
  class CacheControl:
26
39
  def __init__(self) -> None:
27
- self.directives = []
40
+ self.directives: list[str] = []
28
41
 
29
42
  def add(self, directive: DirectiveType, value: Optional[int] = None) -> None:
30
43
  if value is not None:
@@ -36,12 +49,32 @@ class CacheControl:
36
49
  return ", ".join(self.directives)
37
50
 
38
51
 
39
- async def get_response(func: Callable, *args: Any, **kwargs: Any) -> Response:
52
+ async def get_response(
53
+ __func: AnyCallable[Response], __request: Request, *args: Any, **kwargs: Any
54
+ ) -> Response:
40
55
  """Get the response from the function."""
41
- if inspect.iscoroutinefunction(func):
42
- return await func(*args, **kwargs)
56
+ if inspect.iscoroutinefunction(__func):
57
+ result = await __func(*args, **kwargs)
43
58
  else:
44
- return func(*args, **kwargs)
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)
45
78
 
46
79
 
47
80
  def cache( # noqa: C901
@@ -54,8 +87,8 @@ def cache( # noqa: C901
54
87
  private: bool = False,
55
88
  immutable: bool = False,
56
89
  must_revalidate: bool = False,
57
- ) -> Callable:
58
- def decorator(func: Callable) -> Callable: # noqa: C901
90
+ ) -> Callable[[AnyCallable[Response]], AsyncCallable[Response]]:
91
+ def decorator(func: AnyCallable[Response]) -> AsyncCallable[Response]: # noqa: C901
59
92
  try:
60
93
  cache_backend = BackendProxy.get_backend()
61
94
  except BackendNotFoundError:
@@ -87,106 +120,128 @@ def cache( # noqa: C901
87
120
  else:
88
121
  request_name = found_request.name
89
122
 
90
- func.__signature__ = sig
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)
91
159
 
92
160
  @wraps(func)
93
161
  async def wrapper(*args: Any, **kwargs: Any) -> Response: # noqa: C901
94
162
  if found_request:
95
- request: Request | None = kwargs.get(request_name)
163
+ req: Request | None = kwargs.get(request_name)
96
164
  else:
97
- request: Request | None = kwargs.pop(request_name, None)
165
+ req = kwargs.pop(request_name, None)
98
166
 
99
- if not request: # pragma: no cover
167
+ if not req: # pragma: no cover
100
168
  # Skip coverage for this case, as it should not happen
101
169
  raise RequestNotFoundError()
102
170
 
103
171
  # 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
- )
172
+ if req.method != "GET":
173
+ return await get_response(func, req, *args, **kwargs)
120
174
 
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()
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())
142
179
 
143
180
  # Handle special case: no-store (highest priority)
144
181
  if no_store:
145
- cache_control.add(DirectiveType.NO_STORE)
146
- response.headers["Cache-Control"] = str(cache_control)
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)
147
186
  return response
148
187
 
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)
188
+ # Check cache and handle ETag validation
189
+ cached_data = await cache_backend.get(cache_key)
180
190
 
181
- # 5. Special flags
182
- if immutable:
183
- cache_control.add(DirectiveType.IMMUTABLE)
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
+ )
184
238
 
185
- # Store the data in the cache
186
- await cache_backend.set(cache_key, ETagContent(etag, content), ttl=ttl)
239
+ current_response.headers["Cache-Control"] = cache_control
240
+ return current_response
187
241
 
188
- response.headers["Cache-Control"] = str(cache_control)
189
- return response
242
+ # Update the wrapper with the new signature
243
+ update_wrapper(wrapper, func)
244
+ wrapper.__signature__ = sig # type: ignore
190
245
 
191
246
  return wrapper
192
247
 
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
 
@@ -1,15 +1,16 @@
1
1
  fastapi_cachex/__init__.py,sha256=K8zRD7pEOo77Ged7SJQ-BFNMe6Pnz8yM5ePFq97nI_s,82
2
- fastapi_cachex/cache.py,sha256=oMMPPcGISUWUuzVFhnd3KlgnL4ooMZErJVMQxieYy3A,6631
2
+ fastapi_cachex/cache.py,sha256=b-55IR0kdcVj4yUk8dplqqUy2avW49P-oI01ART9pyU,9174
3
3
  fastapi_cachex/directives.py,sha256=kJCmsbyQ89m6tsWo_c1vVJn3rk0pD5JZaY8xtNLcRh0,530
4
4
  fastapi_cachex/exceptions.py,sha256=coYct4u6uK_pdjetUWDwM5OUCfhql0OkTECynMRUq4M,379
5
5
  fastapi_cachex/proxy.py,sha256=vFShY7_xp4Sh1XU9dJzsBv2ICN8Rtwx6g1qCcCvmdf8,810
6
+ fastapi_cachex/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
7
  fastapi_cachex/types.py,sha256=YkXlBARIr5lHQE4PYQrwXjEoLHdz9CIjfX7V-S9N8p0,328
7
8
  fastapi_cachex/backends/__init__.py,sha256=U65JrCeh1eusklqUfV5yvZGK7Kfy5RctzfVrRfFPuaI,166
8
9
  fastapi_cachex/backends/base.py,sha256=eGfn0oZNQ8_drNHz4ZtqBVFSxKxEwW8y4ojw5iShgLQ,707
9
10
  fastapi_cachex/backends/memcached.py,sha256=g3184fHpFK7LH1UY9xfzRszBBzqmzeaLG806B5MsZDM,2190
10
- fastapi_cachex/backends/memory.py,sha256=-69k5-HwNzAPumemy-17LBZuBzUcA-qYhz2qCzVTOCc,2419
11
- fastapi_cachex-0.1.2.dist-info/licenses/LICENSE,sha256=asJkHbd10YDSnjeAOIlKafh7E_exwtKXY5rA-qc_Mno,11339
12
- fastapi_cachex-0.1.2.dist-info/METADATA,sha256=VIrxJdGN6n_YjWcFCPrCcYS_rsGIOEYtA5QLjRfQ9d0,5224
13
- fastapi_cachex-0.1.2.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
14
- fastapi_cachex-0.1.2.dist-info/top_level.txt,sha256=97FfG5FDycd3hks-_JznEr-5lUOgg8AZd8pqK5imWj0,15
15
- fastapi_cachex-0.1.2.dist-info/RECORD,,
11
+ fastapi_cachex/backends/memory.py,sha256=7KFSn5e1CvDzflZ5zqUPDQsBf6emcV0ob_tCsLQcDLw,2445
12
+ fastapi_cachex-0.1.4.dist-info/licenses/LICENSE,sha256=asJkHbd10YDSnjeAOIlKafh7E_exwtKXY5rA-qc_Mno,11339
13
+ fastapi_cachex-0.1.4.dist-info/METADATA,sha256=6qK6F6Pi338JYWN0NEv5SnR8gN4eJaXyFntNmFAu57s,4669
14
+ fastapi_cachex-0.1.4.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
15
+ fastapi_cachex-0.1.4.dist-info/top_level.txt,sha256=97FfG5FDycd3hks-_JznEr-5lUOgg8AZd8pqK5imWj0,15
16
+ fastapi_cachex-0.1.4.dist-info/RECORD,,