hishel 1.0.0.dev2__py3-none-any.whl → 1.0.0.dev3__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.
- hishel/__init__.py +15 -14
- hishel/_async_cache.py +42 -36
- hishel/_async_httpx.py +243 -0
- hishel/_core/_headers.py +11 -1
- hishel/_core/_spec.py +88 -84
- hishel/_core/_storages/_async_base.py +71 -0
- hishel/_core/{_async/_storages/_sqlite.py → _storages/_async_sqlite.py} +95 -132
- hishel/_core/_storages/_packing.py +144 -0
- hishel/_core/_storages/_sync_base.py +71 -0
- hishel/_core/{_sync/_storages/_sqlite.py → _storages/_sync_sqlite.py} +95 -132
- hishel/_core/models.py +6 -22
- hishel/_sync_cache.py +42 -36
- hishel/_sync_httpx.py +243 -0
- hishel/_utils.py +49 -2
- hishel/asgi.py +400 -0
- hishel/fastapi.py +263 -0
- hishel/httpx.py +3 -326
- hishel/requests.py +25 -17
- {hishel-1.0.0.dev2.dist-info → hishel-1.0.0.dev3.dist-info}/METADATA +100 -6
- hishel-1.0.0.dev3.dist-info/RECORD +23 -0
- hishel/_core/__init__.py +0 -59
- hishel/_core/_base/_storages/_base.py +0 -272
- hishel/_core/_base/_storages/_packing.py +0 -165
- hishel-1.0.0.dev2.dist-info/RECORD +0 -19
- {hishel-1.0.0.dev2.dist-info → hishel-1.0.0.dev3.dist-info}/WHEEL +0 -0
- {hishel-1.0.0.dev2.dist-info → hishel-1.0.0.dev3.dist-info}/licenses/LICENSE +0 -0
|
@@ -9,21 +9,19 @@ from typing import (
|
|
|
9
9
|
Iterator,
|
|
10
10
|
Callable,
|
|
11
11
|
List,
|
|
12
|
-
Literal,
|
|
13
12
|
Optional,
|
|
14
13
|
Union,
|
|
15
14
|
)
|
|
16
15
|
|
|
17
|
-
from hishel._core.
|
|
18
|
-
from hishel._core.
|
|
16
|
+
from hishel._core._storages._sync_base import SyncBaseStorage
|
|
17
|
+
from hishel._core._storages._packing import pack, unpack
|
|
19
18
|
from hishel._core.models import (
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
Pair,
|
|
23
|
-
PairMeta,
|
|
19
|
+
Entry,
|
|
20
|
+
EntryMeta,
|
|
24
21
|
Request,
|
|
25
22
|
Response,
|
|
26
23
|
)
|
|
24
|
+
from hishel._utils import ensure_cache_dict
|
|
27
25
|
|
|
28
26
|
# Batch cleanup configuration
|
|
29
27
|
# How often to run cleanup (seconds). Default: 1 hour.
|
|
@@ -38,7 +36,6 @@ try:
|
|
|
38
36
|
import sqlite3
|
|
39
37
|
|
|
40
38
|
class SyncSqliteStorage(SyncBaseStorage):
|
|
41
|
-
_STREAM_KIND = {"request": 0, "response": 1}
|
|
42
39
|
_COMPLETE_CHUNK_NUMBER = -1
|
|
43
40
|
|
|
44
41
|
def __init__(
|
|
@@ -85,14 +82,13 @@ try:
|
|
|
85
82
|
)
|
|
86
83
|
""")
|
|
87
84
|
|
|
88
|
-
# Table for storing stream chunks
|
|
85
|
+
# Table for storing response stream chunks only
|
|
89
86
|
cursor.execute("""
|
|
90
87
|
CREATE TABLE IF NOT EXISTS streams (
|
|
91
88
|
entry_id BLOB NOT NULL,
|
|
92
|
-
kind INTEGER NOT NULL,
|
|
93
89
|
chunk_number INTEGER NOT NULL,
|
|
94
90
|
chunk_data BLOB NOT NULL,
|
|
95
|
-
PRIMARY KEY (entry_id,
|
|
91
|
+
PRIMARY KEY (entry_id, chunk_number),
|
|
96
92
|
FOREIGN KEY (entry_id) REFERENCES entries(id) ON DELETE CASCADE
|
|
97
93
|
)
|
|
98
94
|
""")
|
|
@@ -100,85 +96,48 @@ try:
|
|
|
100
96
|
# Indexes for performance
|
|
101
97
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_entries_deleted_at ON entries(deleted_at)")
|
|
102
98
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_entries_cache_key ON entries(cache_key)")
|
|
103
|
-
# Note: PRIMARY KEY (entry_id, kind, chunk_number) already provides an index
|
|
104
|
-
# for queries like: entry_id = ? AND kind = ? AND chunk_number = ?
|
|
105
99
|
|
|
106
100
|
self.connection.commit()
|
|
107
101
|
|
|
108
|
-
def
|
|
109
|
-
self,
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
) -> IncompletePair:
|
|
113
|
-
pair_id = id if id is not None else uuid.uuid4()
|
|
114
|
-
pair_meta = PairMeta(
|
|
115
|
-
created_at=time.time(),
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
pair = IncompletePair(id=pair_id, request=request, meta=pair_meta)
|
|
119
|
-
|
|
120
|
-
packed_pair = pack(pair, kind="pair")
|
|
102
|
+
def create_entry(
|
|
103
|
+
self, request: Request, response: Response, key: str, id_: uuid.UUID | None = None
|
|
104
|
+
) -> Entry:
|
|
105
|
+
key_bytes = key.encode("utf-8")
|
|
121
106
|
|
|
122
107
|
connection = self._ensure_connection()
|
|
123
108
|
cursor = connection.cursor()
|
|
124
|
-
cursor.execute(
|
|
125
|
-
"INSERT INTO entries (id, cache_key, data, created_at, deleted_at) VALUES (?, ?, ?, ?, ?)",
|
|
126
|
-
(pair_id.bytes, None, packed_pair, pair_meta.created_at, None),
|
|
127
|
-
)
|
|
128
|
-
connection.commit()
|
|
129
|
-
|
|
130
|
-
assert isinstance(request.stream, Iterable), "Request stream must be an Iterable, not Iterable"
|
|
131
109
|
|
|
132
|
-
request
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
metadata=request.metadata,
|
|
137
|
-
stream=self._save_stream(request.stream, pair_id.bytes, "request"),
|
|
110
|
+
# Create a new entry directly with both request and response
|
|
111
|
+
pair_id = id_ if id_ is not None else uuid.uuid4()
|
|
112
|
+
pair_meta = EntryMeta(
|
|
113
|
+
created_at=time.time(),
|
|
138
114
|
)
|
|
139
115
|
|
|
140
|
-
return replace(pair, request=request)
|
|
141
|
-
|
|
142
|
-
def add_response(
|
|
143
|
-
self,
|
|
144
|
-
pair_id: uuid.UUID,
|
|
145
|
-
response: Response,
|
|
146
|
-
key: str | bytes,
|
|
147
|
-
) -> CompletePair:
|
|
148
|
-
if isinstance(key, str):
|
|
149
|
-
key = key.encode("utf-8")
|
|
150
|
-
|
|
151
|
-
connection = self._ensure_connection()
|
|
152
|
-
cursor = connection.cursor()
|
|
153
|
-
|
|
154
|
-
# Get the existing pair
|
|
155
|
-
cursor.execute("SELECT data FROM entries WHERE id = ?", (pair_id.bytes,))
|
|
156
|
-
result = cursor.fetchone()
|
|
157
|
-
|
|
158
|
-
if result is None:
|
|
159
|
-
raise ValueError(f"Entry with ID {pair_id} not found.")
|
|
160
|
-
|
|
161
|
-
pair = unpack(result[0], kind="pair")
|
|
162
|
-
|
|
163
116
|
assert isinstance(response.stream, (Iterator, Iterable))
|
|
164
|
-
|
|
117
|
+
response_with_stream = replace(
|
|
118
|
+
response,
|
|
119
|
+
stream=self._save_stream(response.stream, pair_id.bytes),
|
|
120
|
+
)
|
|
165
121
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
122
|
+
complete_entry = Entry(
|
|
123
|
+
id=pair_id,
|
|
124
|
+
request=request,
|
|
125
|
+
response=response_with_stream,
|
|
126
|
+
meta=pair_meta,
|
|
127
|
+
cache_key=key_bytes,
|
|
169
128
|
)
|
|
170
129
|
|
|
171
|
-
#
|
|
130
|
+
# Insert the complete entry into the database
|
|
172
131
|
cursor.execute(
|
|
173
|
-
"
|
|
174
|
-
(pack(
|
|
132
|
+
"INSERT INTO entries (id, cache_key, data, created_at, deleted_at) VALUES (?, ?, ?, ?, ?)",
|
|
133
|
+
(pair_id.bytes, key_bytes, pack(complete_entry, kind="pair"), pair_meta.created_at, None),
|
|
175
134
|
)
|
|
176
135
|
connection.commit()
|
|
177
136
|
|
|
178
|
-
return
|
|
137
|
+
return complete_entry
|
|
179
138
|
|
|
180
|
-
def
|
|
181
|
-
final_pairs: List[
|
|
139
|
+
def get_entries(self, key: str) -> List[Entry]:
|
|
140
|
+
final_pairs: List[Entry] = []
|
|
182
141
|
|
|
183
142
|
now = time.time()
|
|
184
143
|
if now - self.last_cleanup >= BATCH_CLEANUP_INTERVAL:
|
|
@@ -191,39 +150,40 @@ try:
|
|
|
191
150
|
connection = self._ensure_connection()
|
|
192
151
|
cursor = connection.cursor()
|
|
193
152
|
# Query entries directly by cache_key
|
|
194
|
-
cursor.execute(
|
|
153
|
+
cursor.execute(
|
|
154
|
+
"SELECT id, data FROM entries WHERE cache_key = ?",
|
|
155
|
+
(key.encode("utf-8"),),
|
|
156
|
+
)
|
|
195
157
|
|
|
196
158
|
for row in cursor.fetchall():
|
|
197
159
|
pair_data = unpack(row[1], kind="pair")
|
|
198
160
|
|
|
199
|
-
|
|
161
|
+
# Skip entries without a response (incomplete)
|
|
162
|
+
if not isinstance(pair_data, Entry) or pair_data.response is None:
|
|
200
163
|
continue
|
|
201
164
|
|
|
202
165
|
final_pairs.append(pair_data)
|
|
203
166
|
|
|
204
|
-
pairs_with_streams: List[
|
|
167
|
+
pairs_with_streams: List[Entry] = []
|
|
205
168
|
|
|
169
|
+
# Only restore response streams from cache
|
|
206
170
|
for pair in final_pairs:
|
|
207
171
|
pairs_with_streams.append(
|
|
208
172
|
replace(
|
|
209
173
|
pair,
|
|
210
174
|
response=replace(
|
|
211
175
|
pair.response,
|
|
212
|
-
stream=self._stream_data_from_cache(pair.id.bytes
|
|
213
|
-
),
|
|
214
|
-
request=replace(
|
|
215
|
-
pair.request,
|
|
216
|
-
stream=self._stream_data_from_cache(pair.id.bytes, "request"),
|
|
176
|
+
stream=self._stream_data_from_cache(pair.id.bytes),
|
|
217
177
|
),
|
|
218
178
|
)
|
|
219
179
|
)
|
|
220
180
|
return pairs_with_streams
|
|
221
181
|
|
|
222
|
-
def
|
|
182
|
+
def update_entry(
|
|
223
183
|
self,
|
|
224
184
|
id: uuid.UUID,
|
|
225
|
-
new_pair: Union[
|
|
226
|
-
) -> Optional[
|
|
185
|
+
new_pair: Union[Entry, Callable[[Entry], Entry]],
|
|
186
|
+
) -> Optional[Entry]:
|
|
227
187
|
connection = self._ensure_connection()
|
|
228
188
|
cursor = connection.cursor()
|
|
229
189
|
cursor.execute("SELECT data FROM entries WHERE id = ?", (id.bytes,))
|
|
@@ -234,10 +194,11 @@ try:
|
|
|
234
194
|
|
|
235
195
|
pair = unpack(result[0], kind="pair")
|
|
236
196
|
|
|
237
|
-
|
|
197
|
+
# Skip entries without a response (incomplete)
|
|
198
|
+
if not isinstance(pair, Entry) or pair.response is None:
|
|
238
199
|
return None
|
|
239
200
|
|
|
240
|
-
if isinstance(new_pair,
|
|
201
|
+
if isinstance(new_pair, Entry):
|
|
241
202
|
complete_pair = new_pair
|
|
242
203
|
else:
|
|
243
204
|
complete_pair = new_pair(pair)
|
|
@@ -246,7 +207,8 @@ try:
|
|
|
246
207
|
raise ValueError("Pair ID mismatch")
|
|
247
208
|
|
|
248
209
|
cursor.execute(
|
|
249
|
-
"UPDATE entries SET data = ? WHERE id = ?",
|
|
210
|
+
"UPDATE entries SET data = ? WHERE id = ?",
|
|
211
|
+
(pack(complete_pair, kind="pair"), id.bytes),
|
|
250
212
|
)
|
|
251
213
|
|
|
252
214
|
if pair.cache_key != complete_pair.cache_key:
|
|
@@ -259,7 +221,7 @@ try:
|
|
|
259
221
|
|
|
260
222
|
return complete_pair
|
|
261
223
|
|
|
262
|
-
def
|
|
224
|
+
def remove_entry(self, id: uuid.UUID) -> None:
|
|
263
225
|
connection = self._ensure_connection()
|
|
264
226
|
cursor = connection.cursor()
|
|
265
227
|
cursor.execute("SELECT data FROM entries WHERE id = ?", (id.bytes,))
|
|
@@ -272,28 +234,33 @@ try:
|
|
|
272
234
|
self._soft_delete_pair(pair, cursor)
|
|
273
235
|
connection.commit()
|
|
274
236
|
|
|
275
|
-
def _is_stream_complete(
|
|
276
|
-
|
|
277
|
-
) -> bool:
|
|
278
|
-
kind_id = self._STREAM_KIND[kind]
|
|
279
|
-
# Check if there's a completion marker (chunk_number = -1)
|
|
237
|
+
def _is_stream_complete(self, pair_id: uuid.UUID, cursor: sqlite3.Cursor) -> bool:
|
|
238
|
+
# Check if there's a completion marker (chunk_number = -1) for response stream
|
|
280
239
|
cursor.execute(
|
|
281
|
-
"SELECT 1 FROM streams WHERE entry_id = ? AND
|
|
282
|
-
(pair_id.bytes,
|
|
240
|
+
"SELECT 1 FROM streams WHERE entry_id = ? AND chunk_number = ? LIMIT 1",
|
|
241
|
+
(pair_id.bytes, self._COMPLETE_CHUNK_NUMBER),
|
|
283
242
|
)
|
|
284
243
|
return cursor.fetchone() is not None
|
|
285
244
|
|
|
286
|
-
def _soft_delete_pair(
|
|
245
|
+
def _soft_delete_pair(
|
|
246
|
+
self,
|
|
247
|
+
pair: Entry,
|
|
248
|
+
cursor: sqlite3.Cursor,
|
|
249
|
+
) -> None:
|
|
287
250
|
"""
|
|
288
251
|
Mark the pair as deleted by setting the deleted_at timestamp.
|
|
289
252
|
"""
|
|
290
253
|
marked_pair = self.mark_pair_as_deleted(pair)
|
|
291
254
|
cursor.execute(
|
|
292
255
|
"UPDATE entries SET data = ?, deleted_at = ? WHERE id = ?",
|
|
293
|
-
(
|
|
256
|
+
(
|
|
257
|
+
pack(marked_pair, kind="pair"),
|
|
258
|
+
marked_pair.meta.deleted_at,
|
|
259
|
+
pair.id.bytes,
|
|
260
|
+
),
|
|
294
261
|
)
|
|
295
262
|
|
|
296
|
-
def _is_pair_expired(self, pair:
|
|
263
|
+
def _is_pair_expired(self, pair: Entry, cursor: sqlite3.Cursor) -> bool:
|
|
297
264
|
"""
|
|
298
265
|
Check if the pair is expired.
|
|
299
266
|
"""
|
|
@@ -307,10 +274,10 @@ try:
|
|
|
307
274
|
self,
|
|
308
275
|
) -> None:
|
|
309
276
|
"""
|
|
310
|
-
Cleanup expired
|
|
277
|
+
Cleanup expired entries in the database.
|
|
311
278
|
"""
|
|
312
|
-
should_mark_as_deleted: List[
|
|
313
|
-
should_hard_delete: List[
|
|
279
|
+
should_mark_as_deleted: List[Entry] = []
|
|
280
|
+
should_hard_delete: List[Entry] = []
|
|
314
281
|
|
|
315
282
|
connection = self._ensure_connection()
|
|
316
283
|
cursor = connection.cursor()
|
|
@@ -319,7 +286,10 @@ try:
|
|
|
319
286
|
chunk_size = BATCH_CLEANUP_CHUNK_SIZE
|
|
320
287
|
offset = 0
|
|
321
288
|
while True:
|
|
322
|
-
cursor.execute(
|
|
289
|
+
cursor.execute(
|
|
290
|
+
"SELECT id, data FROM entries LIMIT ? OFFSET ?",
|
|
291
|
+
(chunk_size, offset),
|
|
292
|
+
)
|
|
323
293
|
rows = cursor.fetchall()
|
|
324
294
|
if not rows:
|
|
325
295
|
break
|
|
@@ -350,61 +320,56 @@ try:
|
|
|
350
320
|
|
|
351
321
|
connection.commit()
|
|
352
322
|
|
|
353
|
-
def _is_corrupted(self, pair:
|
|
354
|
-
# if
|
|
355
|
-
if pair.meta.created_at + 3600 < time.time() and
|
|
323
|
+
def _is_corrupted(self, pair: Entry, cursor: sqlite3.Cursor) -> bool:
|
|
324
|
+
# if entry was created more than 1 hour ago and still has no response (incomplete)
|
|
325
|
+
if pair.meta.created_at + 3600 < time.time() and pair.response is None:
|
|
356
326
|
return True
|
|
357
327
|
|
|
358
|
-
if
|
|
328
|
+
# Check if response stream is complete for Entry with response
|
|
329
|
+
if (
|
|
330
|
+
isinstance(pair, Entry)
|
|
331
|
+
and pair.response is not None
|
|
332
|
+
and not self._is_stream_complete(pair.id, cursor)
|
|
333
|
+
):
|
|
359
334
|
return True
|
|
360
335
|
return False
|
|
361
336
|
|
|
362
|
-
def _hard_delete_pair(self, pair:
|
|
337
|
+
def _hard_delete_pair(self, pair: Entry, cursor: sqlite3.Cursor) -> None:
|
|
363
338
|
"""
|
|
364
339
|
Permanently delete the pair from the database.
|
|
365
340
|
"""
|
|
366
341
|
cursor.execute("DELETE FROM entries WHERE id = ?", (pair.id.bytes,))
|
|
367
342
|
|
|
368
|
-
# Delete
|
|
343
|
+
# Delete response stream for this entry
|
|
369
344
|
self._delete_stream(pair.id.bytes, cursor)
|
|
370
345
|
|
|
371
346
|
def _delete_stream(
|
|
372
347
|
self,
|
|
373
348
|
entry_id: bytes,
|
|
374
349
|
cursor: sqlite3.Cursor,
|
|
375
|
-
type: Literal["request", "response", "all"] = "all",
|
|
376
350
|
) -> None:
|
|
377
351
|
"""
|
|
378
|
-
Delete
|
|
352
|
+
Delete response stream associated with the given entry ID.
|
|
379
353
|
"""
|
|
380
|
-
|
|
381
|
-
cursor.execute(
|
|
382
|
-
"DELETE FROM streams WHERE entry_id = ? AND kind = ?", (entry_id, self._STREAM_KIND["request"])
|
|
383
|
-
)
|
|
384
|
-
elif type == "response":
|
|
385
|
-
cursor.execute(
|
|
386
|
-
"DELETE FROM streams WHERE entry_id = ? AND kind = ?", (entry_id, self._STREAM_KIND["response"])
|
|
387
|
-
)
|
|
388
|
-
elif type == "all":
|
|
389
|
-
cursor.execute("DELETE FROM streams WHERE entry_id = ?", (entry_id,))
|
|
354
|
+
cursor.execute("DELETE FROM streams WHERE entry_id = ?", (entry_id,))
|
|
390
355
|
|
|
391
356
|
def _save_stream(
|
|
392
357
|
self,
|
|
393
358
|
stream: Iterator[bytes],
|
|
394
359
|
entry_id: bytes,
|
|
395
|
-
kind: Literal["response", "request"],
|
|
396
360
|
) -> Iterator[bytes]:
|
|
397
361
|
"""
|
|
398
|
-
Wrapper around an async iterator that also saves the data to the cache in chunks.
|
|
362
|
+
Wrapper around an async iterator that also saves the response data to the cache in chunks.
|
|
399
363
|
"""
|
|
400
|
-
kind_id = self._STREAM_KIND[kind]
|
|
401
364
|
chunk_number = 0
|
|
365
|
+
content_length = 0
|
|
402
366
|
for chunk in stream:
|
|
367
|
+
content_length += len(chunk)
|
|
403
368
|
connection = self._ensure_connection()
|
|
404
369
|
cursor = connection.cursor()
|
|
405
370
|
cursor.execute(
|
|
406
|
-
"INSERT INTO streams (entry_id,
|
|
407
|
-
(entry_id,
|
|
371
|
+
"INSERT INTO streams (entry_id, chunk_number, chunk_data) VALUES (?, ?, ?)",
|
|
372
|
+
(entry_id, chunk_number, chunk),
|
|
408
373
|
)
|
|
409
374
|
connection.commit()
|
|
410
375
|
chunk_number += 1
|
|
@@ -414,28 +379,26 @@ try:
|
|
|
414
379
|
connection = self._ensure_connection()
|
|
415
380
|
cursor = connection.cursor()
|
|
416
381
|
cursor.execute(
|
|
417
|
-
"INSERT INTO streams (entry_id,
|
|
418
|
-
(entry_id,
|
|
382
|
+
"INSERT INTO streams (entry_id, chunk_number, chunk_data) VALUES (?, ?, ?)",
|
|
383
|
+
(entry_id, self._COMPLETE_CHUNK_NUMBER, b""),
|
|
419
384
|
)
|
|
420
385
|
connection.commit()
|
|
421
386
|
|
|
422
387
|
def _stream_data_from_cache(
|
|
423
388
|
self,
|
|
424
389
|
entry_id: bytes,
|
|
425
|
-
kind: Literal["response", "request"],
|
|
426
390
|
) -> Iterator[bytes]:
|
|
427
391
|
"""
|
|
428
|
-
Get an async iterator that yields the stream data from the cache.
|
|
392
|
+
Get an async iterator that yields the response stream data from the cache.
|
|
429
393
|
"""
|
|
430
|
-
kind_id = self._STREAM_KIND[kind]
|
|
431
394
|
chunk_number = 0
|
|
432
395
|
|
|
433
396
|
connection = self._ensure_connection()
|
|
434
397
|
while True:
|
|
435
398
|
cursor = connection.cursor()
|
|
436
399
|
cursor.execute(
|
|
437
|
-
"SELECT chunk_data FROM streams WHERE entry_id = ? AND
|
|
438
|
-
(entry_id,
|
|
400
|
+
"SELECT chunk_data FROM streams WHERE entry_id = ? AND chunk_number = ?",
|
|
401
|
+
(entry_id, chunk_number),
|
|
439
402
|
)
|
|
440
403
|
result = cursor.fetchone()
|
|
441
404
|
|
|
@@ -449,7 +412,7 @@ try:
|
|
|
449
412
|
chunk_number += 1
|
|
450
413
|
except ImportError:
|
|
451
414
|
|
|
452
|
-
class SyncSqliteStorage
|
|
415
|
+
class SyncSqliteStorage: # type: ignore[no-redef]
|
|
453
416
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
454
417
|
raise ImportError(
|
|
455
418
|
"The 'sqlite3' library is required to use the `SyncSqliteStorage` integration. "
|
hishel/_core/models.py
CHANGED
|
@@ -64,7 +64,9 @@ class RequestMetadata(TypedDict, total=False):
|
|
|
64
64
|
"""
|
|
65
65
|
|
|
66
66
|
|
|
67
|
-
def extract_metadata_from_headers(
|
|
67
|
+
def extract_metadata_from_headers(
|
|
68
|
+
headers: Mapping[str, str],
|
|
69
|
+
) -> RequestMetadata:
|
|
68
70
|
metadata: RequestMetadata = {}
|
|
69
71
|
if "X-Hishel-Ttl" in headers:
|
|
70
72
|
try:
|
|
@@ -146,34 +148,16 @@ class Response:
|
|
|
146
148
|
|
|
147
149
|
|
|
148
150
|
@dataclass
|
|
149
|
-
class
|
|
151
|
+
class EntryMeta:
|
|
150
152
|
created_at: float = field(default_factory=time.time)
|
|
151
153
|
deleted_at: Optional[float] = None
|
|
152
154
|
|
|
153
155
|
|
|
154
156
|
@dataclass
|
|
155
|
-
class
|
|
157
|
+
class Entry:
|
|
156
158
|
id: uuid.UUID
|
|
157
159
|
request: Request
|
|
158
|
-
meta:
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
# class used by storage
|
|
162
|
-
@dataclass
|
|
163
|
-
class IncompletePair(Pair):
|
|
164
|
-
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
@dataclass
|
|
168
|
-
class CompletePair(Pair):
|
|
160
|
+
meta: EntryMeta
|
|
169
161
|
response: Response
|
|
170
162
|
cache_key: bytes
|
|
171
163
|
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
172
|
-
|
|
173
|
-
@classmethod
|
|
174
|
-
def create(
|
|
175
|
-
cls,
|
|
176
|
-
response: Response,
|
|
177
|
-
request: Request,
|
|
178
|
-
) -> "CompletePair": # pragma: nocover
|
|
179
|
-
return cls(id=uuid.uuid4(), request=request, response=response, meta=PairMeta(), cache_key=b"")
|
hishel/_sync_cache.py
CHANGED
|
@@ -4,7 +4,7 @@ import hashlib
|
|
|
4
4
|
import logging
|
|
5
5
|
import time
|
|
6
6
|
from dataclasses import replace
|
|
7
|
-
from typing import Iterator, Awaitable, Callable
|
|
7
|
+
from typing import Iterable, Iterator, Awaitable, Callable
|
|
8
8
|
|
|
9
9
|
from typing_extensions import assert_never
|
|
10
10
|
|
|
@@ -24,8 +24,9 @@ from hishel import (
|
|
|
24
24
|
StoreAndUse,
|
|
25
25
|
create_idle_state,
|
|
26
26
|
)
|
|
27
|
-
from hishel._core._spec import
|
|
28
|
-
from hishel._core.models import
|
|
27
|
+
from hishel._core._spec import InvalidateEntries, vary_headers_match
|
|
28
|
+
from hishel._core.models import Entry, ResponseMetadata
|
|
29
|
+
from hishel._utils import make_sync_iterator
|
|
29
30
|
|
|
30
31
|
logger = logging.getLogger("hishel.integrations.clients")
|
|
31
32
|
|
|
@@ -42,12 +43,12 @@ class SyncCacheProxy:
|
|
|
42
43
|
|
|
43
44
|
def __init__(
|
|
44
45
|
self,
|
|
45
|
-
|
|
46
|
+
request_sender: Callable[[Request], Response],
|
|
46
47
|
storage: SyncBaseStorage | None = None,
|
|
47
48
|
cache_options: CacheOptions | None = None,
|
|
48
49
|
ignore_specification: bool = False,
|
|
49
50
|
) -> None:
|
|
50
|
-
self.send_request =
|
|
51
|
+
self.send_request = request_sender
|
|
51
52
|
self.storage = storage if storage is not None else SyncSqliteStorage()
|
|
52
53
|
self.cache_options = cache_options if cache_options is not None else CacheOptions()
|
|
53
54
|
self.ignore_specification = ignore_specification
|
|
@@ -59,26 +60,30 @@ class SyncCacheProxy:
|
|
|
59
60
|
|
|
60
61
|
def _get_key_for_request(self, request: Request) -> str:
|
|
61
62
|
if request.metadata.get("hishel_body_key"):
|
|
62
|
-
assert isinstance(request.stream, Iterator)
|
|
63
|
+
assert isinstance(request.stream, (Iterator, Iterable))
|
|
63
64
|
collected = b"".join([chunk for chunk in request.stream])
|
|
64
65
|
hash_ = hashlib.sha256(collected).hexdigest()
|
|
66
|
+
request.stream = make_sync_iterator([collected])
|
|
65
67
|
return f"{str(request.url)}-{hash_}"
|
|
66
|
-
return str(request.url)
|
|
68
|
+
return hashlib.sha256(str(request.url).encode("utf-8")).hexdigest()
|
|
67
69
|
|
|
68
|
-
def _maybe_refresh_pair_ttl(self, pair:
|
|
70
|
+
def _maybe_refresh_pair_ttl(self, pair: Entry) -> None:
|
|
69
71
|
if pair.request.metadata.get("hishel_refresh_ttl_on_access"):
|
|
70
|
-
self.storage.
|
|
72
|
+
self.storage.update_entry(
|
|
71
73
|
pair.id,
|
|
72
|
-
lambda complete_pair: replace(
|
|
74
|
+
lambda complete_pair: replace(
|
|
75
|
+
complete_pair,
|
|
76
|
+
meta=replace(complete_pair.meta, created_at=time.time()),
|
|
77
|
+
),
|
|
73
78
|
)
|
|
74
79
|
|
|
75
80
|
def _handle_request_ignoring_spec(self, request: Request) -> Response:
|
|
76
81
|
logger.debug("Trying to get cached response ignoring specification")
|
|
77
|
-
|
|
82
|
+
entries = self.storage.get_entries(self._get_key_for_request(request))
|
|
78
83
|
|
|
79
|
-
logger.debug(f"Found {len(
|
|
84
|
+
logger.debug(f"Found {len(entries)} cached entries for the request")
|
|
80
85
|
|
|
81
|
-
for pair in
|
|
86
|
+
for pair in entries:
|
|
82
87
|
if (
|
|
83
88
|
str(pair.request.url) == str(request.url)
|
|
84
89
|
and pair.request.method == request.method
|
|
@@ -101,16 +106,15 @@ class SyncCacheProxy:
|
|
|
101
106
|
self._maybe_refresh_pair_ttl(pair)
|
|
102
107
|
return pair.response
|
|
103
108
|
|
|
104
|
-
|
|
105
|
-
request,
|
|
106
|
-
)
|
|
107
|
-
response = self.send_request(incomplete_pair.request)
|
|
109
|
+
response = self.send_request(request)
|
|
108
110
|
|
|
109
111
|
logger.debug("Storing response in cache ignoring specification")
|
|
110
|
-
|
|
111
|
-
|
|
112
|
+
entry = self.storage.create_entry(
|
|
113
|
+
request,
|
|
114
|
+
response,
|
|
115
|
+
self._get_key_for_request(request),
|
|
112
116
|
)
|
|
113
|
-
return
|
|
117
|
+
return entry.response
|
|
114
118
|
|
|
115
119
|
def _handle_request_respecting_spec(self, request: Request) -> Response:
|
|
116
120
|
state: AnyState = create_idle_state("client", self.cache_options)
|
|
@@ -132,25 +136,26 @@ class SyncCacheProxy:
|
|
|
132
136
|
return state.pair.response
|
|
133
137
|
elif isinstance(state, NeedToBeUpdated):
|
|
134
138
|
state = self._handle_update(state)
|
|
135
|
-
elif isinstance(state,
|
|
136
|
-
state = self.
|
|
139
|
+
elif isinstance(state, InvalidateEntries):
|
|
140
|
+
state = self._handle_invalidate_entries(state)
|
|
137
141
|
else:
|
|
138
142
|
assert_never(state)
|
|
139
143
|
|
|
140
144
|
raise RuntimeError("Unreachable")
|
|
141
145
|
|
|
142
146
|
def _handle_idle_state(self, state: IdleClient, request: Request) -> AnyState:
|
|
143
|
-
|
|
144
|
-
return state.next(request,
|
|
147
|
+
stored_entries = self.storage.get_entries(self._get_key_for_request(request))
|
|
148
|
+
return state.next(request, stored_entries)
|
|
145
149
|
|
|
146
150
|
def _handle_cache_miss(self, state: CacheMiss) -> AnyState:
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return state.next(response, incomplete_pair.id)
|
|
151
|
+
response = self.send_request(state.request)
|
|
152
|
+
return state.next(response)
|
|
150
153
|
|
|
151
154
|
def _handle_store_and_use(self, state: StoreAndUse, request: Request) -> Response:
|
|
152
|
-
complete_pair = self.storage.
|
|
153
|
-
|
|
155
|
+
complete_pair = self.storage.create_entry(
|
|
156
|
+
request,
|
|
157
|
+
state.response,
|
|
158
|
+
self._get_key_for_request(request),
|
|
154
159
|
)
|
|
155
160
|
return complete_pair.response
|
|
156
161
|
|
|
@@ -159,16 +164,17 @@ class SyncCacheProxy:
|
|
|
159
164
|
return state.next(revalidation_response)
|
|
160
165
|
|
|
161
166
|
def _handle_update(self, state: NeedToBeUpdated) -> AnyState:
|
|
162
|
-
for
|
|
163
|
-
self.storage.
|
|
164
|
-
|
|
167
|
+
for entry in state.updating_entries:
|
|
168
|
+
self.storage.update_entry(
|
|
169
|
+
entry.id,
|
|
165
170
|
lambda complete_pair: replace(
|
|
166
|
-
complete_pair,
|
|
171
|
+
complete_pair,
|
|
172
|
+
response=replace(entry.response, headers=entry.response.headers),
|
|
167
173
|
),
|
|
168
174
|
)
|
|
169
175
|
return state.next()
|
|
170
176
|
|
|
171
|
-
def
|
|
172
|
-
for
|
|
173
|
-
self.storage.
|
|
177
|
+
def _handle_invalidate_entries(self, state: InvalidateEntries) -> AnyState:
|
|
178
|
+
for entry_id in state.entry_ids:
|
|
179
|
+
self.storage.remove_entry(entry_id)
|
|
174
180
|
return state.next()
|