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/_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)
@@ -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