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.

Files changed (24) hide show
  1. {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/PKG-INFO +48 -5
  2. {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/README.md +45 -4
  3. {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex/backends/__init__.py +1 -0
  4. fastapi_cachex-0.1.3/fastapi_cachex/backends/memcached.py +74 -0
  5. {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex/backends/memory.py +2 -2
  6. {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex/cache.py +57 -32
  7. {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex/proxy.py +9 -3
  8. fastapi_cachex-0.1.3/fastapi_cachex/py.typed +0 -0
  9. {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex.egg-info/PKG-INFO +48 -5
  10. {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex.egg-info/SOURCES.txt +4 -1
  11. fastapi_cachex-0.1.3/fastapi_cachex.egg-info/requires.txt +5 -0
  12. {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/pyproject.toml +36 -1
  13. {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/tests/test_cache.py +68 -0
  14. fastapi_cachex-0.1.3/tests/test_proxybackend.py +101 -0
  15. fastapi_cachex-0.1.1/fastapi_cachex.egg-info/requires.txt +0 -2
  16. {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/LICENSE +0 -0
  17. {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex/__init__.py +0 -0
  18. {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex/backends/base.py +0 -0
  19. {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex/directives.py +0 -0
  20. {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex/exceptions.py +0 -0
  21. {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex/types.py +0 -0
  22. {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex.egg-info/dependency_links.txt +0 -0
  23. {fastapi_cachex-0.1.1 → fastapi_cachex-0.1.3}/fastapi_cachex.egg-info/top_level.txt +0 -0
  24. {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.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
  [![Tests](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml/badge.svg)](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml)
35
37
  [![Coverage Status](https://raw.githubusercontent.com/allen0099/FastAPI-CacheX/coverage-badge/coverage.svg)](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml)
36
38
 
37
- [![PyPI version](https://badge.fury.io/py/fastapi-cachex.svg)](https://badge.fury.io/py/fastapi-cachex)
38
- [![Python Versions](https://img.shields.io/pypi/pyversions/fastapi-cachex.svg)](https://pypi.org/project/fastapi-cachex/)
39
+ [![Downloads](https://static.pepy.tech/badge/fastapi-cachex)](https://pepy.tech/project/fastapi-cachex)
40
+ [![Weekly downloads](https://static.pepy.tech/badge/fastapi-cachex/week)](https://pepy.tech/project/fastapi-cachex)
41
+ [![Monthly downloads](https://static.pepy.tech/badge/fastapi-cachex/month)](https://pepy.tech/project/fastapi-cachex)
42
+
43
+ [![PyPI version](https://img.shields.io/pypi/v/fastapi-cachex.svg?logo=pypi&logoColor=gold&label=PyPI)](https://pypi.org/project/fastapi-cachex)
44
+ [![Python Versions](https://img.shields.io/pypi/pyversions/fastapi-cachex.svg?logo=python&label=Python&logoColor=gold)](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
  [![Tests](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml/badge.svg)](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml)
6
6
  [![Coverage Status](https://raw.githubusercontent.com/allen0099/FastAPI-CacheX/coverage-badge/coverage.svg)](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml)
7
7
 
8
- [![PyPI version](https://badge.fury.io/py/fastapi-cachex.svg)](https://badge.fury.io/py/fastapi-cachex)
9
- [![Python Versions](https://img.shields.io/pypi/pyversions/fastapi-cachex.svg)](https://pypi.org/project/fastapi-cachex/)
8
+ [![Downloads](https://static.pepy.tech/badge/fastapi-cachex)](https://pepy.tech/project/fastapi-cachex)
9
+ [![Weekly downloads](https://static.pepy.tech/badge/fastapi-cachex/week)](https://pepy.tech/project/fastapi-cachex)
10
+ [![Monthly downloads](https://static.pepy.tech/badge/fastapi-cachex/month)](https://pepy.tech/project/fastapi-cachex)
11
+
12
+ [![PyPI version](https://img.shields.io/pypi/v/fastapi-cachex.svg?logo=pypi&logoColor=gold&label=PyPI)](https://pypi.org/project/fastapi-cachex)
13
+ [![Python Versions](https://img.shields.io/pypi/pyversions/fastapi-cachex.svg?logo=python&label=Python&logoColor=gold)](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
@@ -1,2 +1,3 @@
1
1
  from .base import BaseCacheBackend as BaseCacheBackend
2
+ from .memcached import MemcachedBackend as MemcachedBackend
2
3
  from .memory import MemoryBackend as MemoryBackend
@@ -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.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)
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
- return func(*args, **kwargs)
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,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
- request: Request | None = kwargs.get(request_name)
126
+ req: Request | None = kwargs.get(request_name)
96
127
  else:
97
- request: Request | None = kwargs.pop(request_name, None)
128
+ req = kwargs.pop(request_name, None)
98
129
 
99
- if not request: # pragma: no cover
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 request.method != "GET":
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"{request.url.path}:{request.query_params}"
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
- 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
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(cache_key, ETagContent(etag, content), ttl=ttl)
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 | None = None
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.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
  [![Tests](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml/badge.svg)](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml)
35
37
  [![Coverage Status](https://raw.githubusercontent.com/allen0099/FastAPI-CacheX/coverage-badge/coverage.svg)](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml)
36
38
 
37
- [![PyPI version](https://badge.fury.io/py/fastapi-cachex.svg)](https://badge.fury.io/py/fastapi-cachex)
38
- [![Python Versions](https://img.shields.io/pypi/pyversions/fastapi-cachex.svg)](https://pypi.org/project/fastapi-cachex/)
39
+ [![Downloads](https://static.pepy.tech/badge/fastapi-cachex)](https://pepy.tech/project/fastapi-cachex)
40
+ [![Weekly downloads](https://static.pepy.tech/badge/fastapi-cachex/week)](https://pepy.tech/project/fastapi-cachex)
41
+ [![Monthly downloads](https://static.pepy.tech/badge/fastapi-cachex/month)](https://pepy.tech/project/fastapi-cachex)
42
+
43
+ [![PyPI version](https://img.shields.io/pypi/v/fastapi-cachex.svg?logo=pypi&logoColor=gold&label=PyPI)](https://pypi.org/project/fastapi-cachex)
44
+ [![Python Versions](https://img.shields.io/pypi/pyversions/fastapi-cachex.svg?logo=python&label=Python&logoColor=gold)](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
@@ -0,0 +1,5 @@
1
+ fastapi
2
+ httpx
3
+
4
+ [memcache]
5
+ pymemcache
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-cachex"
3
- version = "0.1.1" # Initial release version
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
@@ -1,2 +0,0 @@
1
- fastapi
2
- httpx
File without changes
File without changes