fast-cache-middleware 0.0.1__py3-none-any.whl → 0.0.2__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.
@@ -1,34 +1,35 @@
1
- """FastCacheMiddleware - high-performance ASGI middleware for caching.
2
-
3
- Route resolution approach:
4
- - Route analysis at application startup
5
- - Cache configuration extraction from FastAPI dependencies
6
- - Efficient caching and invalidation based on routes
7
-
8
-
9
- TODO:
10
- - add check for dependencies for middleware exists. and raise error if not.
11
- """
12
-
13
- from .controller import Controller
14
- from .depends import BaseCacheConfigDepends, CacheConfig, CacheDropConfig
15
- from .middleware import FastCacheMiddleware
16
- from .storages import BaseStorage, InMemoryStorage
17
-
18
- __version__ = "1.0.0"
19
-
20
- __all__ = [
21
- # Main components
22
- "FastCacheMiddleware",
23
- "Controller",
24
- # Configuration via dependencies
25
- "CacheConfig",
26
- "CacheDropConfig",
27
- "BaseCacheConfigDepends",
28
- # Storages
29
- "BaseStorage",
30
- "InMemoryStorage",
31
- # Serialization
32
- "BaseSerializer",
33
- "DefaultSerializer",
34
- ]
1
+ """FastCacheMiddleware - high-performance ASGI middleware for caching.
2
+
3
+ Route resolution approach:
4
+ - Route analysis at application startup
5
+ - Cache configuration extraction from FastAPI dependencies
6
+ - Efficient caching and invalidation based on routes
7
+
8
+
9
+ TODO:
10
+ - add check for dependencies for middleware exists. and raise error if not.
11
+ - automatically add x-cache-age to the OpenAPI schema (openapi_extra) based on caching dependency.
12
+ """
13
+
14
+ from .controller import Controller
15
+ from .depends import BaseCacheConfigDepends, CacheConfig, CacheDropConfig
16
+ from .middleware import FastCacheMiddleware
17
+ from .storages import BaseStorage, InMemoryStorage
18
+
19
+ __version__ = "1.0.0"
20
+
21
+ __all__ = [
22
+ # Main components
23
+ "FastCacheMiddleware",
24
+ "Controller",
25
+ # Configuration via dependencies
26
+ "CacheConfig",
27
+ "CacheDropConfig",
28
+ "BaseCacheConfigDepends",
29
+ # Storages
30
+ "BaseStorage",
31
+ "InMemoryStorage",
32
+ # Serialization
33
+ "BaseSerializer",
34
+ "DefaultSerializer",
35
+ ]
@@ -1,231 +1,231 @@
1
- import http
2
- import logging
3
- import typing as tp
4
- from hashlib import blake2b
5
-
6
- from starlette.requests import Request
7
- from starlette.responses import Response
8
-
9
- from .depends import CacheConfig, CacheDropConfig
10
- from .storages import BaseStorage
11
-
12
- logger = logging.getLogger(__name__)
13
-
14
- KNOWN_HTTP_METHODS = [method.value for method in http.HTTPMethod]
15
-
16
-
17
- def generate_key(request: Request) -> str:
18
- """Generates fast unique key for caching HTTP request.
19
-
20
- Args:
21
- request: Starlette Request object.
22
-
23
- Returns:
24
- str: Unique key for caching, based on request method and path.
25
- Uses fast blake2b hashing algorithm.
26
-
27
- Note:
28
- Does not consider scheme and host, as requests usually go to the same host.
29
- Only considers method, path and query parameters for maximum performance.
30
- """
31
- # Get only necessary components from scope
32
- scope = request.scope
33
- url = scope["path"]
34
- if scope["query_string"]:
35
- url += f"?{scope['query_string'].decode('ascii')}"
36
-
37
- # Use fast blake2b algorithm with minimal digest size
38
- key = blake2b(digest_size=8)
39
- key.update(request.method.encode())
40
- key.update(url.encode())
41
-
42
- return key.hexdigest()
43
-
44
-
45
- class Controller:
46
- """Caching controller for Starlette/FastAPI.
47
-
48
- Responsibilities:
49
- 1. Define rules for caching requests and responses
50
- 2. Generate cache keys with custom functions
51
- 3. Manage TTL and validation of cached data
52
- 4. Check HTTP caching headers
53
- 5. Invalidate cache by URL patterns
54
-
55
- Supports:
56
- - Custom key generation functions via CacheConfig
57
- - Cache invalidation by URL patterns via CacheDropConfig
58
- - Standard HTTP caching headers (Cache-Control, ETag, Last-Modified)
59
- - Cache lifetime configuration via max_age in CacheConfig
60
- """
61
-
62
- def __init__(
63
- self,
64
- cacheable_methods: list[str] | None = None,
65
- cacheable_status_codes: list[int] | None = None,
66
- ) -> None:
67
- self.cacheable_methods = []
68
- if cacheable_methods:
69
- for method in cacheable_methods:
70
- method = method.upper()
71
- if method in KNOWN_HTTP_METHODS:
72
- self.cacheable_methods.append(method)
73
- else:
74
- raise ValueError(f"Invalid HTTP method: {method}")
75
- else:
76
- self.cacheable_methods.append(http.HTTPMethod.GET.value)
77
-
78
- self.cacheable_status_codes = cacheable_status_codes or [
79
- http.HTTPStatus.OK.value,
80
- http.HTTPStatus.MOVED_PERMANENTLY.value,
81
- http.HTTPStatus.PERMANENT_REDIRECT.value,
82
- ]
83
-
84
- async def is_cachable_request(self, request: Request) -> bool:
85
- """Determines if this request should be cached.
86
-
87
- Args:
88
- request: HTTP request
89
- cache_config: Cache configuration
90
-
91
- Returns:
92
- bool: True if request should be cached
93
- """
94
- # Cache only GET requests by default
95
- if request.method not in self.cacheable_methods:
96
- return False
97
-
98
- # Check Cache-Control headers
99
- # todo: add parsing cache-control function
100
- cache_control = request.headers.get("cache-control", "").lower()
101
- if "no-cache" in cache_control or "no-store" in cache_control:
102
- return False
103
-
104
- return True
105
-
106
- async def is_cachable_response(self, response: Response) -> bool:
107
- """Determines if this response can be cached.
108
-
109
- Args:
110
- request: HTTP request
111
- response: HTTP response
112
-
113
- Returns:
114
- bool: True if response can be cached
115
- """
116
- if response.status_code not in self.cacheable_status_codes:
117
- return False
118
-
119
- # Check Cache-Control headers
120
- cache_control = response.headers.get("cache-control", "").lower()
121
- if (
122
- "no-cache" in cache_control
123
- or "no-store" in cache_control
124
- or "private" in cache_control
125
- ):
126
- return False
127
-
128
- # Check response size (don't cache too large responses)
129
- if (
130
- hasattr(response, "body")
131
- and response.body
132
- and len(response.body) > 1024 * 1024
133
- ): # 1MB
134
- return False
135
-
136
- return True
137
-
138
- async def generate_cache_key(
139
- self, request: Request, cache_config: CacheConfig
140
- ) -> str:
141
- """Generates cache key for request.
142
-
143
- Args:
144
- request: HTTP request
145
- cache_config: Cache configuration
146
-
147
- Returns:
148
- str: Cache key
149
- """
150
- # Use custom key generation function if available
151
- if cache_config.key_func:
152
- return cache_config.key_func(request)
153
-
154
- # Use standard function
155
- return generate_key(request)
156
-
157
- async def cache_response(
158
- self,
159
- cache_key: str,
160
- request: Request,
161
- response: Response,
162
- storage: BaseStorage,
163
- ttl: tp.Optional[int] = None,
164
- ) -> None:
165
- """Saves response to cache.
166
-
167
- Args:
168
- cache_key: Cache key
169
- request: HTTP request
170
- response: HTTP response to cache
171
- storage: Cache storage
172
- ttl: Cache lifetime in seconds
173
- todo: in meta can write etag and last_modified from response headers
174
- """
175
- if await self.is_cachable_response(response):
176
- response.headers["X-Cache-Status"] = "HIT"
177
- await storage.store(cache_key, response, request, {"ttl": ttl})
178
- else:
179
- logger.debug("Skip caching for response: %s", response.status_code)
180
-
181
- async def get_cached_response(
182
- self, cache_key: str, storage: BaseStorage
183
- ) -> tp.Optional[Response]:
184
- """Gets cached response if it exists and is valid.
185
-
186
- Args:
187
- cache_key: Cache key
188
- storage: Cache storage
189
-
190
- Returns:
191
- Response or None if cache is invalid/missing
192
- """
193
- result = await storage.retrieve(cache_key)
194
- if result is None:
195
- return None
196
- response, _, _ = result
197
- return response
198
-
199
- async def invalidate_cache(
200
- self,
201
- cache_drop_config: CacheDropConfig,
202
- storage: BaseStorage,
203
- ) -> None:
204
- """Invalidates cache by configuration.
205
-
206
- Args:
207
- cache_drop_config: Cache invalidation configuration
208
- storage: Cache storage
209
-
210
- TODO: Comments on improvements:
211
-
212
- 1. Need to add pattern support in storage for bulk invalidation
213
- by key prefix/mask (especially for Redis/Memcached)
214
-
215
- 2. Desirable to add bulk operations for removing multiple keys
216
- in one storage request
217
-
218
- 3. Can add delayed/asynchronous invalidation via queue
219
- for large datasets
220
-
221
- 4. Should add invalidation strategies:
222
- - Immediate (current implementation)
223
- - Delayed (via TTL)
224
- - Partial (only specific fields)
225
-
226
- 5. Add tag support for grouping related caches
227
- and their joint invalidation
228
- """
229
- for path in cache_drop_config.paths:
230
- await storage.remove(path)
231
- logger.info("Invalidated cache for pattern: %s", path.pattern)
1
+ import http
2
+ import logging
3
+ import typing as tp
4
+ from hashlib import blake2b
5
+
6
+ from starlette.requests import Request
7
+ from starlette.responses import Response
8
+
9
+ from .depends import CacheConfig, CacheDropConfig
10
+ from .storages import BaseStorage
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ KNOWN_HTTP_METHODS = [method.value for method in http.HTTPMethod]
15
+
16
+
17
+ def generate_key(request: Request) -> str:
18
+ """Generates fast unique key for caching HTTP request.
19
+
20
+ Args:
21
+ request: Starlette Request object.
22
+
23
+ Returns:
24
+ str: Unique key for caching, based on request method and path.
25
+ Uses fast blake2b hashing algorithm.
26
+
27
+ Note:
28
+ Does not consider scheme and host, as requests usually go to the same host.
29
+ Only considers method, path and query parameters for maximum performance.
30
+ """
31
+ # Get only necessary components from scope
32
+ scope = request.scope
33
+ url = scope["path"]
34
+ if scope["query_string"]:
35
+ url += f"?{scope['query_string'].decode('ascii')}"
36
+
37
+ # Use fast blake2b algorithm with minimal digest size
38
+ key = blake2b(digest_size=8)
39
+ key.update(request.method.encode())
40
+ key.update(url.encode())
41
+
42
+ return key.hexdigest()
43
+
44
+
45
+ class Controller:
46
+ """Caching controller for Starlette/FastAPI.
47
+
48
+ Responsibilities:
49
+ 1. Define rules for caching requests and responses
50
+ 2. Generate cache keys with custom functions
51
+ 3. Manage TTL and validation of cached data
52
+ 4. Check HTTP caching headers
53
+ 5. Invalidate cache by URL patterns
54
+
55
+ Supports:
56
+ - Custom key generation functions via CacheConfig
57
+ - Cache invalidation by URL patterns via CacheDropConfig
58
+ - Standard HTTP caching headers (Cache-Control, ETag, Last-Modified)
59
+ - Cache lifetime configuration via max_age in CacheConfig
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ cacheable_methods: list[str] | None = None,
65
+ cacheable_status_codes: list[int] | None = None,
66
+ ) -> None:
67
+ self.cacheable_methods = []
68
+ if cacheable_methods:
69
+ for method in cacheable_methods:
70
+ method = method.upper()
71
+ if method in KNOWN_HTTP_METHODS:
72
+ self.cacheable_methods.append(method)
73
+ else:
74
+ raise ValueError(f"Invalid HTTP method: {method}")
75
+ else:
76
+ self.cacheable_methods.append(http.HTTPMethod.GET.value)
77
+
78
+ self.cacheable_status_codes = cacheable_status_codes or [
79
+ http.HTTPStatus.OK.value,
80
+ http.HTTPStatus.MOVED_PERMANENTLY.value,
81
+ http.HTTPStatus.PERMANENT_REDIRECT.value,
82
+ ]
83
+
84
+ async def is_cachable_request(self, request: Request) -> bool:
85
+ """Determines if this request should be cached.
86
+
87
+ Args:
88
+ request: HTTP request
89
+ cache_config: Cache configuration
90
+
91
+ Returns:
92
+ bool: True if request should be cached
93
+ """
94
+ # Cache only GET requests by default
95
+ if request.method not in self.cacheable_methods:
96
+ return False
97
+
98
+ # Check Cache-Control headers
99
+ # todo: add parsing cache-control function
100
+ cache_control = request.headers.get("cache-control", "").lower()
101
+ if "no-cache" in cache_control or "no-store" in cache_control:
102
+ return False
103
+
104
+ return True
105
+
106
+ async def is_cachable_response(self, response: Response) -> bool:
107
+ """Determines if this response can be cached.
108
+
109
+ Args:
110
+ request: HTTP request
111
+ response: HTTP response
112
+
113
+ Returns:
114
+ bool: True if response can be cached
115
+ """
116
+ if response.status_code not in self.cacheable_status_codes:
117
+ return False
118
+
119
+ # Check Cache-Control headers
120
+ cache_control = response.headers.get("cache-control", "").lower()
121
+ if (
122
+ "no-cache" in cache_control
123
+ or "no-store" in cache_control
124
+ or "private" in cache_control
125
+ ):
126
+ return False
127
+
128
+ # Check response size (don't cache too large responses)
129
+ if (
130
+ hasattr(response, "body")
131
+ and response.body
132
+ and len(response.body) > 1024 * 1024
133
+ ): # 1MB
134
+ return False
135
+
136
+ return True
137
+
138
+ async def generate_cache_key(
139
+ self, request: Request, cache_config: CacheConfig
140
+ ) -> str:
141
+ """Generates cache key for request.
142
+
143
+ Args:
144
+ request: HTTP request
145
+ cache_config: Cache configuration
146
+
147
+ Returns:
148
+ str: Cache key
149
+ """
150
+ # Use custom key generation function if available
151
+ if cache_config.key_func:
152
+ return cache_config.key_func(request)
153
+
154
+ # Use standard function
155
+ return generate_key(request)
156
+
157
+ async def cache_response(
158
+ self,
159
+ cache_key: str,
160
+ request: Request,
161
+ response: Response,
162
+ storage: BaseStorage,
163
+ ttl: tp.Optional[int] = None,
164
+ ) -> None:
165
+ """Saves response to cache.
166
+
167
+ Args:
168
+ cache_key: Cache key
169
+ request: HTTP request
170
+ response: HTTP response to cache
171
+ storage: Cache storage
172
+ ttl: Cache lifetime in seconds
173
+ todo: in meta can write etag and last_modified from response headers
174
+ """
175
+ if await self.is_cachable_response(response):
176
+ response.headers["X-Cache-Status"] = "HIT"
177
+ await storage.store(cache_key, response, request, {"ttl": ttl})
178
+ else:
179
+ logger.debug("Skip caching for response: %s", response.status_code)
180
+
181
+ async def get_cached_response(
182
+ self, cache_key: str, storage: BaseStorage
183
+ ) -> tp.Optional[Response]:
184
+ """Gets cached response if it exists and is valid.
185
+
186
+ Args:
187
+ cache_key: Cache key
188
+ storage: Cache storage
189
+
190
+ Returns:
191
+ Response or None if cache is invalid/missing
192
+ """
193
+ result = await storage.retrieve(cache_key)
194
+ if result is None:
195
+ return None
196
+ response, _, _ = result
197
+ return response
198
+
199
+ async def invalidate_cache(
200
+ self,
201
+ cache_drop_config: CacheDropConfig,
202
+ storage: BaseStorage,
203
+ ) -> None:
204
+ """Invalidates cache by configuration.
205
+
206
+ Args:
207
+ cache_drop_config: Cache invalidation configuration
208
+ storage: Cache storage
209
+
210
+ TODO: Comments on improvements:
211
+
212
+ 1. Need to add pattern support in storage for bulk invalidation
213
+ by key prefix/mask (especially for Redis/Memcached)
214
+
215
+ 2. Desirable to add bulk operations for removing multiple keys
216
+ in one storage request
217
+
218
+ 3. Can add delayed/asynchronous invalidation via queue
219
+ for large datasets
220
+
221
+ 4. Should add invalidation strategies:
222
+ - Immediate (current implementation)
223
+ - Delayed (via TTL)
224
+ - Partial (only specific fields)
225
+
226
+ 5. Add tag support for grouping related caches
227
+ and their joint invalidation
228
+ """
229
+ for path in cache_drop_config.paths:
230
+ await storage.remove(path)
231
+ logger.info("Invalidated cache for pattern: %s", path.pattern)
@@ -1,66 +1,66 @@
1
- import re
2
- import typing as tp
3
-
4
- from fastapi import params
5
- from starlette.requests import Request
6
-
7
-
8
- class BaseCacheConfigDepends(params.Depends):
9
- """Base class for cache configuration via ASGI scope extensions.
10
-
11
- Uses standardized ASGI extensions mechanism for passing
12
- configuration from route dependencies to middleware.
13
- """
14
-
15
- use_cache: bool = True
16
-
17
- def __call__(self, request: Request) -> None:
18
- """Saves configuration in ASGI scope extensions.
19
-
20
- Args:
21
- request: HTTP request
22
- """
23
- # Use standard ASGI extensions mechanism
24
- if "extensions" not in request.scope:
25
- request.scope["extensions"] = {}
26
-
27
- if "fast_cache" not in request.scope["extensions"]:
28
- request.scope["extensions"]["fast_cache"] = {}
29
-
30
- request.scope["extensions"]["fast_cache"]["config"] = self
31
-
32
- @property
33
- def dependency(self) -> params.Depends:
34
- return self
35
-
36
-
37
- class CacheConfig(BaseCacheConfigDepends):
38
- """Cache configuration for route.
39
-
40
- Args:
41
- max_age: Cache lifetime in seconds
42
- key_func: Cache key generation function
43
- """
44
-
45
- def __init__(
46
- self,
47
- max_age: int = 5 * 60,
48
- key_func: tp.Optional[tp.Callable[[Request], str]] = None,
49
- ) -> None:
50
- self.max_age = max_age
51
- self.key_func = key_func
52
-
53
-
54
- class CacheDropConfig(BaseCacheConfigDepends):
55
- """Cache invalidation configuration for route.
56
-
57
- Args:
58
- paths: Path for cache invalidation. Can be string or regular expression.
59
- If string, it will be converted to regular expression
60
- that matches the beginning of request path.
61
- """
62
-
63
- def __init__(self, paths: list[str | re.Pattern]) -> None:
64
- self.paths: list[re.Pattern] = [
65
- p if isinstance(p, re.Pattern) else re.compile(f"^{p}$") for p in paths
66
- ]
1
+ import re
2
+ import typing as tp
3
+
4
+ from fastapi import params
5
+ from starlette.requests import Request
6
+
7
+
8
+ class BaseCacheConfigDepends(params.Depends):
9
+ """Base class for cache configuration via ASGI scope extensions.
10
+
11
+ Uses standardized ASGI extensions mechanism for passing
12
+ configuration from route dependencies to middleware.
13
+ """
14
+
15
+ use_cache: bool = True
16
+
17
+ def __call__(self, request: Request) -> None:
18
+ """Saves configuration in ASGI scope extensions.
19
+
20
+ Args:
21
+ request: HTTP request
22
+ """
23
+ # Use standard ASGI extensions mechanism
24
+ if "extensions" not in request.scope:
25
+ request.scope["extensions"] = {}
26
+
27
+ if "fast_cache" not in request.scope["extensions"]:
28
+ request.scope["extensions"]["fast_cache"] = {}
29
+
30
+ request.scope["extensions"]["fast_cache"]["config"] = self
31
+
32
+ @property
33
+ def dependency(self) -> params.Depends:
34
+ return self
35
+
36
+
37
+ class CacheConfig(BaseCacheConfigDepends):
38
+ """Cache configuration for route.
39
+
40
+ Args:
41
+ max_age: Cache lifetime in seconds
42
+ key_func: Cache key generation function
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ max_age: int = 5 * 60,
48
+ key_func: tp.Optional[tp.Callable[[Request], str]] = None,
49
+ ) -> None:
50
+ self.max_age = max_age
51
+ self.key_func = key_func
52
+
53
+
54
+ class CacheDropConfig(BaseCacheConfigDepends):
55
+ """Cache invalidation configuration for route.
56
+
57
+ Args:
58
+ paths: Path for cache invalidation. Can be string or regular expression.
59
+ If string, it will be converted to regular expression
60
+ that matches the beginning of request path.
61
+ """
62
+
63
+ def __init__(self, paths: list[str | re.Pattern]) -> None:
64
+ self.paths: list[re.Pattern] = [
65
+ p if isinstance(p, re.Pattern) else re.compile(f"^{p}$") for p in paths
66
+ ]