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.
@@ -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._base._storages._base import SyncBaseStorage, ensure_cache_dict
18
- from hishel._core._base._storages._packing import pack, unpack
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
- CompletePair,
21
- IncompletePair,
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
- base_path = ensure_cache_dict()
50
+ db_path = Path(database_path)
53
51
 
54
52
  self.connection = connection
55
- self.database_path = base_path / 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, kind, chunk_number),
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 create_pair(
109
- self,
110
- request: Request,
111
- id: uuid.UUID | None = None,
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
- assert isinstance(request.stream, Iterable), "Request stream must be an Iterable, not Iterable"
131
-
132
- request = Request(
133
- method=request.method,
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
- response = replace(response, stream=self._save_stream(response.stream, pair_id.bytes, "response"))
120
+ response_with_stream = replace(
121
+ response,
122
+ stream=self._save_stream(response.stream, pair_id.bytes),
123
+ )
165
124
 
166
- self._delete_stream(pair.id.bytes, cursor, type="response")
167
- complete_pair = CompletePair(
168
- id=pair.id, request=pair.request, response=response, meta=pair.meta, cache_key=key
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
- # Update the entry with the complete pair and set cache_key
133
+ # Insert the complete entry into the database
172
134
  cursor.execute(
173
- "UPDATE entries SET data = ?, cache_key = ? WHERE id = ?",
174
- (pack(complete_pair, kind="pair"), key, pair_id.bytes),
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 complete_pair
140
+ return complete_entry
179
141
 
180
- def get_pairs(self, key: str) -> List[CompletePair]:
181
- final_pairs: List[CompletePair] = []
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("SELECT id, data FROM entries WHERE cache_key = ?", (key.encode("utf-8"),))
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
- if isinstance(pair_data, IncompletePair):
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[CompletePair] = []
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, "response"),
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 update_pair(
185
+ def update_entry(
223
186
  self,
224
187
  id: uuid.UUID,
225
- new_pair: Union[CompletePair, Callable[[CompletePair], CompletePair]],
226
- ) -> Optional[CompletePair]:
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
- if isinstance(pair, IncompletePair):
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, CompletePair):
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 = ?", (pack(complete_pair, kind="pair"), id.bytes)
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 remove(self, id: uuid.UUID) -> None:
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
- self, kind: Literal["request", "response"], pair_id: uuid.UUID, cursor: sqlite3.Cursor
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 kind = ? AND chunk_number = ? LIMIT 1",
282
- (pair_id.bytes, kind_id, self._COMPLETE_CHUNK_NUMBER),
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(self, pair: Union[CompletePair, IncompletePair], cursor: sqlite3.Cursor) -> None:
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
- (pack(marked_pair, kind="pair"), marked_pair.meta.deleted_at, pair.id.bytes),
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: Pair, cursor: sqlite3.Cursor) -> bool:
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 pairs in the database.
280
+ Cleanup expired entries in the database.
311
281
  """
312
- should_mark_as_deleted: List[Union[CompletePair, IncompletePair]] = []
313
- should_hard_delete: List[Union[CompletePair, IncompletePair]] = []
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("SELECT id, data FROM entries LIMIT ? OFFSET ?", (chunk_size, offset))
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: IncompletePair | CompletePair, cursor: sqlite3.Cursor) -> bool:
354
- # if pair was created more than 1 hour ago and still not completed
355
- if pair.meta.created_at + 3600 < time.time() and isinstance(pair, IncompletePair):
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 isinstance(pair, CompletePair) and not self._is_stream_complete("request", pair.id, cursor):
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: CompletePair | IncompletePair, cursor: sqlite3.Cursor) -> None:
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 all streams (both request and response) for this entry
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 all streams (both request and response) associated with the given entry ID.
355
+ Delete response stream associated with the given entry ID.
379
356
  """
380
- if type == "request":
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, kind, chunk_number, chunk_data) VALUES (?, ?, ?, ?)",
407
- (entry_id, kind_id, chunk_number, chunk),
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, kind, chunk_number, chunk_data) VALUES (?, ?, ?, ?)",
418
- (entry_id, kind_id, self._COMPLETE_CHUNK_NUMBER, b""),
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 kind = ? AND chunk_number = ?",
438
- (entry_id, kind_id, chunk_number),
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(SyncBaseStorage): # type: ignore[no-redef]
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(headers: Mapping[str, str]) -> RequestMetadata:
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 iter_stream(self) -> Iterator[bytes]:
98
- if isinstance(self.stream, Iterator):
99
- return self.stream
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 aiter_stream(self) -> AsyncIterator[bytes]:
103
- if isinstance(self.stream, AsyncIterator):
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 iter_stream(self) -> Iterator[bytes]:
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
- return self.stream
181
+ yield from self.stream
182
+ return
138
183
  raise TypeError("Response stream is not an Iterator")
139
184
 
140
- async def aiter_stream(self) -> AsyncIterator[bytes]:
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 PairMeta:
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 Pair:
233
+ class Entry:
156
234
  id: uuid.UUID
157
235
  request: Request
158
- meta: PairMeta
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"")