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,92 +1,92 @@
1
- import json
2
- import typing as tp
3
-
4
- from starlette.requests import Request
5
- from starlette.responses import Response
6
-
7
- # Define types for metadata and stored response
8
- Metadata: tp.TypeAlias = tp.Dict[str, tp.Any] # todo: make it models
9
- StoredResponse: tp.TypeAlias = tp.Tuple[Response, Request, Metadata]
10
-
11
-
12
- class BaseSerializer:
13
- def dumps(
14
- self, response: Response, request: Request, metadata: Metadata
15
- ) -> tp.Union[str, bytes]:
16
- raise NotImplementedError()
17
-
18
- def loads(
19
- self, data: tp.Union[str, bytes]
20
- ) -> tp.Tuple[Response, Request, Metadata]:
21
- raise NotImplementedError()
22
-
23
- @property
24
- def is_binary(self) -> bool:
25
- raise NotImplementedError()
26
-
27
-
28
- class JSONSerializer(BaseSerializer):
29
- def dumps(self, response: Response, request: Request, metadata: Metadata) -> str:
30
- serialized = {
31
- "response": {
32
- "status_code": response.status_code,
33
- "headers": [[k.decode(), v.decode()] for k, v in response.headers.raw],
34
- "content": (
35
- response.body.decode("utf-8", errors="ignore")
36
- if response.body
37
- else None
38
- ),
39
- },
40
- "request": {
41
- "method": request.method,
42
- "url": str(request.url),
43
- "headers": [[k.decode(), v.decode()] for k, v in request.headers.raw],
44
- },
45
- "metadata": metadata,
46
- }
47
- return json.dumps(serialized)
48
-
49
- def loads(self, data: tp.Union[str, bytes]) -> StoredResponse:
50
- if isinstance(data, bytes):
51
- data = data.decode()
52
-
53
- parsed = json.loads(data)
54
-
55
- # Restore Response
56
- response_data = parsed["response"]
57
- response = Response(
58
- content=(
59
- response_data["content"].encode("utf-8")
60
- if response_data["content"]
61
- else b""
62
- ),
63
- status_code=response_data["status_code"],
64
- headers=dict(response_data["headers"]),
65
- )
66
-
67
- # Restore Request - create mock object for compatibility
68
- request_data = parsed["request"]
69
-
70
- # Create minimal scope for Request
71
- from urllib.parse import urlparse
72
-
73
- parsed_url = urlparse(request_data["url"])
74
- scope = {
75
- "type": "http",
76
- "method": request_data["method"],
77
- "path": parsed_url.path,
78
- "query_string": parsed_url.query.encode() if parsed_url.query else b"",
79
- "headers": [[k.encode(), v.encode()] for k, v in request_data["headers"]],
80
- }
81
-
82
- # Create empty receive function
83
- async def receive():
84
- return {"type": "http.request", "body": b""}
85
-
86
- request = Request(scope, receive)
87
-
88
- return response, request, parsed["metadata"]
89
-
90
- @property
91
- def is_binary(self) -> bool:
92
- return False
1
+ import json
2
+ import typing as tp
3
+
4
+ from starlette.requests import Request
5
+ from starlette.responses import Response
6
+
7
+ # Define types for metadata and stored response
8
+ Metadata: tp.TypeAlias = tp.Dict[str, tp.Any] # todo: make it models
9
+ StoredResponse: tp.TypeAlias = tp.Tuple[Response, Request, Metadata]
10
+
11
+
12
+ class BaseSerializer:
13
+ def dumps(
14
+ self, response: Response, request: Request, metadata: Metadata
15
+ ) -> tp.Union[str, bytes]:
16
+ raise NotImplementedError()
17
+
18
+ def loads(
19
+ self, data: tp.Union[str, bytes]
20
+ ) -> tp.Tuple[Response, Request, Metadata]:
21
+ raise NotImplementedError()
22
+
23
+ @property
24
+ def is_binary(self) -> bool:
25
+ raise NotImplementedError()
26
+
27
+
28
+ class JSONSerializer(BaseSerializer):
29
+ def dumps(self, response: Response, request: Request, metadata: Metadata) -> str:
30
+ serialized = {
31
+ "response": {
32
+ "status_code": response.status_code,
33
+ "headers": [[k.decode(), v.decode()] for k, v in response.headers.raw],
34
+ "content": (
35
+ response.body.decode("utf-8", errors="ignore")
36
+ if response.body
37
+ else None
38
+ ),
39
+ },
40
+ "request": {
41
+ "method": request.method,
42
+ "url": str(request.url),
43
+ "headers": [[k.decode(), v.decode()] for k, v in request.headers.raw],
44
+ },
45
+ "metadata": metadata,
46
+ }
47
+ return json.dumps(serialized)
48
+
49
+ def loads(self, data: tp.Union[str, bytes]) -> StoredResponse:
50
+ if isinstance(data, bytes):
51
+ data = data.decode()
52
+
53
+ parsed = json.loads(data)
54
+
55
+ # Restore Response
56
+ response_data = parsed["response"]
57
+ response = Response(
58
+ content=(
59
+ response_data["content"].encode("utf-8")
60
+ if response_data["content"]
61
+ else b""
62
+ ),
63
+ status_code=response_data["status_code"],
64
+ headers=dict(response_data["headers"]),
65
+ )
66
+
67
+ # Restore Request - create mock object for compatibility
68
+ request_data = parsed["request"]
69
+
70
+ # Create minimal scope for Request
71
+ from urllib.parse import urlparse
72
+
73
+ parsed_url = urlparse(request_data["url"])
74
+ scope = {
75
+ "type": "http",
76
+ "method": request_data["method"],
77
+ "path": parsed_url.path,
78
+ "query_string": parsed_url.query.encode() if parsed_url.query else b"",
79
+ "headers": [[k.encode(), v.encode()] for k, v in request_data["headers"]],
80
+ }
81
+
82
+ # Create empty receive function
83
+ async def receive():
84
+ return {"type": "http.request", "body": b""}
85
+
86
+ request = Request(scope, receive)
87
+
88
+ return response, request, parsed["metadata"]
89
+
90
+ @property
91
+ def is_binary(self) -> bool:
92
+ return False
@@ -1,238 +1,238 @@
1
- import logging
2
- import re
3
- import time
4
- import typing as tp
5
- from collections import OrderedDict
6
-
7
- from starlette.requests import Request
8
- from starlette.responses import Response
9
- from typing_extensions import TypeAlias
10
-
11
- from .exceptions import StorageError
12
- from .serializers import BaseSerializer, JSONSerializer, Metadata
13
-
14
- logger = logging.getLogger(__name__)
15
-
16
- # Define type for stored response
17
- StoredResponse: TypeAlias = tp.Tuple[Response, Request, Metadata]
18
-
19
-
20
- # Define base class for cache storage
21
- class BaseStorage:
22
- """Base class for cache storage.
23
-
24
- Args:
25
- serializer: Serializer for converting Response/Request to string/bytes
26
- ttl: Cache lifetime in seconds. None for permanent storage
27
- """
28
-
29
- def __init__(
30
- self,
31
- serializer: tp.Optional[BaseSerializer] = None,
32
- ttl: tp.Optional[tp.Union[int, float]] = None,
33
- ) -> None:
34
- self._serializer = serializer or JSONSerializer()
35
-
36
- if ttl is not None and ttl <= 0:
37
- raise StorageError("TTL must be positive")
38
-
39
- self._ttl = ttl
40
-
41
- async def store(
42
- self, key: str, response: Response, request: Request, metadata: Metadata
43
- ) -> None:
44
- raise NotImplementedError()
45
-
46
- async def retrieve(self, key: str) -> tp.Optional[StoredResponse]:
47
- raise NotImplementedError()
48
-
49
- async def remove(self, path: re.Pattern) -> None:
50
- raise NotImplementedError()
51
-
52
- async def close(self) -> None:
53
- raise NotImplementedError()
54
-
55
-
56
- class InMemoryStorage(BaseStorage):
57
- """In-memory cache storage with TTL and LRU eviction support.
58
-
59
- Implements optimized storage of cached responses in memory with:
60
- - LRU (Least Recently Used) eviction when max_size is exceeded
61
- - TTL (Time To Live) with lazy checking on read
62
- - Batch cleanup for better performance
63
-
64
- Args:
65
- max_size: Maximum number of cache entries
66
- serializer: Serializer not used for InMemoryStorage
67
- ttl: Cache lifetime in seconds. None for permanent storage
68
- """
69
-
70
- def __init__(
71
- self,
72
- max_size: int = 1000,
73
- serializer: tp.Optional[BaseSerializer] = None,
74
- ttl: tp.Optional[tp.Union[int, float]] = None,
75
- ) -> None:
76
- super().__init__(serializer=serializer, ttl=ttl)
77
-
78
- if max_size <= 0:
79
- raise StorageError("Max size must be positive")
80
-
81
- self._max_size = max_size
82
- # Cleanup batch size - default 10% of max_size, minimum 1
83
- self._cleanup_batch_size = max(1, max_size // 10)
84
- # Cleanup threshold - 5% more than max_size
85
- self._cleanup_threshold = max_size + max(1, max_size // 20)
86
-
87
- # OrderedDict for efficient LRU
88
- self._storage: OrderedDict[str, StoredResponse] = OrderedDict()
89
- # Separate expiry time storage for fast TTL checking
90
- self._expiry_times: tp.Dict[str, float] = {}
91
- self._last_expiry_check_time: float = 0
92
- self._expiry_check_interval: float = 60
93
-
94
- async def store(
95
- self, key: str, response: Response, request: Request, metadata: Metadata
96
- ) -> None:
97
- """Saves response to cache with TTL and LRU eviction support.
98
-
99
- If element already exists, it moves to the end (most recently used).
100
- When size limit is exceeded, batch cleanup of old elements starts.
101
-
102
- Args:
103
- key: Key for saving
104
- response: HTTP response to cache
105
- request: Original HTTP request
106
- metadata: Cache metadata
107
- """
108
- current_time = time.time()
109
-
110
- # Update metadata
111
- metadata = metadata.copy()
112
- metadata["write_time"] = current_time
113
-
114
- # If element already exists, remove it (it will be added to the end)
115
- if key in self._storage:
116
- logger.info("Element %s removed from cache - overwrite", key)
117
- self._pop_item(key)
118
-
119
- self._storage[key] = (response, request, metadata)
120
-
121
- data_ttl = metadata.get("ttl", self._ttl)
122
- if data_ttl is not None:
123
- self._expiry_times[key] = current_time + data_ttl
124
-
125
- self._remove_expired_items()
126
-
127
- self._cleanup_lru_items()
128
-
129
- async def retrieve(self, key: str) -> tp.Optional[StoredResponse]:
130
- """Gets response from cache with lazy TTL checking.
131
-
132
- Element moves to the end to update LRU position.
133
- Expired elements are automatically removed.
134
-
135
- Args:
136
- key: Key to search
137
-
138
- Returns:
139
- Tuple (response, request, metadata) if found and not expired, None if not found or expired
140
- """
141
- if key not in self._storage:
142
- return None
143
-
144
- # Lazy TTL check
145
- if self._is_expired(key):
146
- self._pop_item(key)
147
- logger.debug("Element %s removed from cache - TTL expired", key)
148
- return None
149
-
150
- self._storage.move_to_end(key)
151
-
152
- return self._storage[key]
153
-
154
- async def remove(self, path: re.Pattern) -> None:
155
- """Removes responses from cache by request path pattern.
156
-
157
- Args:
158
- path: Regular expression for matching request paths
159
- """
160
- # Find all keys matching path pattern
161
- keys_to_remove = []
162
- for key, (_, request, _) in self._storage.items():
163
- if path.match(request.url.path):
164
- keys_to_remove.append(key)
165
-
166
- # Remove found keys
167
- for key in keys_to_remove:
168
- self._pop_item(key)
169
-
170
- logger.debug(
171
- "Removed %d entries from cache by pattern %s",
172
- len(keys_to_remove),
173
- path.pattern,
174
- )
175
-
176
- async def close(self) -> None:
177
- """Clears storage and frees resources."""
178
- self._storage.clear()
179
- self._expiry_times.clear()
180
- logger.debug("Cache storage cleared")
181
-
182
- def __len__(self) -> int:
183
- """Returns current number of elements in cache."""
184
- return len(self._storage)
185
-
186
- def _pop_item(self, key: str) -> StoredResponse | None:
187
- """Removes element from storage and expiry times.
188
-
189
- Args:
190
- key: Element key to remove
191
- """
192
- self._expiry_times.pop(key, None)
193
- return self._storage.pop(key, None)
194
-
195
- def _is_expired(self, key: str) -> bool:
196
- """Checks if element is expired by TTL."""
197
- try:
198
- return time.time() > self._expiry_times[key]
199
- except KeyError:
200
- return False
201
-
202
- def _remove_expired_items(self) -> None:
203
- """Removes all expired elements from cache."""
204
- current_time = time.time()
205
-
206
- if current_time - self._last_expiry_check_time < self._expiry_check_interval:
207
- return
208
-
209
- self._last_expiry_check_time = current_time
210
-
211
- expired_keys = [
212
- key
213
- for key, expiry_time in self._expiry_times.items()
214
- if current_time > expiry_time
215
- ]
216
- if not expired_keys:
217
- return
218
-
219
- for key in expired_keys:
220
- self._pop_item(key)
221
-
222
- logger.debug("Removed %d expired elements from cache", len(expired_keys))
223
-
224
- def _cleanup_lru_items(self) -> None:
225
- """Removes old elements by LRU strategy when limit is exceeded."""
226
- if len(self._storage) <= self._cleanup_threshold:
227
- return
228
-
229
- # Remove elements in batches for better performance
230
- items_to_remove = min(
231
- self._cleanup_batch_size, len(self._storage) - self._max_size
232
- )
233
-
234
- for _ in range(items_to_remove):
235
- key, _ = self._storage.popitem(last=False) # FIFO
236
- self._expiry_times.pop(key, None)
237
-
238
- logger.debug("Removed %d elements from cache by LRU strategy", items_to_remove)
1
+ import logging
2
+ import re
3
+ import time
4
+ import typing as tp
5
+ from collections import OrderedDict
6
+
7
+ from starlette.requests import Request
8
+ from starlette.responses import Response
9
+ from typing_extensions import TypeAlias
10
+
11
+ from .exceptions import StorageError
12
+ from .serializers import BaseSerializer, JSONSerializer, Metadata
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Define type for stored response
17
+ StoredResponse: TypeAlias = tp.Tuple[Response, Request, Metadata]
18
+
19
+
20
+ # Define base class for cache storage
21
+ class BaseStorage:
22
+ """Base class for cache storage.
23
+
24
+ Args:
25
+ serializer: Serializer for converting Response/Request to string/bytes
26
+ ttl: Cache lifetime in seconds. None for permanent storage
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ serializer: tp.Optional[BaseSerializer] = None,
32
+ ttl: tp.Optional[tp.Union[int, float]] = None,
33
+ ) -> None:
34
+ self._serializer = serializer or JSONSerializer()
35
+
36
+ if ttl is not None and ttl <= 0:
37
+ raise StorageError("TTL must be positive")
38
+
39
+ self._ttl = ttl
40
+
41
+ async def store(
42
+ self, key: str, response: Response, request: Request, metadata: Metadata
43
+ ) -> None:
44
+ raise NotImplementedError()
45
+
46
+ async def retrieve(self, key: str) -> tp.Optional[StoredResponse]:
47
+ raise NotImplementedError()
48
+
49
+ async def remove(self, path: re.Pattern) -> None:
50
+ raise NotImplementedError()
51
+
52
+ async def close(self) -> None:
53
+ raise NotImplementedError()
54
+
55
+
56
+ class InMemoryStorage(BaseStorage):
57
+ """In-memory cache storage with TTL and LRU eviction support.
58
+
59
+ Implements optimized storage of cached responses in memory with:
60
+ - LRU (Least Recently Used) eviction when max_size is exceeded
61
+ - TTL (Time To Live) with lazy checking on read
62
+ - Batch cleanup for better performance
63
+
64
+ Args:
65
+ max_size: Maximum number of cache entries
66
+ serializer: Serializer not used for InMemoryStorage
67
+ ttl: Cache lifetime in seconds. None for permanent storage
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ max_size: int = 1000,
73
+ serializer: tp.Optional[BaseSerializer] = None,
74
+ ttl: tp.Optional[tp.Union[int, float]] = None,
75
+ ) -> None:
76
+ super().__init__(serializer=serializer, ttl=ttl)
77
+
78
+ if max_size <= 0:
79
+ raise StorageError("Max size must be positive")
80
+
81
+ self._max_size = max_size
82
+ # Cleanup batch size - default 10% of max_size, minimum 1
83
+ self._cleanup_batch_size = max(1, max_size // 10)
84
+ # Cleanup threshold - 5% more than max_size
85
+ self._cleanup_threshold = max_size + max(1, max_size // 20)
86
+
87
+ # OrderedDict for efficient LRU
88
+ self._storage: OrderedDict[str, StoredResponse] = OrderedDict()
89
+ # Separate expiry time storage for fast TTL checking
90
+ self._expiry_times: tp.Dict[str, float] = {}
91
+ self._last_expiry_check_time: float = 0
92
+ self._expiry_check_interval: float = 60
93
+
94
+ async def store(
95
+ self, key: str, response: Response, request: Request, metadata: Metadata
96
+ ) -> None:
97
+ """Saves response to cache with TTL and LRU eviction support.
98
+
99
+ If element already exists, it moves to the end (most recently used).
100
+ When size limit is exceeded, batch cleanup of old elements starts.
101
+
102
+ Args:
103
+ key: Key for saving
104
+ response: HTTP response to cache
105
+ request: Original HTTP request
106
+ metadata: Cache metadata
107
+ """
108
+ current_time = time.time()
109
+
110
+ # Update metadata
111
+ metadata = metadata.copy()
112
+ metadata["write_time"] = current_time
113
+
114
+ # If element already exists, remove it (it will be added to the end)
115
+ if key in self._storage:
116
+ logger.info("Element %s removed from cache - overwrite", key)
117
+ self._pop_item(key)
118
+
119
+ self._storage[key] = (response, request, metadata)
120
+
121
+ data_ttl = metadata.get("ttl", self._ttl)
122
+ if data_ttl is not None:
123
+ self._expiry_times[key] = current_time + data_ttl
124
+
125
+ self._remove_expired_items()
126
+
127
+ self._cleanup_lru_items()
128
+
129
+ async def retrieve(self, key: str) -> tp.Optional[StoredResponse]:
130
+ """Gets response from cache with lazy TTL checking.
131
+
132
+ Element moves to the end to update LRU position.
133
+ Expired elements are automatically removed.
134
+
135
+ Args:
136
+ key: Key to search
137
+
138
+ Returns:
139
+ Tuple (response, request, metadata) if found and not expired, None if not found or expired
140
+ """
141
+ if key not in self._storage:
142
+ return None
143
+
144
+ # Lazy TTL check
145
+ if self._is_expired(key):
146
+ self._pop_item(key)
147
+ logger.debug("Element %s removed from cache - TTL expired", key)
148
+ return None
149
+
150
+ self._storage.move_to_end(key)
151
+
152
+ return self._storage[key]
153
+
154
+ async def remove(self, path: re.Pattern) -> None:
155
+ """Removes responses from cache by request path pattern.
156
+
157
+ Args:
158
+ path: Regular expression for matching request paths
159
+ """
160
+ # Find all keys matching path pattern
161
+ keys_to_remove = []
162
+ for key, (_, request, _) in self._storage.items():
163
+ if path.match(request.url.path):
164
+ keys_to_remove.append(key)
165
+
166
+ # Remove found keys
167
+ for key in keys_to_remove:
168
+ self._pop_item(key)
169
+
170
+ logger.debug(
171
+ "Removed %d entries from cache by pattern %s",
172
+ len(keys_to_remove),
173
+ path.pattern,
174
+ )
175
+
176
+ async def close(self) -> None:
177
+ """Clears storage and frees resources."""
178
+ self._storage.clear()
179
+ self._expiry_times.clear()
180
+ logger.debug("Cache storage cleared")
181
+
182
+ def __len__(self) -> int:
183
+ """Returns current number of elements in cache."""
184
+ return len(self._storage)
185
+
186
+ def _pop_item(self, key: str) -> StoredResponse | None:
187
+ """Removes element from storage and expiry times.
188
+
189
+ Args:
190
+ key: Element key to remove
191
+ """
192
+ self._expiry_times.pop(key, None)
193
+ return self._storage.pop(key, None)
194
+
195
+ def _is_expired(self, key: str) -> bool:
196
+ """Checks if element is expired by TTL."""
197
+ try:
198
+ return time.time() > self._expiry_times[key]
199
+ except KeyError:
200
+ return False
201
+
202
+ def _remove_expired_items(self) -> None:
203
+ """Removes all expired elements from cache."""
204
+ current_time = time.time()
205
+
206
+ if current_time - self._last_expiry_check_time < self._expiry_check_interval:
207
+ return
208
+
209
+ self._last_expiry_check_time = current_time
210
+
211
+ expired_keys = [
212
+ key
213
+ for key, expiry_time in self._expiry_times.items()
214
+ if current_time > expiry_time
215
+ ]
216
+ if not expired_keys:
217
+ return
218
+
219
+ for key in expired_keys:
220
+ self._pop_item(key)
221
+
222
+ logger.debug("Removed %d expired elements from cache", len(expired_keys))
223
+
224
+ def _cleanup_lru_items(self) -> None:
225
+ """Removes old elements by LRU strategy when limit is exceeded."""
226
+ if len(self._storage) <= self._cleanup_threshold:
227
+ return
228
+
229
+ # Remove elements in batches for better performance
230
+ items_to_remove = min(
231
+ self._cleanup_batch_size, len(self._storage) - self._max_size
232
+ )
233
+
234
+ for _ in range(items_to_remove):
235
+ key, _ = self._storage.popitem(last=False) # FIFO
236
+ self._expiry_times.pop(key, None)
237
+
238
+ logger.debug("Removed %d elements from cache by LRU strategy", items_to_remove)