hishel 1.0.0.dev3__py3-none-any.whl → 1.1.1__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 CHANGED
@@ -15,17 +15,20 @@ from hishel._core._spec import (
15
15
  NeedToBeUpdated as NeedToBeUpdated,
16
16
  State as State,
17
17
  StoreAndUse as StoreAndUse,
18
- create_idle_state as create_idle_state,
19
18
  )
20
19
  from hishel._core.models import (
21
20
  Entry as Entry,
22
21
  EntryMeta as EntryMeta,
23
22
  Request as Request,
24
- Response,
23
+ Response as Response,
24
+ ResponseMetadata as ResponseMetadata,
25
+ RequestMetadata as RequestMetadata,
25
26
  )
26
27
  from hishel._async_cache import AsyncCacheProxy as AsyncCacheProxy
27
28
  from hishel._sync_cache import SyncCacheProxy as SyncCacheProxy
28
29
 
30
+ from hishel._policies import SpecificationPolicy, FilterPolicy, CachePolicy
31
+
29
32
  __all__ = (
30
33
  # New API
31
34
  ## States
@@ -41,12 +44,13 @@ __all__ = (
41
44
  "StoreAndUse",
42
45
  "CouldNotBeStored",
43
46
  "InvalidateEntries",
44
- "create_idle_state",
45
47
  ## Models
46
48
  "Request",
47
49
  "Response",
48
50
  "Entry",
49
51
  "EntryMeta",
52
+ "RequestMetadata",
53
+ "ResponseMetadata",
50
54
  ## Headers
51
55
  "Headers",
52
56
  ## Storages
@@ -57,4 +61,8 @@ __all__ = (
57
61
  # Proxy
58
62
  "AsyncCacheProxy",
59
63
  "SyncCacheProxy",
64
+ # Policies
65
+ "CachePolicy",
66
+ "SpecificationPolicy",
67
+ "FilterPolicy",
60
68
  )
hishel/_async_cache.py CHANGED
@@ -13,7 +13,6 @@ from hishel import (
13
13
  AsyncBaseStorage,
14
14
  AsyncSqliteStorage,
15
15
  CacheMiss,
16
- CacheOptions,
17
16
  CouldNotBeStored,
18
17
  FromCache,
19
18
  IdleClient,
@@ -22,10 +21,10 @@ from hishel import (
22
21
  Request,
23
22
  Response,
24
23
  StoreAndUse,
25
- create_idle_state,
26
24
  )
27
25
  from hishel._core._spec import InvalidateEntries, vary_headers_match
28
26
  from hishel._core.models import Entry, ResponseMetadata
27
+ from hishel._policies import CachePolicy, FilterPolicy, SpecificationPolicy
29
28
  from hishel._utils import make_async_iterator
30
29
 
31
30
  logger = logging.getLogger("hishel.integrations.clients")
@@ -37,87 +36,121 @@ class AsyncCacheProxy:
37
36
 
38
37
  This class is independent of any specific HTTP library and works only with internal models.
39
38
  It delegates request execution to a user-provided callable, making it compatible with any
40
- HTTP client. Caching behavior can be configured to either fully respect HTTP
41
- caching rules or bypass them entirely.
39
+ HTTP client. Caching behavior is determined by the policy object.
40
+
41
+ Args:
42
+ request_sender: Callable that sends HTTP requests and returns responses.
43
+ storage: Storage backend for cache entries. Defaults to AsyncSqliteStorage.
44
+ policy: Caching policy to use. Can be SpecificationPolicy (respects RFC 9111) or
45
+ FilterPolicy (user-defined filtering). Defaults to SpecificationPolicy().
42
46
  """
43
47
 
44
48
  def __init__(
45
49
  self,
46
50
  request_sender: Callable[[Request], Awaitable[Response]],
47
51
  storage: AsyncBaseStorage | None = None,
48
- cache_options: CacheOptions | None = None,
49
- ignore_specification: bool = False,
52
+ policy: CachePolicy | None = None,
50
53
  ) -> None:
51
54
  self.send_request = request_sender
52
55
  self.storage = storage if storage is not None else AsyncSqliteStorage()
53
- self.cache_options = cache_options if cache_options is not None else CacheOptions()
54
- self.ignore_specification = ignore_specification
56
+ self.policy = policy if policy is not None else SpecificationPolicy()
55
57
 
56
58
  async def handle_request(self, request: Request) -> Response:
57
- if self.ignore_specification or request.metadata.get("hishel_spec_ignore"):
58
- return await self._handle_request_ignoring_spec(request)
59
+ if isinstance(self.policy, FilterPolicy):
60
+ return await self._handle_request_with_filters(request)
59
61
  return await self._handle_request_respecting_spec(request)
60
62
 
61
63
  async def _get_key_for_request(self, request: Request) -> str:
62
- if request.metadata.get("hishel_body_key"):
64
+ if self.policy.use_body_key or request.metadata.get("hishel_body_key"):
63
65
  assert isinstance(request.stream, (AsyncIterator, AsyncIterable))
64
66
  collected = b"".join([chunk async for chunk in request.stream])
65
67
  hash_ = hashlib.sha256(collected).hexdigest()
66
68
  request.stream = make_async_iterator([collected])
67
- return f"{str(request.url)}-{hash_}"
69
+ return hash_
68
70
  return hashlib.sha256(str(request.url).encode("utf-8")).hexdigest()
69
71
 
70
- async def _maybe_refresh_pair_ttl(self, pair: Entry) -> None:
71
- if pair.request.metadata.get("hishel_refresh_ttl_on_access"):
72
+ async def _maybe_refresh_entry_ttl(self, entry: Entry) -> None:
73
+ if entry.request.metadata.get("hishel_refresh_ttl_on_access"):
72
74
  await self.storage.update_entry(
73
- pair.id,
74
- lambda complete_pair: replace(
75
- complete_pair,
76
- meta=replace(complete_pair.meta, created_at=time.time()),
75
+ entry.id,
76
+ lambda current_entry: replace(
77
+ current_entry,
78
+ meta=replace(current_entry.meta, created_at=time.time()),
77
79
  ),
78
80
  )
79
81
 
80
- async def _handle_request_ignoring_spec(self, request: Request) -> Response:
82
+ async def _handle_request_with_filters(self, request: Request) -> Response:
83
+ assert isinstance(self.policy, FilterPolicy)
84
+
85
+ for request_filter in self.policy.request_filters:
86
+ if request_filter.needs_body():
87
+ body = await request.aread()
88
+ if not request_filter.apply(request, body):
89
+ logger.debug("Request filtered out by request filter")
90
+ return await self.send_request(request)
91
+ else:
92
+ if not request_filter.apply(request, None):
93
+ logger.debug("Request filtered out by request filter")
94
+ return await self.send_request(request)
95
+
81
96
  logger.debug("Trying to get cached response ignoring specification")
82
- entries = await self.storage.get_entries(await self._get_key_for_request(request))
97
+ cache_key = await self._get_key_for_request(request)
98
+ entries = await self.storage.get_entries(cache_key)
83
99
 
84
100
  logger.debug(f"Found {len(entries)} cached entries for the request")
85
101
 
86
- for pair in entries:
102
+ for entry in entries:
87
103
  if (
88
- str(pair.request.url) == str(request.url)
89
- and pair.request.method == request.method
104
+ str(entry.request.url) == str(request.url)
105
+ and entry.request.method == request.method
90
106
  and vary_headers_match(
91
107
  request,
92
- pair,
108
+ entry,
93
109
  )
94
110
  ):
95
111
  logger.debug(
96
112
  "Found matching cached response for the request",
97
113
  )
98
114
  response_meta = ResponseMetadata(
99
- hishel_spec_ignored=True,
100
115
  hishel_from_cache=True,
101
- hishel_created_at=pair.meta.created_at,
116
+ hishel_created_at=entry.meta.created_at,
102
117
  hishel_revalidated=False,
103
118
  hishel_stored=False,
104
119
  )
105
- pair.response.metadata.update(response_meta) # type: ignore
106
- await self._maybe_refresh_pair_ttl(pair)
107
- return pair.response
120
+ entry.response.metadata.update(response_meta) # type: ignore
121
+ await self._maybe_refresh_entry_ttl(entry)
122
+ return entry.response
108
123
 
109
124
  response = await self.send_request(request)
125
+ for response_filter in self.policy.response_filters:
126
+ if response_filter.needs_body():
127
+ body = await response.aread()
128
+ if not response_filter.apply(response, body):
129
+ logger.debug("Response filtered out by response filter")
130
+ return response
131
+ else:
132
+ if not response_filter.apply(response, None):
133
+ logger.debug("Response filtered out by response filter")
134
+ return response
135
+ response_meta = ResponseMetadata(
136
+ hishel_from_cache=False,
137
+ hishel_created_at=time.time(),
138
+ hishel_revalidated=False,
139
+ hishel_stored=True,
140
+ )
141
+ response.metadata.update(response_meta) # type: ignore
110
142
 
111
143
  logger.debug("Storing response in cache ignoring specification")
112
144
  entry = await self.storage.create_entry(
113
145
  request,
114
146
  response,
115
- await self._get_key_for_request(request),
147
+ cache_key,
116
148
  )
117
149
  return entry.response
118
150
 
119
151
  async def _handle_request_respecting_spec(self, request: Request) -> Response:
120
- state: AnyState = create_idle_state("client", self.cache_options)
152
+ assert isinstance(self.policy, SpecificationPolicy)
153
+ state: AnyState = IdleClient(options=self.policy.cache_options)
121
154
 
122
155
  while state:
123
156
  logger.debug(f"Handling state: {state.__class__.__name__}")
@@ -132,8 +165,8 @@ class AsyncCacheProxy:
132
165
  elif isinstance(state, NeedRevalidation):
133
166
  state = await self._handle_revalidation(state)
134
167
  elif isinstance(state, FromCache):
135
- await self._maybe_refresh_pair_ttl(state.pair)
136
- return state.pair.response
168
+ await self._maybe_refresh_entry_ttl(state.entry)
169
+ return state.entry.response
137
170
  elif isinstance(state, NeedToBeUpdated):
138
171
  state = await self._handle_update(state)
139
172
  elif isinstance(state, InvalidateEntries):
@@ -152,12 +185,12 @@ class AsyncCacheProxy:
152
185
  return state.next(response)
153
186
 
154
187
  async def _handle_store_and_use(self, state: StoreAndUse, request: Request) -> Response:
155
- complete_pair = await self.storage.create_entry(
188
+ entry = await self.storage.create_entry(
156
189
  request,
157
190
  state.response,
158
191
  await self._get_key_for_request(request),
159
192
  )
160
- return complete_pair.response
193
+ return entry.response
161
194
 
162
195
  async def _handle_revalidation(self, state: NeedRevalidation) -> AnyState:
163
196
  revalidation_response = await self.send_request(state.request)
@@ -167,8 +200,8 @@ class AsyncCacheProxy:
167
200
  for entry in state.updating_entries:
168
201
  await self.storage.update_entry(
169
202
  entry.id,
170
- lambda complete_pair: replace(
171
- complete_pair,
203
+ lambda entry: replace(
204
+ entry,
172
205
  response=replace(entry.response, headers=entry.response.headers),
173
206
  ),
174
207
  )
hishel/_async_httpx.py CHANGED
@@ -14,11 +14,9 @@ from typing import (
14
14
  from httpx import RequestNotRead
15
15
 
16
16
  from hishel import AsyncCacheProxy, Headers, Request, Response
17
- from hishel._core._spec import (
18
- CacheOptions,
19
- )
20
17
  from hishel._core._storages._async_base import AsyncBaseStorage
21
18
  from hishel._core.models import RequestMetadata, extract_metadata_from_headers
19
+ from hishel._policies import CachePolicy
22
20
  from hishel._utils import (
23
21
  filter_mapping,
24
22
  make_async_iterator,
@@ -62,14 +60,14 @@ def _internal_to_httpx(
62
60
  method=value.method,
63
61
  url=value.url,
64
62
  headers=value.headers,
65
- stream=_IteratorStream(value.aiter_stream()),
63
+ stream=_IteratorStream(value._aiter_stream()),
66
64
  extensions=value.metadata,
67
65
  )
68
66
  elif isinstance(value, Response):
69
67
  return httpx.Response(
70
68
  status_code=value.status_code,
71
69
  headers=value.headers,
72
- stream=_IteratorStream(value.aiter_stream()),
70
+ stream=_IteratorStream(value._aiter_stream()),
73
71
  extensions=value.metadata,
74
72
  )
75
73
 
@@ -149,15 +147,13 @@ class AsyncCacheTransport(httpx.AsyncBaseTransport):
149
147
  self,
150
148
  next_transport: httpx.AsyncBaseTransport,
151
149
  storage: AsyncBaseStorage | None = None,
152
- cache_options: CacheOptions | None = None,
153
- ignore_specification: bool = False,
150
+ policy: CachePolicy | None = None,
154
151
  ) -> None:
155
152
  self.next_transport = next_transport
156
153
  self._cache_proxy: AsyncCacheProxy = AsyncCacheProxy(
157
154
  request_sender=self.request_sender,
158
155
  storage=storage,
159
- cache_options=cache_options,
160
- ignore_specification=ignore_specification,
156
+ policy=policy,
161
157
  )
162
158
  self.storage = self._cache_proxy.storage
163
159
 
@@ -184,8 +180,7 @@ class AsyncCacheTransport(httpx.AsyncBaseTransport):
184
180
  class AsyncCacheClient(httpx.AsyncClient):
185
181
  def __init__(self, *args: t.Any, **kwargs: t.Any) -> None:
186
182
  self.storage: AsyncBaseStorage | None = kwargs.pop("storage", None)
187
- self.cache_options: CacheOptions | None = kwargs.pop("cache_options", None)
188
- self.ignore_specification: bool = kwargs.pop("ignore_specification", False)
183
+ self.policy: CachePolicy | None = kwargs.pop("policy", None)
189
184
  super().__init__(*args, **kwargs)
190
185
 
191
186
  def _init_transport(
@@ -212,8 +207,7 @@ class AsyncCacheClient(httpx.AsyncClient):
212
207
  limits=limits,
213
208
  ),
214
209
  storage=self.storage,
215
- cache_options=self.cache_options,
216
- ignore_specification=False,
210
+ policy=self.policy,
217
211
  )
218
212
 
219
213
  def _init_proxy_transport(
@@ -238,6 +232,5 @@ class AsyncCacheClient(httpx.AsyncClient):
238
232
  proxy=proxy,
239
233
  ),
240
234
  storage=self.storage,
241
- cache_options=self.cache_options,
242
- ignore_specification=self.ignore_specification,
235
+ policy=self.policy,
243
236
  )
hishel/_core/_spec.py CHANGED
@@ -9,7 +9,6 @@ from typing import (
9
9
  TYPE_CHECKING,
10
10
  Any,
11
11
  Dict,
12
- Literal,
13
12
  Optional,
14
13
  TypeVar,
15
14
  Union,
@@ -1128,12 +1127,6 @@ AnyState = Union[
1128
1127
  SAFE_METHODS = frozenset(["GET", "HEAD", "OPTIONS", "TRACE"])
1129
1128
 
1130
1129
 
1131
- def create_idle_state(role: Literal["client", "server"], options: Optional[CacheOptions] = None) -> IdleClient:
1132
- if role == "server":
1133
- raise NotImplementedError("Server role is not implemented yet.")
1134
- return IdleClient(options=options or CacheOptions())
1135
-
1136
-
1137
1130
  @dataclass
1138
1131
  class IdleClient(State):
1139
1132
  """
@@ -1419,7 +1412,7 @@ class IdleClient(State):
1419
1412
  # Calculate current age and update the Age header
1420
1413
  current_age = get_age(selected_pair.response)
1421
1414
  return FromCache(
1422
- pair=replace(
1415
+ entry=replace(
1423
1416
  selected_pair,
1424
1417
  response=replace(
1425
1418
  selected_pair.response,
@@ -1900,7 +1893,7 @@ class NeedRevalidation(State):
1900
1893
 
1901
1894
  def next(
1902
1895
  self, revalidation_response: Response
1903
- ) -> Union["NeedToBeUpdated", "InvalidateEntries", "CacheMiss", "FromCache"]:
1896
+ ) -> Union["NeedToBeUpdated", "InvalidateEntries", "CacheMiss", "FromCache", "StoreAndUse", "CouldNotBeStored"]:
1904
1897
  """
1905
1898
  Handles the response to a conditional request and determines the next state.
1906
1899
 
@@ -2059,30 +2052,17 @@ class NeedRevalidation(State):
2059
2052
  revalidation_response,
2060
2053
  ),
2061
2054
  )
2062
- elif revalidation_response.status_code // 100 == 3:
2063
- # 3xx Redirects should have been followed by the HTTP client
2064
- return FromCache(
2065
- pair=replace(
2066
- self.revalidating_entries[-1],
2067
- response=revalidation_response,
2068
- ),
2055
+ else:
2056
+ # ============================================================================
2057
+ # STEP 4: Handle Unexpected Status Codes
2058
+ # ============================================================================
2059
+ # RFC 9111 does not define behavior for other status codes in this context.
2060
+ # In practice, we need to forward any unexpected responses to the client.
2061
+ return CacheMiss(
2062
+ request=self.revalidating_entries[-1].request,
2069
2063
  options=self.options,
2070
- )
2071
-
2072
- # ============================================================================
2073
- # STEP 4: Handle Unexpected Status Codes
2074
- # ============================================================================
2075
- # This should not happen in normal operation. Valid revalidation responses are:
2076
- # - 304 Not Modified
2077
- # - 2xx Success (typically 200 OK)
2078
- # - 5xx Server Error
2079
- #
2080
- # Other status codes (1xx, 3xx, 4xx) are unexpected during revalidation.
2081
- # 3xx redirects should have been followed by the HTTP client.
2082
- # 4xx errors (except 404) are unusual during revalidation.
2083
- raise RuntimeError(
2084
- f"Unexpected response status code during revalidation: {revalidation_response.status_code}"
2085
- ) # pragma: nocover
2064
+ after_revalidation=True,
2065
+ ).next(revalidation_response)
2086
2066
 
2087
2067
  def freshening_stored_responses(
2088
2068
  self, revalidation_response: Response
@@ -2330,7 +2310,6 @@ class StoreAndUse(State):
2330
2310
  response_meta = ResponseMetadata(
2331
2311
  hishel_created_at=time.time(),
2332
2312
  hishel_from_cache=False,
2333
- hishel_spec_ignored=False,
2334
2313
  hishel_revalidated=after_revalidation,
2335
2314
  hishel_stored=True,
2336
2315
  )
@@ -2379,7 +2358,6 @@ class CouldNotBeStored(State):
2379
2358
  response_meta = ResponseMetadata(
2380
2359
  hishel_created_at=time.time(),
2381
2360
  hishel_from_cache=False,
2382
- hishel_spec_ignored=False,
2383
2361
  hishel_revalidated=after_revalidation,
2384
2362
  hishel_stored=False,
2385
2363
  )
@@ -2406,21 +2384,20 @@ class InvalidateEntries(State):
2406
2384
  class FromCache(State):
2407
2385
  def __init__(
2408
2386
  self,
2409
- pair: Entry,
2387
+ entry: Entry,
2410
2388
  options: CacheOptions,
2411
2389
  after_revalidation: bool = False,
2412
2390
  ) -> None:
2413
2391
  super().__init__(options)
2414
- self.pair = pair
2392
+ self.entry = entry
2415
2393
  self.after_revalidation = after_revalidation
2416
2394
  response_meta = ResponseMetadata(
2417
- hishel_created_at=pair.meta.created_at,
2395
+ hishel_created_at=entry.meta.created_at,
2418
2396
  hishel_from_cache=True,
2419
- hishel_spec_ignored=False,
2420
2397
  hishel_revalidated=after_revalidation,
2421
2398
  hishel_stored=False,
2422
2399
  )
2423
- self.pair.response.metadata.update(response_meta) # type: ignore
2400
+ self.entry.response.metadata.update(response_meta) # type: ignore
2424
2401
 
2425
2402
  def next(self) -> None:
2426
2403
  return None
@@ -2432,4 +2409,4 @@ class NeedToBeUpdated(State):
2432
2409
  original_request: Request
2433
2410
 
2434
2411
  def next(self) -> FromCache:
2435
- return FromCache(pair=self.updating_entries[-1], options=self.options) # pragma: nocover
2412
+ return FromCache(entry=self.updating_entries[-1], options=self.options)
@@ -3,6 +3,7 @@ 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
  AsyncIterable,
@@ -46,10 +47,12 @@ try:
46
47
  default_ttl: Optional[float] = None,
47
48
  refresh_ttl_on_access: bool = True,
48
49
  ) -> None:
49
- base_path = ensure_cache_dict()
50
+ db_path = Path(database_path)
50
51
 
51
52
  self.connection = connection
52
- 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
+ )
53
56
  self.default_ttl = default_ttl
54
57
  self.refresh_ttl_on_access = refresh_ttl_on_access
55
58
  self.last_cleanup = time.time() - BATCH_CLEANUP_INTERVAL + BATCH_CLEANUP_START_DELAY
@@ -3,6 +3,7 @@ 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,
@@ -46,10 +47,12 @@ try:
46
47
  default_ttl: Optional[float] = None,
47
48
  refresh_ttl_on_access: bool = True,
48
49
  ) -> None:
49
- base_path = ensure_cache_dict()
50
+ db_path = Path(database_path)
50
51
 
51
52
  self.connection = connection
52
- 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
+ )
53
56
  self.default_ttl = default_ttl
54
57
  self.refresh_ttl_on_access = refresh_ttl_on_access
55
58
  self.last_cleanup = time.time() - BATCH_CLEANUP_INTERVAL + BATCH_CLEANUP_START_DELAY
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:
@@ -96,18 +100,56 @@ class Request:
96
100
  stream: Iterator[bytes] | AsyncIterator[bytes] = field(default_factory=lambda: iter(AnyIterable()))
97
101
  metadata: RequestMetadata | Mapping[str, Any] = field(default_factory=dict)
98
102
 
99
- def iter_stream(self) -> Iterator[bytes]:
100
- if isinstance(self.stream, Iterator):
101
- 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
102
110
  raise TypeError("Request stream is not an Iterator")
103
111
 
104
- async def aiter_stream(self) -> AsyncIterator[bytes]:
105
- 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)):
106
117
  async for chunk in self.stream:
107
118
  yield chunk
119
+ return
108
120
  else:
109
121
  raise TypeError("Request stream is not an AsyncIterator")
110
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
+
111
153
 
112
154
  class ResponseMetadata(TypedDict, total=False):
113
155
  # All the names here should be prefixed with "hishel_" to avoid collisions with user data
@@ -117,9 +159,6 @@ class ResponseMetadata(TypedDict, total=False):
117
159
  hishel_revalidated: bool
118
160
  """Indicates whether the response was revalidated with the origin server."""
119
161
 
120
- hishel_spec_ignored: bool
121
- """Indicates whether the caching specification was ignored for this response."""
122
-
123
162
  hishel_stored: bool
124
163
  """Indicates whether the response was stored in cache."""
125
164
 
@@ -134,18 +173,55 @@ class Response:
134
173
  stream: Iterator[bytes] | AsyncIterator[bytes] = field(default_factory=lambda: iter(AnyIterable()))
135
174
  metadata: ResponseMetadata | Mapping[str, Any] = field(default_factory=dict)
136
175
 
137
- 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
138
180
  if isinstance(self.stream, Iterator):
139
- return self.stream
181
+ yield from self.stream
182
+ return
140
183
  raise TypeError("Response stream is not an Iterator")
141
184
 
142
- 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
143
189
  if isinstance(self.stream, AsyncIterator):
144
190
  async for chunk in self.stream:
145
191
  yield chunk
146
192
  else:
147
193
  raise TypeError("Response stream is not an AsyncIterator")
148
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
+
149
225
 
150
226
  @dataclass
151
227
  class EntryMeta: