hishel 0.1.3__py3-none-any.whl → 0.1.5__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 +41 -1
- hishel/_async/_client.py +1 -1
- hishel/_async/_storages.py +10 -14
- hishel/_async/_transports.py +9 -4
- hishel/_controller.py +2 -3
- hishel/_lmdb_types_.pyi +53 -0
- hishel/_serializers.py +2 -2
- hishel/_sync/_client.py +1 -1
- hishel/_sync/_storages.py +10 -14
- hishel/_sync/_transports.py +9 -4
- hishel/_utils.py +340 -0
- hishel/beta/__init__.py +59 -0
- hishel/beta/_async_cache.py +167 -0
- hishel/beta/_core/__init__.py +0 -0
- hishel/beta/_core/_async/_storages/_sqlite.py +411 -0
- hishel/beta/_core/_base/_storages/_base.py +272 -0
- hishel/beta/_core/_base/_storages/_packing.py +165 -0
- hishel/beta/_core/_headers.py +636 -0
- hishel/beta/_core/_spec.py +2291 -0
- hishel/beta/_core/_sync/_storages/_sqlite.py +411 -0
- hishel/beta/_core/models.py +176 -0
- hishel/beta/_sync_cache.py +167 -0
- hishel/beta/httpx.py +328 -0
- hishel/beta/requests.py +198 -0
- {hishel-0.1.3.dist-info → hishel-0.1.5.dist-info}/METADATA +58 -167
- hishel-0.1.5.dist-info/RECORD +41 -0
- hishel-0.1.3.dist-info/RECORD +0 -27
- {hishel-0.1.3.dist-info → hishel-0.1.5.dist-info}/WHEEL +0 -0
- {hishel-0.1.3.dist-info → hishel-0.1.5.dist-info}/licenses/LICENSE +0 -0
hishel/_utils.py
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import calendar
|
|
2
4
|
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
3
7
|
import time
|
|
4
8
|
import typing as tp
|
|
9
|
+
from datetime import date
|
|
5
10
|
from email.utils import parsedate_tz
|
|
11
|
+
from typing import Any, AsyncIterator, Generator, Iterable, Iterator, TypeVar
|
|
6
12
|
|
|
7
13
|
import anyio
|
|
14
|
+
import anysqlite
|
|
8
15
|
import httpcore
|
|
9
16
|
import httpx
|
|
17
|
+
from anyio import from_thread, to_thread
|
|
10
18
|
|
|
11
19
|
HEADERS_ENCODING = "iso-8859-1"
|
|
12
20
|
|
|
21
|
+
T = tp.TypeVar("T")
|
|
22
|
+
|
|
13
23
|
|
|
14
24
|
class BaseClock:
|
|
15
25
|
def now(self) -> int:
|
|
@@ -116,3 +126,333 @@ def sleep(seconds: tp.Union[int, float]) -> None:
|
|
|
116
126
|
|
|
117
127
|
def float_seconds_to_int_milliseconds(seconds: float) -> int:
|
|
118
128
|
return int(seconds * 1000)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def partition(iterable: tp.Iterable[T], predicate: tp.Callable[[T], bool]) -> tp.Tuple[tp.List[T], tp.List[T]]:
|
|
132
|
+
"""
|
|
133
|
+
Partition an iterable into two lists: one for matching items and one for non-matching items.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
iterable (tp.Iterable[T]): The input iterable to partition.
|
|
137
|
+
predicate (tp.Callable[[T], bool]): A function that evaluates each item in the iterable.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
tp.Tuple[tp.List[T], tp.List[T]]: A tuple containing two lists: the first for matching items,
|
|
141
|
+
and the second for non-matching items.
|
|
142
|
+
Example:
|
|
143
|
+
```
|
|
144
|
+
iterable = [1, 2, 3, 4, 5]
|
|
145
|
+
is_even = lambda x: x % 2 == 0
|
|
146
|
+
evens, odds = partition(iterable, is_even)
|
|
147
|
+
```
|
|
148
|
+
"""
|
|
149
|
+
matching, non_matching = [], []
|
|
150
|
+
for item in iterable:
|
|
151
|
+
if predicate(item):
|
|
152
|
+
matching.append(item)
|
|
153
|
+
else:
|
|
154
|
+
non_matching.append(item)
|
|
155
|
+
return matching, non_matching
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def async_iterator_to_sync(iterator: AsyncIterator[bytes]) -> Iterator[bytes]:
|
|
159
|
+
"""
|
|
160
|
+
Convert an asynchronous byte iterator to a synchronous one.
|
|
161
|
+
This function takes an asynchronous iterator that yields bytes and converts it into
|
|
162
|
+
a synchronous iterator.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
iterator (AsyncIterator[bytes]): The asynchronous byte iterator to be converted.
|
|
166
|
+
Returns:
|
|
167
|
+
Iterator[bytes]: A synchronous iterator that yields the same byte chunks as the input iterator.
|
|
168
|
+
Example:
|
|
169
|
+
```python
|
|
170
|
+
async_iter = some_async_byte_stream()
|
|
171
|
+
sync_iter = async_iterator_to_sync(async_iter)
|
|
172
|
+
for chunk in sync_iter:
|
|
173
|
+
process_bytes(chunk)
|
|
174
|
+
```
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
while True:
|
|
178
|
+
try:
|
|
179
|
+
chunk = from_thread.run(iterator.__anext__)
|
|
180
|
+
except StopAsyncIteration:
|
|
181
|
+
break
|
|
182
|
+
yield chunk
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _call_next(iterator: Iterator[bytes]) -> bytes:
|
|
186
|
+
try:
|
|
187
|
+
return iterator.__next__()
|
|
188
|
+
except StopIteration:
|
|
189
|
+
raise StopAsyncIteration
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def sync_iterator_to_async(iterator: Iterator[bytes]) -> AsyncIterator[bytes]:
|
|
193
|
+
"""
|
|
194
|
+
Converts a synchronous bytes iterator to an asynchronous one.
|
|
195
|
+
This function takes a synchronous iterator that yields bytes and converts it into an
|
|
196
|
+
asynchronous iterator, allowing it to be used in async contexts without blocking.
|
|
197
|
+
Args:
|
|
198
|
+
iterator (Iterator[bytes]): A synchronous iterator yielding bytes objects.
|
|
199
|
+
Returns:
|
|
200
|
+
AsyncIterator[bytes]: An asynchronous iterator yielding the same bytes objects.
|
|
201
|
+
Example:
|
|
202
|
+
```
|
|
203
|
+
sync_iter = iter([b'data1', b'data2'])
|
|
204
|
+
async for chunk in sync_iterator_to_async(sync_iter):
|
|
205
|
+
await process_chunk(chunk)
|
|
206
|
+
```
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
while True:
|
|
210
|
+
try:
|
|
211
|
+
chunk = await to_thread.run_sync(_call_next, iterator)
|
|
212
|
+
except StopAsyncIteration:
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
yield chunk
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def make_async_iterator(iterable: Iterable[bytes]) -> AsyncIterator[bytes]:
|
|
219
|
+
for item in iterable:
|
|
220
|
+
yield item
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def make_sync_iterator(iterable: Iterable[bytes]) -> Iterator[bytes]:
|
|
224
|
+
for item in iterable:
|
|
225
|
+
yield item
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def snake_to_header(text: str) -> str:
|
|
229
|
+
"""
|
|
230
|
+
Convert snake_case string to Header-Case format.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
text: Snake case string (e.g., "hishel_ttl")
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Header case string (e.g., "X-Hishel-Ttl")
|
|
237
|
+
|
|
238
|
+
Examples:
|
|
239
|
+
>>> snake_to_header("hishel_ttl")
|
|
240
|
+
'X-Hishel-Ttl'
|
|
241
|
+
>>> snake_to_header("cache_control")
|
|
242
|
+
'X-Cache-Control'
|
|
243
|
+
>>> snake_to_header("content_type")
|
|
244
|
+
'X-Content-Type'
|
|
245
|
+
"""
|
|
246
|
+
# Split by underscore, capitalize each word, join with dash, add X- prefix
|
|
247
|
+
return "X-" + "-".join(word.capitalize() for word in text.split("_"))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
_T = TypeVar("_T")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class GeneratorWithReturnValue:
|
|
254
|
+
def __init__(
|
|
255
|
+
self, gen: Generator[None, bytes | None, bytes], stream: AsyncIterator[bytes] | Iterator[bytes]
|
|
256
|
+
) -> None:
|
|
257
|
+
self.gen = gen
|
|
258
|
+
self.stream = stream
|
|
259
|
+
self.value: bytes | None = None
|
|
260
|
+
|
|
261
|
+
def __iter__(self) -> Iterator[bytes]:
|
|
262
|
+
return self
|
|
263
|
+
|
|
264
|
+
def __next__(self) -> bytes:
|
|
265
|
+
assert isinstance(self.stream, Iterator)
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
chunk = next(self.stream)
|
|
269
|
+
self.gen.send(chunk)
|
|
270
|
+
except StopIteration as exc:
|
|
271
|
+
self.gen.send(None)
|
|
272
|
+
self.value = exc.value
|
|
273
|
+
raise
|
|
274
|
+
return chunk
|
|
275
|
+
|
|
276
|
+
def __aiter__(self) -> AsyncIterator[bytes]:
|
|
277
|
+
return self
|
|
278
|
+
|
|
279
|
+
async def __anext__(self) -> bytes:
|
|
280
|
+
assert isinstance(self.stream, AsyncIterator)
|
|
281
|
+
try:
|
|
282
|
+
chunk = await self.stream.__anext__()
|
|
283
|
+
self.gen.send(chunk)
|
|
284
|
+
except StopIteration as exc:
|
|
285
|
+
self.gen.send(None)
|
|
286
|
+
self.value = exc.value
|
|
287
|
+
raise
|
|
288
|
+
return chunk
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def print_sqlite_state(conn: sqlite3.Connection) -> str:
|
|
292
|
+
"""
|
|
293
|
+
Print all tables and their rows in a pretty format suitable for inline snapshots.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
conn: SQLite database connection
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
Formatted string representation of the database state
|
|
300
|
+
"""
|
|
301
|
+
cursor = conn.cursor()
|
|
302
|
+
|
|
303
|
+
# Get all table names
|
|
304
|
+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
|
305
|
+
tables = [row[0] for row in cursor.fetchall()]
|
|
306
|
+
|
|
307
|
+
output_lines = []
|
|
308
|
+
output_lines.append("=" * 80)
|
|
309
|
+
output_lines.append("DATABASE SNAPSHOT")
|
|
310
|
+
output_lines.append("=" * 80)
|
|
311
|
+
|
|
312
|
+
for table_name in tables:
|
|
313
|
+
# Get column information
|
|
314
|
+
cursor.execute(f"PRAGMA table_info({table_name})")
|
|
315
|
+
columns = cursor.fetchall()
|
|
316
|
+
column_names = [col[1] for col in columns]
|
|
317
|
+
column_types = {col[1]: col[2] for col in columns}
|
|
318
|
+
|
|
319
|
+
# Get all rows
|
|
320
|
+
cursor.execute(f"SELECT * FROM {table_name}")
|
|
321
|
+
rows = cursor.fetchall()
|
|
322
|
+
|
|
323
|
+
output_lines.append("")
|
|
324
|
+
output_lines.append(f"TABLE: {table_name}")
|
|
325
|
+
output_lines.append("-" * 80)
|
|
326
|
+
output_lines.append(f"Rows: {len(rows)}")
|
|
327
|
+
output_lines.append("")
|
|
328
|
+
|
|
329
|
+
if not rows:
|
|
330
|
+
output_lines.append(" (empty)")
|
|
331
|
+
continue
|
|
332
|
+
|
|
333
|
+
# Format each row
|
|
334
|
+
for idx, row in enumerate(rows, 1):
|
|
335
|
+
output_lines.append(f" Row {idx}:")
|
|
336
|
+
|
|
337
|
+
for col_name, value in zip(column_names, row):
|
|
338
|
+
col_type = column_types[col_name]
|
|
339
|
+
formatted_value = format_value(value, col_name, col_type)
|
|
340
|
+
output_lines.append(f" {col_name:15} = {formatted_value}")
|
|
341
|
+
|
|
342
|
+
if idx < len(rows):
|
|
343
|
+
output_lines.append("")
|
|
344
|
+
|
|
345
|
+
output_lines.append("")
|
|
346
|
+
output_lines.append("=" * 80)
|
|
347
|
+
|
|
348
|
+
result = "\n".join(output_lines)
|
|
349
|
+
return result
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
async def aprint_sqlite_state(conn: anysqlite.Connection) -> str:
|
|
353
|
+
"""
|
|
354
|
+
Print all tables and their rows in a pretty format suitable for inline snapshots.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
conn: SQLite database connection
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
Formatted string representation of the database state
|
|
361
|
+
"""
|
|
362
|
+
cursor = await conn.cursor()
|
|
363
|
+
|
|
364
|
+
# Get all table names
|
|
365
|
+
await cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
|
366
|
+
tables = [row[0] for row in await cursor.fetchall()]
|
|
367
|
+
|
|
368
|
+
output_lines = []
|
|
369
|
+
output_lines.append("=" * 80)
|
|
370
|
+
output_lines.append("DATABASE SNAPSHOT")
|
|
371
|
+
output_lines.append("=" * 80)
|
|
372
|
+
|
|
373
|
+
for table_name in tables:
|
|
374
|
+
# Get column information
|
|
375
|
+
await cursor.execute(f"PRAGMA table_info({table_name})")
|
|
376
|
+
columns = await cursor.fetchall()
|
|
377
|
+
column_names = [col[1] for col in columns]
|
|
378
|
+
column_types = {col[1]: col[2] for col in columns}
|
|
379
|
+
|
|
380
|
+
# Get all rows
|
|
381
|
+
await cursor.execute(f"SELECT * FROM {table_name}")
|
|
382
|
+
rows = await cursor.fetchall()
|
|
383
|
+
|
|
384
|
+
output_lines.append("")
|
|
385
|
+
output_lines.append(f"TABLE: {table_name}")
|
|
386
|
+
output_lines.append("-" * 80)
|
|
387
|
+
output_lines.append(f"Rows: {len(rows)}")
|
|
388
|
+
output_lines.append("")
|
|
389
|
+
|
|
390
|
+
if not rows:
|
|
391
|
+
output_lines.append(" (empty)")
|
|
392
|
+
continue
|
|
393
|
+
|
|
394
|
+
# Format each row
|
|
395
|
+
for idx, row in enumerate(rows, 1):
|
|
396
|
+
output_lines.append(f" Row {idx}:")
|
|
397
|
+
|
|
398
|
+
for col_name, value in zip(column_names, row):
|
|
399
|
+
col_type = column_types[col_name]
|
|
400
|
+
formatted_value = format_value(value, col_name, col_type)
|
|
401
|
+
output_lines.append(f" {col_name:15} = {formatted_value}")
|
|
402
|
+
|
|
403
|
+
if idx < len(rows):
|
|
404
|
+
output_lines.append("")
|
|
405
|
+
|
|
406
|
+
output_lines.append("")
|
|
407
|
+
output_lines.append("=" * 80)
|
|
408
|
+
|
|
409
|
+
result = "\n".join(output_lines)
|
|
410
|
+
return result
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def format_value(value: Any, col_name: str, col_type: str) -> str:
|
|
414
|
+
"""Format a value for display based on its type and column name."""
|
|
415
|
+
|
|
416
|
+
if value is None:
|
|
417
|
+
return "NULL"
|
|
418
|
+
|
|
419
|
+
# Handle BLOB columns
|
|
420
|
+
if col_type.upper() == "BLOB":
|
|
421
|
+
if isinstance(value, bytes):
|
|
422
|
+
# Try to decode as UTF-8 string first
|
|
423
|
+
try:
|
|
424
|
+
decoded = value.decode("utf-8")
|
|
425
|
+
# Check if it looks like JSON
|
|
426
|
+
if decoded.strip().startswith("{") or decoded.strip().startswith("["):
|
|
427
|
+
try:
|
|
428
|
+
parsed = json.loads(decoded)
|
|
429
|
+
return f"(JSON) {json.dumps(parsed, indent=2)}"
|
|
430
|
+
except json.JSONDecodeError:
|
|
431
|
+
pass
|
|
432
|
+
# Show string if it's printable
|
|
433
|
+
if all(32 <= ord(c) <= 126 or c in "\n\r\t" for c in decoded):
|
|
434
|
+
return f"(str) '{decoded}'"
|
|
435
|
+
except UnicodeDecodeError:
|
|
436
|
+
pass
|
|
437
|
+
|
|
438
|
+
# Show hex representation for binary data
|
|
439
|
+
hex_str = value.hex()
|
|
440
|
+
if len(hex_str) > 64:
|
|
441
|
+
return f"(bytes) 0x{hex_str[:60]}... ({len(value)} bytes)"
|
|
442
|
+
return f"(bytes) 0x{hex_str} ({len(value)} bytes)"
|
|
443
|
+
return repr(value)
|
|
444
|
+
|
|
445
|
+
# Handle timestamps - ONLY show date, not the raw timestamp
|
|
446
|
+
if col_name.endswith("_at") and isinstance(value, (int, float)):
|
|
447
|
+
try:
|
|
448
|
+
dt = date.fromtimestamp(value)
|
|
449
|
+
return dt.isoformat() # Changed: removed the timestamp prefix
|
|
450
|
+
except (ValueError, OSError):
|
|
451
|
+
return str(value)
|
|
452
|
+
|
|
453
|
+
# Handle TEXT columns
|
|
454
|
+
if col_type.upper() == "TEXT":
|
|
455
|
+
return f"'{value}'"
|
|
456
|
+
|
|
457
|
+
# Handle other types
|
|
458
|
+
return str(value)
|
hishel/beta/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from hishel.beta._core._async._storages._sqlite import AsyncSqliteStorage
|
|
2
|
+
from hishel.beta._core._base._storages._base import (
|
|
3
|
+
AsyncBaseStorage as AsyncBaseStorage,
|
|
4
|
+
SyncBaseStorage as SyncBaseStorage,
|
|
5
|
+
)
|
|
6
|
+
from hishel.beta._core._headers import Headers as Headers
|
|
7
|
+
from hishel.beta._core._spec import (
|
|
8
|
+
AnyState as AnyState,
|
|
9
|
+
CacheMiss as CacheMiss,
|
|
10
|
+
CacheOptions as CacheOptions,
|
|
11
|
+
CouldNotBeStored as CouldNotBeStored,
|
|
12
|
+
FromCache as FromCache,
|
|
13
|
+
IdleClient as IdleClient,
|
|
14
|
+
NeedRevalidation as NeedRevalidation,
|
|
15
|
+
NeedToBeUpdated as NeedToBeUpdated,
|
|
16
|
+
State as State,
|
|
17
|
+
StoreAndUse as StoreAndUse,
|
|
18
|
+
create_idle_state as create_idle_state,
|
|
19
|
+
)
|
|
20
|
+
from hishel.beta._core._sync._storages._sqlite import SyncSqliteStorage
|
|
21
|
+
from hishel.beta._core.models import (
|
|
22
|
+
CompletePair as CompletePair,
|
|
23
|
+
IncompletePair as IncompletePair,
|
|
24
|
+
Pair as Pair,
|
|
25
|
+
PairMeta as PairMeta,
|
|
26
|
+
Request as Request,
|
|
27
|
+
Response,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = (
|
|
31
|
+
# New API
|
|
32
|
+
## States
|
|
33
|
+
"AnyState",
|
|
34
|
+
"IdleClient",
|
|
35
|
+
"CacheMiss",
|
|
36
|
+
"FromCache",
|
|
37
|
+
"NeedRevalidation",
|
|
38
|
+
"AnyState",
|
|
39
|
+
"CacheOptions",
|
|
40
|
+
"NeedToBeUpdated",
|
|
41
|
+
"State",
|
|
42
|
+
"StoreAndUse",
|
|
43
|
+
"CouldNotBeStored",
|
|
44
|
+
"create_idle_state",
|
|
45
|
+
## Models
|
|
46
|
+
"Request",
|
|
47
|
+
"Response",
|
|
48
|
+
"Pair",
|
|
49
|
+
"IncompletePair",
|
|
50
|
+
"CompletePair",
|
|
51
|
+
"PairMeta",
|
|
52
|
+
## Headers
|
|
53
|
+
"Headers",
|
|
54
|
+
## Storages
|
|
55
|
+
"SyncBaseStorage",
|
|
56
|
+
"AsyncBaseStorage",
|
|
57
|
+
"SyncSqliteStorage",
|
|
58
|
+
"AsyncSqliteStorage",
|
|
59
|
+
)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from typing import AsyncIterator, Awaitable, Callable
|
|
8
|
+
|
|
9
|
+
from typing_extensions import assert_never
|
|
10
|
+
|
|
11
|
+
from hishel.beta import (
|
|
12
|
+
AnyState,
|
|
13
|
+
AsyncBaseStorage,
|
|
14
|
+
AsyncSqliteStorage,
|
|
15
|
+
CacheMiss,
|
|
16
|
+
CacheOptions,
|
|
17
|
+
CouldNotBeStored,
|
|
18
|
+
FromCache,
|
|
19
|
+
IdleClient,
|
|
20
|
+
NeedRevalidation,
|
|
21
|
+
NeedToBeUpdated,
|
|
22
|
+
Request,
|
|
23
|
+
Response,
|
|
24
|
+
StoreAndUse,
|
|
25
|
+
create_idle_state,
|
|
26
|
+
)
|
|
27
|
+
from hishel.beta._core._spec import InvalidatePairs, vary_headers_match
|
|
28
|
+
from hishel.beta._core.models import CompletePair
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("hishel.integrations.clients")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AsyncCacheProxy:
|
|
34
|
+
"""
|
|
35
|
+
A proxy for HTTP caching in clients.
|
|
36
|
+
|
|
37
|
+
This class is independent of any specific HTTP library and works only with internal models.
|
|
38
|
+
It delegates request execution to a user-provided callable, making it compatible with any
|
|
39
|
+
HTTP client. Caching behavior can be configured to either fully respect HTTP
|
|
40
|
+
caching rules or bypass them entirely.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
send_request: Callable[[Request], Awaitable[Response]],
|
|
46
|
+
storage: AsyncBaseStorage | None = None,
|
|
47
|
+
cache_options: CacheOptions | None = None,
|
|
48
|
+
ignore_specification: bool = False,
|
|
49
|
+
) -> None:
|
|
50
|
+
self.send_request = send_request
|
|
51
|
+
self.storage = storage if storage is not None else AsyncSqliteStorage()
|
|
52
|
+
self.cache_options = cache_options if cache_options is not None else CacheOptions()
|
|
53
|
+
self.ignore_specification = ignore_specification
|
|
54
|
+
|
|
55
|
+
async def handle_request(self, request: Request) -> Response:
|
|
56
|
+
if self.ignore_specification or request.metadata.get("hishel_spec_ignore"):
|
|
57
|
+
return await self._handle_request_ignoring_spec(request)
|
|
58
|
+
return await self._handle_request_respecting_spec(request)
|
|
59
|
+
|
|
60
|
+
async def _get_key_for_request(self, request: Request) -> str:
|
|
61
|
+
if request.metadata.get("hishel_body_key"):
|
|
62
|
+
assert isinstance(request.stream, AsyncIterator)
|
|
63
|
+
collected = b"".join([chunk async for chunk in request.stream])
|
|
64
|
+
hash_ = hashlib.sha256(collected).hexdigest()
|
|
65
|
+
return f"{str(request.url)}-{hash_}"
|
|
66
|
+
return str(request.url)
|
|
67
|
+
|
|
68
|
+
async def _maybe_refresh_pair_ttl(self, pair: CompletePair) -> None:
|
|
69
|
+
if pair.request.metadata.get("hishel_refresh_ttl_on_access"):
|
|
70
|
+
await self.storage.update_pair(
|
|
71
|
+
pair.id,
|
|
72
|
+
lambda complete_pair: replace(complete_pair, meta=replace(complete_pair.meta, created_at=time.time())),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def _handle_request_ignoring_spec(self, request: Request) -> Response:
|
|
76
|
+
logger.debug("Trying to get cached response ignoring specification")
|
|
77
|
+
pairs = await self.storage.get_pairs(await self._get_key_for_request(request))
|
|
78
|
+
|
|
79
|
+
logger.debug(f"Found {len(pairs)} cached pairs for the request")
|
|
80
|
+
|
|
81
|
+
for pair in pairs:
|
|
82
|
+
if (
|
|
83
|
+
str(pair.request.url) == str(request.url)
|
|
84
|
+
and pair.request.method == request.method
|
|
85
|
+
and vary_headers_match(
|
|
86
|
+
request,
|
|
87
|
+
pair,
|
|
88
|
+
)
|
|
89
|
+
):
|
|
90
|
+
logger.debug(
|
|
91
|
+
"Found matching cached response for the request",
|
|
92
|
+
)
|
|
93
|
+
pair.response.metadata["hishel_from_cache"] = True # type: ignore
|
|
94
|
+
await self._maybe_refresh_pair_ttl(pair)
|
|
95
|
+
return pair.response
|
|
96
|
+
|
|
97
|
+
incomplete_pair = await self.storage.create_pair(
|
|
98
|
+
request,
|
|
99
|
+
)
|
|
100
|
+
response = await self.send_request(incomplete_pair.request)
|
|
101
|
+
|
|
102
|
+
logger.debug("Storing response in cache ignoring specification")
|
|
103
|
+
complete_pair = await self.storage.add_response(
|
|
104
|
+
incomplete_pair.id, response, await self._get_key_for_request(request)
|
|
105
|
+
)
|
|
106
|
+
return complete_pair.response
|
|
107
|
+
|
|
108
|
+
async def _handle_request_respecting_spec(self, request: Request) -> Response:
|
|
109
|
+
state: AnyState = create_idle_state("client", self.cache_options)
|
|
110
|
+
|
|
111
|
+
while state:
|
|
112
|
+
logger.debug(f"Handling state: {state.__class__.__name__}")
|
|
113
|
+
if isinstance(state, IdleClient):
|
|
114
|
+
state = await self._handle_idle_state(state, request)
|
|
115
|
+
elif isinstance(state, CacheMiss):
|
|
116
|
+
state = await self._handle_cache_miss(state)
|
|
117
|
+
elif isinstance(state, StoreAndUse):
|
|
118
|
+
return await self._handle_store_and_use(state, request)
|
|
119
|
+
elif isinstance(state, CouldNotBeStored):
|
|
120
|
+
return state.response
|
|
121
|
+
elif isinstance(state, NeedRevalidation):
|
|
122
|
+
state = await self._handle_revalidation(state)
|
|
123
|
+
elif isinstance(state, FromCache):
|
|
124
|
+
await self._maybe_refresh_pair_ttl(state.pair)
|
|
125
|
+
return state.pair.response
|
|
126
|
+
elif isinstance(state, NeedToBeUpdated):
|
|
127
|
+
state = await self._handle_update(state)
|
|
128
|
+
elif isinstance(state, InvalidatePairs):
|
|
129
|
+
state = await self._handle_invalidate_pairs(state)
|
|
130
|
+
else:
|
|
131
|
+
assert_never(state)
|
|
132
|
+
|
|
133
|
+
raise RuntimeError("Unreachable")
|
|
134
|
+
|
|
135
|
+
async def _handle_idle_state(self, state: IdleClient, request: Request) -> AnyState:
|
|
136
|
+
stored_pairs = await self.storage.get_pairs(await self._get_key_for_request(request))
|
|
137
|
+
return state.next(request, stored_pairs)
|
|
138
|
+
|
|
139
|
+
async def _handle_cache_miss(self, state: CacheMiss) -> AnyState:
|
|
140
|
+
incomplete_pair = await self.storage.create_pair(state.request)
|
|
141
|
+
response = await self.send_request(incomplete_pair.request)
|
|
142
|
+
return state.next(response, incomplete_pair.id)
|
|
143
|
+
|
|
144
|
+
async def _handle_store_and_use(self, state: StoreAndUse, request: Request) -> Response:
|
|
145
|
+
complete_pair = await self.storage.add_response(
|
|
146
|
+
state.pair_id, state.response, await self._get_key_for_request(request)
|
|
147
|
+
)
|
|
148
|
+
return complete_pair.response
|
|
149
|
+
|
|
150
|
+
async def _handle_revalidation(self, state: NeedRevalidation) -> AnyState:
|
|
151
|
+
revalidation_response = await self.send_request(state.request)
|
|
152
|
+
return state.next(revalidation_response)
|
|
153
|
+
|
|
154
|
+
async def _handle_update(self, state: NeedToBeUpdated) -> AnyState:
|
|
155
|
+
for pair in state.updating_pairs:
|
|
156
|
+
await self.storage.update_pair(
|
|
157
|
+
pair.id,
|
|
158
|
+
lambda complete_pair: replace(
|
|
159
|
+
complete_pair, response=replace(pair.response, headers=pair.response.headers)
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
return state.next()
|
|
163
|
+
|
|
164
|
+
async def _handle_invalidate_pairs(self, state: InvalidatePairs) -> AnyState:
|
|
165
|
+
for pair_id in state.pair_ids:
|
|
166
|
+
await self.storage.remove(pair_id)
|
|
167
|
+
return state.next()
|
|
File without changes
|