hishel 1.0.0.dev2__py3-none-any.whl → 1.0.0.dev3__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/_sync_httpx.py ADDED
@@ -0,0 +1,243 @@
1
+ from __future__ import annotations
2
+
3
+ import ssl
4
+ import typing as t
5
+ from typing import (
6
+ Iterable,
7
+ Iterator,
8
+ Iterator,
9
+ Union,
10
+ cast,
11
+ overload,
12
+ )
13
+
14
+ from httpx import RequestNotRead
15
+
16
+ from hishel import SyncCacheProxy, Headers, Request, Response
17
+ from hishel._core._spec import (
18
+ CacheOptions,
19
+ )
20
+ from hishel._core._storages._sync_base import SyncBaseStorage
21
+ from hishel._core.models import RequestMetadata, extract_metadata_from_headers
22
+ from hishel._utils import (
23
+ filter_mapping,
24
+ make_sync_iterator,
25
+ )
26
+
27
+ try:
28
+ import httpx
29
+ except ImportError as e:
30
+ raise ImportError(
31
+ "httpx is required to use hishel.httpx module. "
32
+ "Please install hishel with the 'httpx' extra, "
33
+ "e.g., 'pip install hishel[httpx]'."
34
+ ) from e
35
+
36
+ SOCKET_OPTION = t.Union[
37
+ t.Tuple[int, int, int],
38
+ t.Tuple[int, int, t.Union[bytes, bytearray]],
39
+ t.Tuple[int, int, None, int],
40
+ ]
41
+
42
+ # 128 KB
43
+ CHUNK_SIZE = 131072
44
+
45
+
46
+ @overload
47
+ def _internal_to_httpx(
48
+ value: Request,
49
+ ) -> httpx.Request: ...
50
+ @overload
51
+ def _internal_to_httpx(
52
+ value: Response,
53
+ ) -> httpx.Response: ...
54
+ def _internal_to_httpx(
55
+ value: Union[Request, Response],
56
+ ) -> Union[httpx.Request, httpx.Response]:
57
+ """
58
+ Convert internal Request/Response to httpx.Request/httpx.Response.
59
+ """
60
+ if isinstance(value, Request):
61
+ return httpx.Request(
62
+ method=value.method,
63
+ url=value.url,
64
+ headers=value.headers,
65
+ stream=_IteratorStream(value.iter_stream()),
66
+ extensions=value.metadata,
67
+ )
68
+ elif isinstance(value, Response):
69
+ return httpx.Response(
70
+ status_code=value.status_code,
71
+ headers=value.headers,
72
+ stream=_IteratorStream(value.iter_stream()),
73
+ extensions=value.metadata,
74
+ )
75
+
76
+
77
+ @overload
78
+ def _httpx_to_internal(
79
+ value: httpx.Request,
80
+ ) -> Request: ...
81
+ @overload
82
+ def _httpx_to_internal(
83
+ value: httpx.Response,
84
+ ) -> Response: ...
85
+ def _httpx_to_internal(
86
+ value: Union[httpx.Request, httpx.Response],
87
+ ) -> Union[Request, Response]:
88
+ """
89
+ Convert httpx.Request/httpx.Response to internal Request/Response.
90
+ """
91
+ headers = Headers(
92
+ filter_mapping(
93
+ Headers({key: value for key, value in value.headers.items()}),
94
+ ["Transfer-Encoding"],
95
+ )
96
+ )
97
+ if isinstance(value, httpx.Request):
98
+ extension_metadata = RequestMetadata(
99
+ hishel_refresh_ttl_on_access=value.extensions.get("hishel_refresh_ttl_on_access"),
100
+ hishel_ttl=value.extensions.get("hishel_ttl"),
101
+ hishel_spec_ignore=value.extensions.get("hishel_spec_ignore"),
102
+ hishel_body_key=value.extensions.get("hishel_body_key"),
103
+ )
104
+ headers_metadata = extract_metadata_from_headers(value.headers)
105
+
106
+ for key, val in extension_metadata.items():
107
+ if key in value.extensions:
108
+ headers_metadata[key] = val # type: ignore
109
+
110
+ try:
111
+ stream = make_sync_iterator([value.content])
112
+ except RequestNotRead:
113
+ stream = cast(Iterator[bytes], value.stream)
114
+
115
+ return Request(
116
+ method=value.method,
117
+ url=str(value.url),
118
+ headers=headers,
119
+ stream=stream,
120
+ metadata=headers_metadata,
121
+ )
122
+ elif isinstance(value, httpx.Response):
123
+ if value.is_stream_consumed and "content-encoding" in value.headers:
124
+ raise RuntimeError("Can't get the raw stream of a response with `Content-Encoding` header.")
125
+ stream = (
126
+ make_sync_iterator([value.content]) if value.is_stream_consumed else value.iter_raw(chunk_size=CHUNK_SIZE)
127
+ )
128
+
129
+ return Response(
130
+ status_code=value.status_code,
131
+ headers=headers,
132
+ stream=stream,
133
+ metadata={},
134
+ )
135
+
136
+
137
+ class _IteratorStream(httpx.SyncByteStream, httpx.AsyncByteStream):
138
+ def __init__(self, iterator: Iterator[bytes] | Iterator[bytes]) -> None:
139
+ self.iterator = iterator
140
+
141
+ def __iter__(self) -> Iterator[bytes]:
142
+ assert isinstance(self.iterator, (Iterator, Iterable))
143
+ for chunk in self.iterator:
144
+ yield chunk
145
+
146
+
147
+ class SyncCacheTransport(httpx.BaseTransport):
148
+ def __init__(
149
+ self,
150
+ next_transport: httpx.BaseTransport,
151
+ storage: SyncBaseStorage | None = None,
152
+ cache_options: CacheOptions | None = None,
153
+ ignore_specification: bool = False,
154
+ ) -> None:
155
+ self.next_transport = next_transport
156
+ self._cache_proxy: SyncCacheProxy = SyncCacheProxy(
157
+ request_sender=self.request_sender,
158
+ storage=storage,
159
+ cache_options=cache_options,
160
+ ignore_specification=ignore_specification,
161
+ )
162
+ self.storage = self._cache_proxy.storage
163
+
164
+ def handle_request(
165
+ self,
166
+ request: httpx.Request,
167
+ ) -> httpx.Response:
168
+ internal_request = _httpx_to_internal(request)
169
+ internal_response = self._cache_proxy.handle_request(internal_request)
170
+ response = _internal_to_httpx(internal_response)
171
+ return response
172
+
173
+ def close(self) -> None:
174
+ self.next_transport.close()
175
+ self.storage.close()
176
+ super().close()
177
+
178
+ def request_sender(self, request: Request) -> Response:
179
+ httpx_request = _internal_to_httpx(request)
180
+ httpx_response = self.next_transport.handle_request(httpx_request)
181
+ return _httpx_to_internal(httpx_response)
182
+
183
+
184
+ class SyncCacheClient(httpx.Client):
185
+ def __init__(self, *args: t.Any, **kwargs: t.Any) -> None:
186
+ self.storage: SyncBaseStorage | 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)
189
+ super().__init__(*args, **kwargs)
190
+
191
+ def _init_transport(
192
+ self,
193
+ verify: ssl.SSLContext | str | bool = True,
194
+ cert: t.Union[str, t.Tuple[str, str], t.Tuple[str, str, str], None] = None,
195
+ trust_env: bool = True,
196
+ http1: bool = True,
197
+ http2: bool = False,
198
+ limits: httpx.Limits = httpx.Limits(max_connections=100, max_keepalive_connections=20),
199
+ transport: httpx.BaseTransport | None = None,
200
+ **kwargs: t.Any,
201
+ ) -> httpx.BaseTransport:
202
+ if transport is not None:
203
+ return transport
204
+
205
+ return SyncCacheTransport(
206
+ next_transport=httpx.HTTPTransport(
207
+ verify=verify,
208
+ cert=cert,
209
+ trust_env=trust_env,
210
+ http1=http1,
211
+ http2=http2,
212
+ limits=limits,
213
+ ),
214
+ storage=self.storage,
215
+ cache_options=self.cache_options,
216
+ ignore_specification=False,
217
+ )
218
+
219
+ def _init_proxy_transport(
220
+ self,
221
+ proxy: httpx.Proxy,
222
+ verify: ssl.SSLContext | str | bool = True,
223
+ cert: t.Union[str, t.Tuple[str, str], t.Tuple[str, str, str], None] = None,
224
+ trust_env: bool = True,
225
+ http1: bool = True,
226
+ http2: bool = False,
227
+ limits: httpx.Limits = httpx.Limits(max_connections=100, max_keepalive_connections=20),
228
+ **kwargs: t.Any,
229
+ ) -> httpx.BaseTransport:
230
+ return SyncCacheTransport(
231
+ next_transport=httpx.HTTPTransport(
232
+ verify=verify,
233
+ cert=cert,
234
+ trust_env=trust_env,
235
+ http1=http1,
236
+ http2=http2,
237
+ limits=limits,
238
+ proxy=proxy,
239
+ ),
240
+ storage=self.storage,
241
+ cache_options=self.cache_options,
242
+ ignore_specification=self.ignore_specification,
243
+ )
hishel/_utils.py CHANGED
@@ -3,7 +3,8 @@ from __future__ import annotations
3
3
  import calendar
