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