fastapi-cachex 0.1.1__tar.gz → 0.1.3__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.1 → fastapi_cachex-0.1.3}/PKG-INFO +48 -5
- {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/README.md +45 -4
- {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex/backends/__init__.py +1 -0
- fastapi_cachex-0.1.3/fastapi_cachex/backends/memcached.py +74 -0
- {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex/backends/memory.py +2 -2
- {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex/cache.py +57 -32
- {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex/proxy.py +9 -3
- fastapi_cachex-0.1.3/fastapi_cachex/py.typed +0 -0
- {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex.egg-info/PKG-INFO +48 -5
- {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex.egg-info/SOURCES.txt +4 -1
- fastapi_cachex-0.1.3/fastapi_cachex.egg-info/requires.txt +5 -0
- {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/pyproject.toml +36 -1
- {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/tests/test_cache.py +68 -0
- fastapi_cachex-0.1.3/tests/test_proxybackend.py +101 -0
- fastapi_cachex-0.1.1/fastapi_cachex.egg-info/requires.txt +0 -2
- {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/LICENSE +0 -0
- {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex/__init__.py +0 -0
- {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex/backends/base.py +0 -0
- {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex/directives.py +0 -0
- {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex/exceptions.py +0 -0
- {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex/types.py +0 -0
- {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex.egg-info/dependency_links.txt +0 -0
- {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex.egg-info/top_level.txt +0 -0
- {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/setup.cfg +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.3
|
|
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
|
|
@@ -25,6 +25,8 @@ Description-Content-Type: text/markdown
|
|
|
25
25
|
License-File: LICENSE
|
|
26
26
|
Requires-Dist: fastapi
|
|
27
27
|
Requires-Dist: httpx
|
|
28
|
+
Provides-Extra: memcache
|
|
29
|
+
Requires-Dist: pymemcache; extra == "memcache"
|
|
28
30
|
Dynamic: license-file
|
|
29
31
|
|
|
30
32
|
# FastAPI-Cache X
|
|
@@ -34,8 +36,12 @@ Dynamic: license-file
|
|
|
34
36
|
[](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml)
|
|
35
37
|
[](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml)
|
|
36
38
|
|
|
37
|
-
[](https://pepy.tech/project/fastapi-cachex)
|
|
40
|
+
[](https://pepy.tech/project/fastapi-cachex)
|
|
41
|
+
[](https://pepy.tech/project/fastapi-cachex)
|
|
42
|
+
|
|
43
|
+
[](https://pypi.org/project/fastapi-cachex)
|
|
44
|
+
[](https://pypi.org/project/fastapi-cachex/)
|
|
39
45
|
|
|
40
46
|
[English](README.md) | [繁體中文](docs/README.zh-TW.md)
|
|
41
47
|
|
|
@@ -72,17 +78,54 @@ uv pip install fastapi-cachex
|
|
|
72
78
|
|
|
73
79
|
```python
|
|
74
80
|
from fastapi import FastAPI
|
|
75
|
-
from fastapi_cachex import cache
|
|
81
|
+
from fastapi_cachex import cache, BackendProxy
|
|
82
|
+
from fastapi_cachex.backends import MemoryBackend, MemcachedBackend
|
|
76
83
|
|
|
77
84
|
app = FastAPI()
|
|
78
85
|
|
|
86
|
+
# Configure your cache backend
|
|
87
|
+
memory_backend = MemoryBackend() # In-memory cache
|
|
88
|
+
# or
|
|
89
|
+
memcached_backend = MemcachedBackend(servers=["localhost:11211"]) # Memcached
|
|
90
|
+
|
|
91
|
+
# Set the backend you want to use
|
|
92
|
+
BackendProxy.set_backend(memory_backend) # or memcached_backend
|
|
93
|
+
|
|
79
94
|
|
|
80
95
|
@app.get("/")
|
|
81
|
-
@cache()
|
|
96
|
+
@cache(ttl=60) # Cache for 60 seconds
|
|
82
97
|
async def read_root():
|
|
83
98
|
return {"Hello": "World"}
|
|
84
99
|
```
|
|
85
100
|
|
|
101
|
+
## Backend Configuration
|
|
102
|
+
|
|
103
|
+
FastAPI-CacheX supports multiple caching backends. You can easily switch between them using the `BackendProxy`.
|
|
104
|
+
|
|
105
|
+
### In-Memory Cache
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from fastapi_cachex.backends import MemoryBackend
|
|
109
|
+
from fastapi_cachex import BackendProxy
|
|
110
|
+
|
|
111
|
+
backend = MemoryBackend()
|
|
112
|
+
BackendProxy.set_backend(backend)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Memcached
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from fastapi_cachex.backends import MemcachedBackend
|
|
119
|
+
from fastapi_cachex import BackendProxy
|
|
120
|
+
|
|
121
|
+
backend = MemcachedBackend(servers=["localhost:11211"])
|
|
122
|
+
BackendProxy.set_backend(backend)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Redis (Coming Soon)
|
|
126
|
+
|
|
127
|
+
Redis support is under development and will be available in future releases.
|
|
128
|
+
|
|
86
129
|
## Development Guide
|
|
87
130
|
|
|
88
131
|
### Running Tests
|
|
@@ -5,8 +5,12 @@
|
|
|
5
5
|
[](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml)
|
|
6
6
|
[](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml)
|
|
7
7
|
|
|
8
|
-
[](https://pepy.tech/project/fastapi-cachex)
|
|
9
|
+
[](https://pepy.tech/project/fastapi-cachex)
|
|
10
|
+
[](https://pepy.tech/project/fastapi-cachex)
|
|
11
|
+
|
|
12
|
+
[](https://pypi.org/project/fastapi-cachex)
|
|
13
|
+
[](https://pypi.org/project/fastapi-cachex/)
|
|
10
14
|
|
|
11
15
|
[English](README.md) | [繁體中文](docs/README.zh-TW.md)
|
|
12
16
|
|
|
@@ -43,17 +47,54 @@ uv pip install fastapi-cachex
|
|
|
43
47
|
|
|
44
48
|
```python
|
|
45
49
|
from fastapi import FastAPI
|
|
46
|
-
from fastapi_cachex import cache
|
|
50
|
+
from fastapi_cachex import cache, BackendProxy
|
|
51
|
+
from fastapi_cachex.backends import MemoryBackend, MemcachedBackend
|
|
47
52
|
|
|
48
53
|
app = FastAPI()
|
|
49
54
|
|
|
55
|
+
# Configure your cache backend
|
|
56
|
+
memory_backend = MemoryBackend() # In-memory cache
|
|
57
|
+
# or
|
|
58
|
+
memcached_backend = MemcachedBackend(servers=["localhost:11211"]) # Memcached
|
|
59
|
+
|
|
60
|
+
# Set the backend you want to use
|
|
61
|
+
BackendProxy.set_backend(memory_backend) # or memcached_backend
|
|
62
|
+
|
|
50
63
|
|
|
51
64
|
@app.get("/")
|
|
52
|
-
@cache()
|
|
65
|
+
@cache(ttl=60) # Cache for 60 seconds
|
|
53
66
|
async def read_root():
|
|
54
67
|
return {"Hello": "World"}
|
|
55
68
|
```
|
|
56
69
|
|
|
70
|
+
## Backend Configuration
|
|
71
|
+
|
|
72
|
+
FastAPI-CacheX supports multiple caching backends. You can easily switch between them using the `BackendProxy`.
|
|
73
|
+
|
|
74
|
+
### In-Memory Cache
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from fastapi_cachex.backends import MemoryBackend
|
|
78
|
+
from fastapi_cachex import BackendProxy
|
|
79
|
+
|
|
80
|
+
backend = MemoryBackend()
|
|
81
|
+
BackendProxy.set_backend(backend)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Memcached
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from fastapi_cachex.backends import MemcachedBackend
|
|
88
|
+
from fastapi_cachex import BackendProxy
|
|
89
|
+
|
|
90
|
+
backend = MemcachedBackend(servers=["localhost:11211"])
|
|
91
|
+
BackendProxy.set_backend(backend)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Redis (Coming Soon)
|
|
95
|
+
|
|
96
|
+
Redis support is under development and will be available in future releases.
|
|
97
|
+
|
|
57
98
|
## Development Guide
|
|
58
99
|
|
|
59
100
|
### Running Tests
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from fastapi_cachex.backends.base import BaseCacheBackend
|
|
5
|
+
from fastapi_cachex.exceptions import CacheXError
|
|
6
|
+
from fastapi_cachex.types import ETagContent
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MemcachedBackend(BaseCacheBackend):
|
|
10
|
+
"""Memcached backend implementation."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, servers: list[str]) -> None:
|
|
13
|
+
"""Initialize the Memcached backend.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
servers: List of Memcached servers in format ["host:port", ...]
|
|
17
|
+
|
|
18
|
+
Raises:
|
|
19
|
+
CacheXError: If pymemcache is not installed
|
|
20
|
+
"""
|
|
21
|
+
try:
|
|
22
|
+
from pymemcache import HashClient
|
|
23
|
+
except ImportError:
|
|
24
|
+
raise CacheXError(
|
|
25
|
+
"pymemcache is not installed. Please install it with 'pip install pymemcache'"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
self.client = HashClient(servers)
|
|
29
|
+
|
|
30
|
+
async def get(self, key: str) -> Optional[ETagContent]:
|
|
31
|
+
"""Get value from cache.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
key: Cache key to retrieve
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Optional[ETagContent]: Cached value with ETag if exists, None otherwise
|
|
38
|
+
"""
|
|
39
|
+
value = self.client.get(key)
|
|
40
|
+
if value is None:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
# Memcached stores data as bytes
|
|
44
|
+
# Convert string back to dictionary
|
|
45
|
+
value_dict = ast.literal_eval(value.decode("utf-8"))
|
|
46
|
+
return ETagContent(etag=value_dict["etag"], content=value_dict["content"])
|
|
47
|
+
|
|
48
|
+
async def set(
|
|
49
|
+
self, key: str, value: ETagContent, ttl: Optional[int] = None
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Set value in cache.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
key: Cache key
|
|
55
|
+
value: ETagContent to store
|
|
56
|
+
ttl: Time to live in seconds
|
|
57
|
+
"""
|
|
58
|
+
# Store as dictionary in string format
|
|
59
|
+
data = {"etag": value.etag, "content": value.content}
|
|
60
|
+
self.client.set(
|
|
61
|
+
key, str(data).encode("utf-8"), expire=ttl if ttl is not None else 0
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async def delete(self, key: str) -> None:
|
|
65
|
+
"""Delete value from cache.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
key: Cache key to delete
|
|
69
|
+
"""
|
|
70
|
+
self.client.delete(key)
|
|
71
|
+
|
|
72
|
+
async def clear(self) -> None:
|
|
73
|
+
"""Clear all values from cache."""
|
|
74
|
+
self.client.flush_all()
|
|
@@ -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:
|
|
@@ -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.
|
|
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(
|
|
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(
|
|
42
|
-
|
|
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
|
+
|
|
43
73
|
else:
|
|
44
|
-
|
|
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:
|
|
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,52 +120,38 @@ def cache( # noqa: C901
|
|
|
87
120
|
else:
|
|
88
121
|
request_name = found_request.name
|
|
89
122
|
|
|
90
|
-
func.__signature__ = sig
|
|
91
|
-
|
|
92
123
|
@wraps(func)
|
|
93
124
|
async def wrapper(*args: Any, **kwargs: Any) -> Response: # noqa: C901
|
|
94
125
|
if found_request:
|
|
95
|
-
|
|
126
|
+
req: Request | None = kwargs.get(request_name)
|
|
96
127
|
else:
|
|
97
|
-
|
|
128
|
+
req = kwargs.pop(request_name, None)
|
|
98
129
|
|
|
99
|
-
if not
|
|
130
|
+
if not req: # pragma: no cover
|
|
100
131
|
# Skip coverage for this case, as it should not happen
|
|
101
132
|
raise RequestNotFoundError()
|
|
102
133
|
|
|
103
134
|
# Only cache GET requests
|
|
104
|
-
if
|
|
105
|
-
return await get_response(func, *args, **kwargs)
|
|
135
|
+
if req.method != "GET":
|
|
136
|
+
return await get_response(func, req, *args, **kwargs)
|
|
106
137
|
|
|
107
138
|
# Generate cache key
|
|
108
|
-
cache_key = f"{
|
|
139
|
+
cache_key = f"{req.url.path}:{req.query_params}"
|
|
109
140
|
|
|
110
141
|
# Check if the data is already in the cache
|
|
111
142
|
cached_data = await cache_backend.get(cache_key)
|
|
112
143
|
|
|
113
|
-
if cached_data and cached_data.etag == (
|
|
114
|
-
request.headers.get("if-none-match")
|
|
115
|
-
):
|
|
144
|
+
if cached_data and cached_data.etag == req.headers.get("if-none-match"):
|
|
116
145
|
return Response(
|
|
117
146
|
status_code=HTTP_304_NOT_MODIFIED,
|
|
118
147
|
headers={"ETag": cached_data.etag},
|
|
119
148
|
)
|
|
120
149
|
|
|
121
150
|
# Get the response
|
|
122
|
-
response = await get_response(func, *args, **kwargs)
|
|
151
|
+
response = await get_response(func, req, *args, **kwargs)
|
|
123
152
|
|
|
124
153
|
# Generate ETag (hash based on response content)
|
|
125
|
-
|
|
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
|
|
154
|
+
etag = f'W/"{hashlib.md5(response.body).hexdigest()}"' # noqa: S324
|
|
136
155
|
|
|
137
156
|
# Add ETag to response headers
|
|
138
157
|
response.headers["ETag"] = etag
|
|
@@ -183,11 +202,17 @@ def cache( # noqa: C901
|
|
|
183
202
|
cache_control.add(DirectiveType.IMMUTABLE)
|
|
184
203
|
|
|
185
204
|
# Store the data in the cache
|
|
186
|
-
await cache_backend.set(
|
|
205
|
+
await cache_backend.set(
|
|
206
|
+
cache_key, ETagContent(etag, response.body), ttl=ttl
|
|
207
|
+
)
|
|
187
208
|
|
|
188
209
|
response.headers["Cache-Control"] = str(cache_control)
|
|
189
210
|
return response
|
|
190
211
|
|
|
212
|
+
# Update the wrapper with the new signature
|
|
213
|
+
update_wrapper(wrapper, func)
|
|
214
|
+
wrapper.__signature__ = sig # type: ignore
|
|
215
|
+
|
|
191
216
|
return wrapper
|
|
192
217
|
|
|
193
218
|
return decorator
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
1
3
|
from fastapi_cachex.backends import BaseCacheBackend
|
|
2
4
|
from fastapi_cachex.exceptions import BackendNotFoundError
|
|
3
5
|
|
|
4
|
-
_default_backend: BaseCacheBackend
|
|
6
|
+
_default_backend: Optional[BaseCacheBackend] = None
|
|
5
7
|
|
|
6
8
|
|
|
7
9
|
class BackendProxy:
|
|
@@ -17,7 +19,11 @@ class BackendProxy:
|
|
|
17
19
|
return _default_backend
|
|
18
20
|
|
|
19
21
|
@staticmethod
|
|
20
|
-
def set_backend(backend: BaseCacheBackend) -> None:
|
|
21
|
-
"""Set the backend for caching.
|
|
22
|
+
def set_backend(backend: Optional[BaseCacheBackend]) -> None:
|
|
23
|
+
"""Set the backend for caching.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
backend: The backend to use for caching, or None to clear the current backend
|
|
27
|
+
"""
|
|
22
28
|
global _default_backend
|
|
23
29
|
_default_backend = backend
|
|
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.3
|
|
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
|
|
@@ -25,6 +25,8 @@ Description-Content-Type: text/markdown
|
|
|
25
25
|
License-File: LICENSE
|
|
26
26
|
Requires-Dist: fastapi
|
|
27
27
|
Requires-Dist: httpx
|
|
28
|
+
Provides-Extra: memcache
|
|
29
|
+
Requires-Dist: pymemcache; extra == "memcache"
|
|
28
30
|
Dynamic: license-file
|
|
29
31
|
|
|
30
32
|
# FastAPI-Cache X
|
|
@@ -34,8 +36,12 @@ Dynamic: license-file
|
|
|
34
36
|
[](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml)
|
|
35
37
|
[](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml)
|
|
36
38
|
|
|
37
|
-
[](https://pepy.tech/project/fastapi-cachex)
|
|
40
|
+
[](https://pepy.tech/project/fastapi-cachex)
|
|
41
|
+
[](https://pepy.tech/project/fastapi-cachex)
|
|
42
|
+
|
|
43
|
+
[](https://pypi.org/project/fastapi-cachex)
|
|
44
|
+
[](https://pypi.org/project/fastapi-cachex/)
|
|
39
45
|
|
|
40
46
|
[English](README.md) | [繁體中文](docs/README.zh-TW.md)
|
|
41
47
|
|
|
@@ -72,17 +78,54 @@ uv pip install fastapi-cachex
|
|
|
72
78
|
|
|
73
79
|
```python
|
|
74
80
|
from fastapi import FastAPI
|
|
75
|
-
from fastapi_cachex import cache
|
|
81
|
+
from fastapi_cachex import cache, BackendProxy
|
|
82
|
+
from fastapi_cachex.backends import MemoryBackend, MemcachedBackend
|
|
76
83
|
|
|
77
84
|
app = FastAPI()
|
|
78
85
|
|
|
86
|
+
# Configure your cache backend
|
|
87
|
+
memory_backend = MemoryBackend() # In-memory cache
|
|
88
|
+
# or
|
|
89
|
+
memcached_backend = MemcachedBackend(servers=["localhost:11211"]) # Memcached
|
|
90
|
+
|
|
91
|
+
# Set the backend you want to use
|
|
92
|
+
BackendProxy.set_backend(memory_backend) # or memcached_backend
|
|
93
|
+
|
|
79
94
|
|
|
80
95
|
@app.get("/")
|
|
81
|
-
@cache()
|
|
96
|
+
@cache(ttl=60) # Cache for 60 seconds
|
|
82
97
|
async def read_root():
|
|
83
98
|
return {"Hello": "World"}
|
|
84
99
|
```
|
|
85
100
|
|
|
101
|
+
## Backend Configuration
|
|
102
|
+
|
|
103
|
+
FastAPI-CacheX supports multiple caching backends. You can easily switch between them using the `BackendProxy`.
|
|
104
|
+
|
|
105
|
+
### In-Memory Cache
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from fastapi_cachex.backends import MemoryBackend
|
|
109
|
+
from fastapi_cachex import BackendProxy
|
|
110
|
+
|
|
111
|
+
backend = MemoryBackend()
|
|
112
|
+
BackendProxy.set_backend(backend)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Memcached
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from fastapi_cachex.backends import MemcachedBackend
|
|
119
|
+
from fastapi_cachex import BackendProxy
|
|
120
|
+
|
|
121
|
+
backend = MemcachedBackend(servers=["localhost:11211"])
|
|
122
|
+
BackendProxy.set_backend(backend)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Redis (Coming Soon)
|
|
126
|
+
|
|
127
|
+
Redis support is under development and will be available in future releases.
|
|
128
|
+
|
|
86
129
|
## Development Guide
|
|
87
130
|
|
|
88
131
|
### Running Tests
|
|
@@ -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
|
|
@@ -14,5 +15,7 @@ fastapi_cachex.egg-info/requires.txt
|
|
|
14
15
|
fastapi_cachex.egg-info/top_level.txt
|
|
15
16
|
fastapi_cachex/backends/__init__.py
|
|
16
17
|
fastapi_cachex/backends/base.py
|
|
18
|
+
fastapi_cachex/backends/memcached.py
|
|
17
19
|
fastapi_cachex/backends/memory.py
|
|
18
|
-
tests/test_cache.py
|
|
20
|
+
tests/test_cache.py
|
|
21
|
+
tests/test_proxybackend.py
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-cachex"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.3" # 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,7 +36,9 @@ 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",
|
|
41
|
+
"pymemcache>=4.0.0",
|
|
40
42
|
"pytest>=8.3.5",
|
|
41
43
|
"pytest-asyncio>=0.26.0",
|
|
42
44
|
"pytest-cov>=6.1.0",
|
|
@@ -44,6 +46,12 @@ dev = [
|
|
|
44
46
|
"tox>=4.25.0",
|
|
45
47
|
]
|
|
46
48
|
|
|
49
|
+
[project.optional-dependencies]
|
|
50
|
+
memcache = ["pymemcache"]
|
|
51
|
+
|
|
52
|
+
[tool.setuptools]
|
|
53
|
+
package-data = {"fastapi_cachex" = ["py.typed"]}
|
|
54
|
+
|
|
47
55
|
[tool.pytest.ini_options]
|
|
48
56
|
pythonpath = [
|
|
49
57
|
"."
|
|
@@ -121,3 +129,30 @@ exclude_lines = [
|
|
|
121
129
|
]
|
|
122
130
|
fail_under = 90
|
|
123
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
|
|
|
@@ -276,3 +278,69 @@ def test_post_should_not_cache():
|
|
|
276
278
|
response = client.post("/post")
|
|
277
279
|
assert response.status_code == 200
|
|
278
280
|
assert "cache-control" not in response.headers
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_use_default_response_class():
|
|
284
|
+
@app.get("/")
|
|
285
|
+
@cache()
|
|
286
|
+
async def default_response_class_endpoint():
|
|
287
|
+
return {"message": "This endpoint uses the default response class"}
|
|
288
|
+
|
|
289
|
+
response = client.get("/")
|
|
290
|
+
assert response.status_code == 200
|
|
291
|
+
assert response.headers["content-type"] == "application/json"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_response_class_html():
|
|
295
|
+
@app.get("/html", response_class=HTMLResponse)
|
|
296
|
+
@cache(ttl=60)
|
|
297
|
+
async def html_endpoint():
|
|
298
|
+
return "<h1>Hello World</h1>"
|
|
299
|
+
|
|
300
|
+
response = client.get("/html")
|
|
301
|
+
assert response.status_code == 200
|
|
302
|
+
assert response.headers["content-type"] == "text/html; charset=utf-8"
|
|
303
|
+
assert response.text == "<h1>Hello World</h1>"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def test_response_class_plain_text():
|
|
307
|
+
@app.get("/text", response_class=PlainTextResponse)
|
|
308
|
+
@cache(ttl=60)
|
|
309
|
+
async def text_endpoint():
|
|
310
|
+
return "Hello World"
|
|
311
|
+
|
|
312
|
+
response = client.get("/text")
|
|
313
|
+
assert response.status_code == 200
|
|
314
|
+
assert response.headers["content-type"] == "text/plain; charset=utf-8"
|
|
315
|
+
assert response.text == "Hello World"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_response_class_json_with_raw_dict():
|
|
319
|
+
@app.get("/json-dict", response_class=JSONResponse)
|
|
320
|
+
@cache(ttl=60)
|
|
321
|
+
async def json_dict_endpoint():
|
|
322
|
+
return {"message": "Hello World"}
|
|
323
|
+
|
|
324
|
+
response = client.get("/json-dict")
|
|
325
|
+
assert response.status_code == 200
|
|
326
|
+
assert response.headers["content-type"] == "application/json"
|
|
327
|
+
assert response.json() == {"message": "Hello World"}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_response_class_with_etag():
|
|
331
|
+
"""Test that different response classes still generate and handle ETags correctly"""
|
|
332
|
+
|
|
333
|
+
@app.get("/html-etag", response_class=HTMLResponse)
|
|
334
|
+
@cache()
|
|
335
|
+
async def html_etag_endpoint():
|
|
336
|
+
return "<h1>Hello World</h1>"
|
|
337
|
+
|
|
338
|
+
# First request
|
|
339
|
+
response1 = client.get("/html-etag")
|
|
340
|
+
assert response1.status_code == 200
|
|
341
|
+
assert "ETag" in response1.headers
|
|
342
|
+
|
|
343
|
+
# Second request with ETag
|
|
344
|
+
etag = response1.headers["ETag"]
|
|
345
|
+
response2 = client.get("/html-etag", headers={"If-None-Match": etag})
|
|
346
|
+
assert response2.status_code == 304 # Not Modified
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
import pytest_asyncio
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
from fastapi.responses import JSONResponse
|
|
7
|
+
from fastapi.testclient import TestClient
|
|
8
|
+
|
|
9
|
+
from fastapi_cachex import BackendProxy
|
|
10
|
+
from fastapi_cachex import cache
|
|
11
|
+
from fastapi_cachex.backends import MemoryBackend
|
|
12
|
+
from fastapi_cachex.exceptions import BackendNotFoundError
|
|
13
|
+
from fastapi_cachex.types import ETagContent
|
|
14
|
+
|
|
15
|
+
app = FastAPI()
|
|
16
|
+
client = TestClient(app)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest_asyncio.fixture(autouse=True)
|
|
20
|
+
async def cleanup():
|
|
21
|
+
# Reset backend before each test
|
|
22
|
+
try:
|
|
23
|
+
backend = BackendProxy.get_backend()
|
|
24
|
+
if isinstance(backend, MemoryBackend):
|
|
25
|
+
backend.stop_cleanup()
|
|
26
|
+
# Reset backend by setting it to None
|
|
27
|
+
BackendProxy.set_backend(None)
|
|
28
|
+
except BackendNotFoundError:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
yield
|
|
32
|
+
|
|
33
|
+
# Clean up after each test
|
|
34
|
+
try:
|
|
35
|
+
backend = BackendProxy.get_backend()
|
|
36
|
+
if isinstance(backend, MemoryBackend):
|
|
37
|
+
await backend.clear() # Clear all cached data
|
|
38
|
+
backend.stop_cleanup()
|
|
39
|
+
# Reset backend by setting it to None
|
|
40
|
+
BackendProxy.set_backend(None)
|
|
41
|
+
except BackendNotFoundError:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_backend_switching():
|
|
46
|
+
# Initial state should have no backend
|
|
47
|
+
with pytest.raises(BackendNotFoundError):
|
|
48
|
+
BackendProxy.get_backend()
|
|
49
|
+
|
|
50
|
+
# Set up MemoryBackend
|
|
51
|
+
memory_backend = MemoryBackend()
|
|
52
|
+
BackendProxy.set_backend(memory_backend)
|
|
53
|
+
assert isinstance(BackendProxy.get_backend(), MemoryBackend)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_memory_cache():
|
|
57
|
+
@app.get("/test")
|
|
58
|
+
@cache(ttl=60)
|
|
59
|
+
async def test_endpoint():
|
|
60
|
+
return JSONResponse(content={"message": "test"})
|
|
61
|
+
|
|
62
|
+
# Use MemoryBackend
|
|
63
|
+
memory_backend = MemoryBackend()
|
|
64
|
+
BackendProxy.set_backend(memory_backend)
|
|
65
|
+
|
|
66
|
+
# First request should return 200
|
|
67
|
+
response1 = client.get("/test")
|
|
68
|
+
assert response1.status_code == 200
|
|
69
|
+
etag1 = response1.headers["ETag"]
|
|
70
|
+
|
|
71
|
+
# Request with same ETag should return 304
|
|
72
|
+
response2 = client.get("/test", headers={"If-None-Match": etag1})
|
|
73
|
+
assert response2.status_code == 304
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.asyncio
|
|
77
|
+
async def test_backend_cleanup():
|
|
78
|
+
# Run cleanup task in async environment
|
|
79
|
+
memory_backend = MemoryBackend()
|
|
80
|
+
BackendProxy.set_backend(memory_backend)
|
|
81
|
+
|
|
82
|
+
# Verify initial state
|
|
83
|
+
assert memory_backend._cleanup_task is None
|
|
84
|
+
|
|
85
|
+
# Set test data
|
|
86
|
+
test_value = ETagContent(etag="test-etag", content="test_value")
|
|
87
|
+
await memory_backend.set("test_key", test_value, ttl=1)
|
|
88
|
+
|
|
89
|
+
# Verify data is stored correctly
|
|
90
|
+
cached_value = await memory_backend.get("test_key")
|
|
91
|
+
assert cached_value is not None
|
|
92
|
+
assert cached_value.content == "test_value"
|
|
93
|
+
|
|
94
|
+
# Wait for data to expire (1 second + extra time)
|
|
95
|
+
await asyncio.sleep(1.1)
|
|
96
|
+
|
|
97
|
+
# Execute cleanup
|
|
98
|
+
await memory_backend.cleanup()
|
|
99
|
+
|
|
100
|
+
# Verify data has been cleaned up
|
|
101
|
+
assert await memory_backend.get("test_key") is None
|
|
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
|