4
4
  import time
5
5
  import typing as tp
6
- from email.utils import parsedate_tz
6
+ from email.utils import formatdate, parsedate_tz
7
+ from pathlib import Path
7
8
  from typing import AsyncIterator, Iterable, Iterator
8
9
 
9
10
  HEADERS_ENCODING = "iso-8859-1"
@@ -50,11 +51,35 @@ def partition(iterable: tp.Iterable[T], predicate: tp.Callable[[T], bool]) -> tp
50
51
  return matching, non_matching
51
52
 
52
53
 
53
- async def make_async_iterator(iterable: Iterable[bytes]) -> AsyncIterator[bytes]:
54
+ async def make_async_iterator(
55
+ iterable: Iterable[bytes],
56
+ ) -> AsyncIterator[bytes]:
54
57
  for item in iterable:
55
58
  yield item
56
59
 
57
60
 
61
+ def filter_mapping(mapping: tp.Mapping[str, T], keys_to_exclude: tp.Iterable[str]) -> tp.Dict[str, T]:
62
+ """
63
+ Filter out specified keys from a string-keyed mapping using case-insensitive comparison.
64
+
65
+ Args:
66
+ mapping: The input mapping with string keys to filter.
67
+ keys_to_exclude: An iterable of string keys to exclude (case-insensitive).
68
+
69
+ Returns:
70
+ A new dictionary with the specified keys excluded.
71
+
72
+ Example:
73
+ ```python
74
+ original = {'a': 1, 'B': 2, 'c': 3}
75
+ filtered = filter_mapping(original, ['b'])
76
+ # filtered will be {'a': 1, 'c': 3}
77
+ ```
78
+ """
79
+ exclude_set = {k.lower() for k in keys_to_exclude}
80
+ return {k: v for k, v in mapping.items() if k.lower() not in exclude_set}
81
+
82
+
58
83
  def make_sync_iterator(iterable: Iterable[bytes]) -> Iterator[bytes]:
59
84
  for item in iterable:
60
85
  yield item
@@ -80,3 +105,25 @@ def snake_to_header(text: str) -> str:
80
105
  """
81
106
  # Split by underscore, capitalize each word, join with dash, add X- prefix
82
107
  return "X-" + "-".join(word.capitalize() for word in text.split("_"))
108
+
109
+
110
+ def ensure_cache_dict(base_path: str | None = None) -> Path:
111
+ _base_path = Path(base_path) if base_path is not None else Path(".cache/hishel")
112
+ _gitignore_file = _base_path / ".gitignore"
113
+
114
+ _base_path.mkdir(parents=True, exist_ok=True)
115
+
116
+ if not _gitignore_file.is_file():
117
+ with open(_gitignore_file, "w", encoding="utf-8") as f:
118
+ f.write("# Automatically created by Hishel\n*")
119
+ return _base_path
120
+
121
+
122
+ def generate_http_date() -> str:
123
+ """
124
+ Generate a Date header value for HTTP responses.
125
+ Returns date in RFC 1123 format (required by HTTP/1.1).
126
+
127
+ Example output: 'Sun, 26 Oct 2025 12:34:56 GMT'
128
+ """
129
+ return formatdate(timeval=None, localtime=False, usegmt=True)