hishel 1.0.0.dev1__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/__init__.py +15 -14
- hishel/_async_cache.py +50 -37
- hishel/_async_httpx.py +243 -0
- hishel/_core/_headers.py +11 -1
- hishel/_core/_spec.py +184 -127
- hishel/_core/_storages/_async_base.py +71 -0
- hishel/_core/{_async/_storages/_sqlite.py → _storages/_async_sqlite.py} +95 -132
- hishel/_core/_storages/_packing.py +144 -0
- hishel/_core/_storages/_sync_base.py +71 -0
- hishel/_core/{_sync/_storages/_sqlite.py → _storages/_sync_sqlite.py} +95 -132
- hishel/_core/models.py +13 -26
- hishel/_sync_cache.py +50 -37
- hishel/_sync_httpx.py +243 -0
- hishel/_utils.py +48 -137
- hishel/asgi.py +400 -0
- hishel/fastapi.py +263 -0
- hishel/httpx.py +3 -326
- hishel/requests.py +25 -17
- {hishel-1.0.0.dev1.dist-info → hishel-1.0.0.dev3.dist-info}/METADATA +139 -27
- hishel-1.0.0.dev3.dist-info/RECORD +23 -0
- hishel/_core/__init__.py +0 -59
- hishel/_core/_base/_storages/_base.py +0 -272
- hishel/_core/_base/_storages/_packing.py +0 -165
- hishel-1.0.0.dev1.dist-info/RECORD +0 -19
- {hishel-1.0.0.dev1.dist-info → hishel-1.0.0.dev3.dist-info}/WHEEL +0 -0
- {hishel-1.0.0.dev1.dist-info → hishel-1.0.0.dev3.dist-info}/licenses/LICENSE +0 -0
hishel/_sync_cache.py
CHANGED
|
@@ -4,7 +4,7 @@ import hashlib
|
|
|
4
4
|
import logging
|
|
5
5
|
import time
|
|
6
6
|
from dataclasses import replace
|
|
7
|
-
from typing import Iterator, Awaitable, Callable
|
|
7
|
+
from typing import Iterable, Iterator, Awaitable, Callable
|
|
8
8
|
|
|
9
9
|
from typing_extensions import assert_never
|
|
10
10
|
|
|
@@ -24,8 +24,9 @@ from hishel import (
|
|
|
24
24
|
StoreAndUse,
|
|
25
25
|
create_idle_state,
|
|
26
26
|
)
|
|
27
|
-
from hishel._core._spec import
|
|
28
|
-
from hishel._core.models import
|
|
27
|
+
from hishel._core._spec import InvalidateEntries, vary_headers_match
|
|
28
|
+
from hishel._core.models import Entry, ResponseMetadata
|
|
29
|
+
from hishel._utils import make_sync_iterator
|
|
29
30
|
|
|
30
31
|
logger = logging.getLogger("hishel.integrations.clients")
|
|
31
32
|
|
|
@@ -42,12 +43,12 @@ class SyncCacheProxy:
|
|
|
42
43
|
|
|
43
44
|
def __init__(
|
|
44
45
|
self,
|
|
45
|
-
|
|
46
|
+
request_sender: Callable[[Request], Response],
|
|
46
47
|
storage: SyncBaseStorage | None = None,
|
|
47
48
|
cache_options: CacheOptions | None = None,
|
|
48
49
|
ignore_specification: bool = False,
|
|
49
50
|
) -> None:
|
|
50
|
-
self.send_request =
|
|
51
|
+
self.send_request = request_sender
|
|
51
52
|
self.storage = storage if storage is not None else SyncSqliteStorage()
|
|
52
53
|
self.cache_options = cache_options if cache_options is not None else CacheOptions()
|
|
53
54
|
self.ignore_specification = ignore_specification
|
|
@@ -59,26 +60,30 @@ class SyncCacheProxy:
|
|
|
59
60
|
|
|
60
61
|
def _get_key_for_request(self, request: Request) -> str:
|
|
61
62
|
if request.metadata.get("hishel_body_key"):
|
|
62
|
-
assert isinstance(request.stream, Iterator)
|
|
63
|
+
assert isinstance(request.stream, (Iterator, Iterable))
|
|
63
64
|
collected = b"".join([chunk for chunk in request.stream])
|
|
64
65
|
hash_ = hashlib.sha256(collected).hexdigest()
|
|
66
|
+
request.stream = make_sync_iterator([collected])
|
|
65
67
|
return f"{str(request.url)}-{hash_}"
|
|
66
|
-
return str(request.url)
|
|
68
|
+
return hashlib.sha256(str(request.url).encode("utf-8")).hexdigest()
|
|
67
69
|
|
|
68
|
-
def _maybe_refresh_pair_ttl(self, pair:
|
|
70
|
+
def _maybe_refresh_pair_ttl(self, pair: Entry) -> None:
|
|
69
71
|
if pair.request.metadata.get("hishel_refresh_ttl_on_access"):
|
|
70
|
-
self.storage.
|
|
72
|
+
self.storage.update_entry(
|
|
71
73
|
pair.id,
|
|
72
|
-
lambda complete_pair: replace(
|
|
74
|
+
lambda complete_pair: replace(
|
|
75
|
+
complete_pair,
|
|
76
|
+
meta=replace(complete_pair.meta, created_at=time.time()),
|
|
77
|
+
),
|
|
73
78
|
)
|
|
74
79
|
|
|
75
80
|
def _handle_request_ignoring_spec(self, request: Request) -> Response:
|
|
76
81
|
logger.debug("Trying to get cached response ignoring specification")
|
|
77
|
-
|
|
82
|
+
entries = self.storage.get_entries(self._get_key_for_request(request))
|
|
78
83
|
|
|
79
|
-
logger.debug(f"Found {len(
|
|
84
|
+
logger.debug(f"Found {len(entries)} cached entries for the request")
|
|
80
85
|
|
|
81
|
-
for pair in
|
|
86
|
+
for pair in entries:
|
|
82
87
|
if (
|
|
83
88
|
str(pair.request.url) == str(request.url)
|
|
84
89
|
and pair.request.method == request.method
|
|
@@ -90,20 +95,26 @@ class SyncCacheProxy:
|
|
|
90
95
|
logger.debug(
|
|
91
96
|
"Found matching cached response for the request",
|
|
92
97
|
)
|
|
93
|
-
|
|
98
|
+
response_meta = ResponseMetadata(
|
|
99
|
+
hishel_spec_ignored=True,
|
|
100
|
+
hishel_from_cache=True,
|
|
101
|
+
hishel_created_at=pair.meta.created_at,
|
|
102
|
+
hishel_revalidated=False,
|
|
103
|
+
hishel_stored=False,
|
|
104
|
+
)
|
|
105
|
+
pair.response.metadata.update(response_meta) # type: ignore
|
|
94
106
|
self._maybe_refresh_pair_ttl(pair)
|
|
95
107
|
return pair.response
|
|
96
108
|
|
|
97
|
-
|
|
98
|
-
request,
|
|
99
|
-
)
|
|
100
|
-
response = self.send_request(incomplete_pair.request)
|
|
109
|
+
response = self.send_request(request)
|
|
101
110
|
|
|
102
111
|
logger.debug("Storing response in cache ignoring specification")
|
|
103
|
-
|
|
104
|
-
|
|
112
|
+
entry = self.storage.create_entry(
|
|
113
|
+
request,
|
|
114
|
+
response,
|
|
115
|
+
self._get_key_for_request(request),
|
|
105
116
|
)
|
|
106
|
-
return
|
|
117
|
+
return entry.response
|
|
107
118
|
|
|
108
119
|
def _handle_request_respecting_spec(self, request: Request) -> Response:
|
|
109
120
|
state: AnyState = create_idle_state("client", self.cache_options)
|
|
@@ -125,25 +136,26 @@ class SyncCacheProxy:
|
|
|
125
136
|
return state.pair.response
|
|
126
137
|
elif isinstance(state, NeedToBeUpdated):
|
|
127
138
|
state = self._handle_update(state)
|
|
128
|
-
elif isinstance(state,
|
|
129
|
-
state = self.
|
|
139
|
+
elif isinstance(state, InvalidateEntries):
|
|
140
|
+
state = self._handle_invalidate_entries(state)
|
|
130
141
|
else:
|
|
131
142
|
assert_never(state)
|
|
132
143
|
|
|
133
144
|
raise RuntimeError("Unreachable")
|
|
134
145
|
|
|
135
146
|
def _handle_idle_state(self, state: IdleClient, request: Request) -> AnyState:
|
|
136
|
-
|
|
137
|
-
return state.next(request,
|
|
147
|
+
stored_entries = self.storage.get_entries(self._get_key_for_request(request))
|
|
148
|
+
return state.next(request, stored_entries)
|
|
138
149
|
|
|
139
150
|
def _handle_cache_miss(self, state: CacheMiss) -> AnyState:
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return state.next(response, incomplete_pair.id)
|
|
151
|
+
response = self.send_request(state.request)
|
|
152
|
+
return state.next(response)
|
|
143
153
|
|
|
144
154
|
def _handle_store_and_use(self, state: StoreAndUse, request: Request) -> Response:
|
|
145
|
-
complete_pair = self.storage.
|
|
146
|
-
|
|
155
|
+
complete_pair = self.storage.create_entry(
|
|
156
|
+
request,
|
|
157
|
+
state.response,
|
|
158
|
+
self._get_key_for_request(request),
|
|
147
159
|
)
|
|
148
160
|
return complete_pair.response
|
|
149
161
|
|
|
@@ -152,16 +164,17 @@ class SyncCacheProxy:
|
|
|
152
164
|
return state.next(revalidation_response)
|
|
153
165
|
|
|
154
166
|
def _handle_update(self, state: NeedToBeUpdated) -> AnyState:
|
|
155
|
-
for
|
|
156
|
-
self.storage.
|
|
157
|
-
|
|
167
|
+
for entry in state.updating_entries:
|
|
168
|
+
self.storage.update_entry(
|
|
169
|
+
entry.id,
|
|
158
170
|
lambda complete_pair: replace(
|
|
159
|
-
complete_pair,
|
|
171
|
+
complete_pair,
|
|
172
|
+
response=replace(entry.response, headers=entry.response.headers),
|
|
160
173
|
),
|
|
161
174
|
)
|
|
162
175
|
return state.next()
|
|
163
176
|
|
|
164
|
-
def
|
|
165
|
-
for
|
|
166
|
-
self.storage.
|
|
177
|
+
def _handle_invalidate_entries(self, state: InvalidateEntries) -> AnyState:
|
|
178
|
+
for entry_id in state.entry_ids:
|
|
179
|
+
self.storage.remove_entry(entry_id)
|
|
167
180
|
return state.next()
|
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
|
@@ -1,107 +1,17 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import calendar
|
|
4
|
-
import hashlib
|
|
5
4
|
import time
|
|
6
5
|
import typing as tp
|
|
7
|
-
from email.utils import parsedate_tz
|
|
8
|
-
from
|
|
9
|
-
|
|
10
|
-
import httpcore
|
|
11
|
-
import httpx
|
|
6
|
+
from email.utils import formatdate, parsedate_tz
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import AsyncIterator, Iterable, Iterator
|
|
12
9
|
|
|
13
10
|
HEADERS_ENCODING = "iso-8859-1"
|
|
14
11
|
|
|
15
12
|
T = tp.TypeVar("T")
|
|
16
13
|
|
|
17
14
|
|
|
18
|
-
class BaseClock:
|
|
19
|
-
def now(self) -> int:
|
|
20
|
-
raise NotImplementedError()
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
class Clock(BaseClock):
|
|
24
|
-
def now(self) -> int:
|
|
25
|
-
return int(time.time())
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def normalized_url(url: tp.Union[httpcore.URL, str, bytes]) -> str:
|
|
29
|
-
if isinstance(url, str): # pragma: no cover
|
|
30
|
-
return url
|
|
31
|
-
|
|
32
|
-
if isinstance(url, bytes): # pragma: no cover
|
|
33
|
-
return url.decode("ascii")
|
|
34
|
-
|
|
35
|
-
if isinstance(url, httpcore.URL):
|
|
36
|
-
port = f":{url.port}" if url.port is not None else ""
|
|
37
|
-
return f"{url.scheme.decode('ascii')}://{url.host.decode('ascii')}{port}{url.target.decode('ascii')}"
|
|
38
|
-
assert False, "Invalid type for `normalized_url`" # pragma: no cover
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def get_safe_url(url: httpcore.URL) -> str:
|
|
42
|
-
httpx_url = httpx.URL(bytes(url).decode("ascii"))
|
|
43
|
-
|
|
44
|
-
schema = httpx_url.scheme
|
|
45
|
-
host = httpx_url.host
|
|
46
|
-
path = httpx_url.path
|
|
47
|
-
|
|
48
|
-
return f"{schema}://{host}{path}"
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def generate_key(request: httpcore.Request, body: bytes = b"") -> str:
|
|
52
|
-
encoded_url = normalized_url(request.url).encode("ascii")
|
|
53
|
-
|
|
54
|
-
key_parts = [request.method, encoded_url, body]
|
|
55
|
-
|
|
56
|
-
# FIPs mode disables blake2 algorithm, use sha256 instead when not found.
|
|
57
|
-
blake2b_hasher = None
|
|
58
|
-
sha256_hasher = hashlib.sha256(usedforsecurity=False)
|
|
59
|
-
try:
|
|
60
|
-
blake2b_hasher = hashlib.blake2b(digest_size=16, usedforsecurity=False)
|
|
61
|
-
except (ValueError, TypeError, AttributeError):
|
|
62
|
-
pass
|
|
63
|
-
|
|
64
|
-
hexdigest: str
|
|
65
|
-
if blake2b_hasher:
|
|
66
|
-
for part in key_parts:
|
|
67
|
-
blake2b_hasher.update(part)
|
|
68
|
-
|
|
69
|
-
hexdigest = blake2b_hasher.hexdigest()
|
|
70
|
-
else:
|
|
71
|
-
for part in key_parts:
|
|
72
|
-
sha256_hasher.update(part)
|
|
73
|
-
|
|
74
|
-
hexdigest = sha256_hasher.hexdigest()
|
|
75
|
-
return hexdigest
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def extract_header_values(
|
|
79
|
-
headers: tp.List[tp.Tuple[bytes, bytes]],
|
|
80
|
-
header_key: tp.Union[bytes, str],
|
|
81
|
-
single: bool = False,
|
|
82
|
-
) -> tp.List[bytes]:
|
|
83
|
-
if isinstance(header_key, str):
|
|
84
|
-
header_key = header_key.encode(HEADERS_ENCODING)
|
|
85
|
-
extracted_headers = []
|
|
86
|
-
for key, value in headers:
|
|
87
|
-
if key.lower() == header_key.lower():
|
|
88
|
-
extracted_headers.append(value)
|
|
89
|
-
if single:
|
|
90
|
-
break
|
|
91
|
-
return extracted_headers
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def extract_header_values_decoded(
|
|
95
|
-
headers: tp.List[tp.Tuple[bytes, bytes]], header_key: bytes, single: bool = False
|
|
96
|
-
) -> tp.List[str]:
|
|
97
|
-
values = extract_header_values(headers=headers, header_key=header_key, single=single)
|
|
98
|
-
return [value.decode(HEADERS_ENCODING) for value in values]
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def header_presents(headers: tp.List[tp.Tuple[bytes, bytes]], header_key: bytes) -> bool:
|
|
102
|
-
return bool(extract_header_values(headers, header_key, single=True))
|
|
103
|
-
|
|
104
|
-
|
|
105
15
|
def parse_date(date: str) -> tp.Optional[int]:
|
|
106
16
|
expires = parsedate_tz(date)
|
|
107
17
|
if expires is None:
|
|
@@ -114,10 +24,6 @@ def sleep(seconds: tp.Union[int, float]) -> None:
|
|
|
114
24
|
time.sleep(seconds)
|
|
115
25
|
|
|
116
26
|
|
|
117
|
-
def float_seconds_to_int_milliseconds(seconds: float) -> int:
|
|
118
|
-
return int(seconds * 1000)
|
|
119
|
-
|
|
120
|
-
|
|
121
27
|
def partition(iterable: tp.Iterable[T], predicate: tp.Callable[[T], bool]) -> tp.Tuple[tp.List[T], tp.List[T]]:
|
|
122
28
|
"""
|
|
123
29
|
Partition an iterable into two lists: one for matching items and one for non-matching items.
|
|
@@ -145,11 +51,35 @@ def partition(iterable: tp.Iterable[T], predicate: tp.Callable[[T], bool]) -> tp
|
|
|
145
51
|
return matching, non_matching
|
|
146
52
|
|
|
147
53
|
|
|
148
|
-
async def make_async_iterator(
|
|
54
|
+
async def make_async_iterator(
|
|
55
|
+
iterable: Iterable[bytes],
|
|
56
|
+
) -> AsyncIterator[bytes]:
|
|
149
57
|
for item in iterable:
|
|
150
58
|
yield item
|
|
151
59
|
|
|
152
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
|
+
|
|
153
83
|
def make_sync_iterator(iterable: Iterable[bytes]) -> Iterator[bytes]:
|
|
154
84
|
for item in iterable:
|
|
155
85
|
yield item
|
|
@@ -177,42 +107,23 @@ def snake_to_header(text: str) -> str:
|
|
|
177
107
|
return "X-" + "-".join(word.capitalize() for word in text.split("_"))
|
|
178
108
|
|
|
179
109
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
except StopIteration as exc:
|
|
201
|
-
self.gen.send(None)
|
|
202
|
-
self.value = exc.value
|
|
203
|
-
raise
|
|
204
|
-
return chunk
|
|
205
|
-
|
|
206
|
-
def __aiter__(self) -> AsyncIterator[bytes]:
|
|
207
|
-
return self
|
|
208
|
-
|
|
209
|
-
async def __anext__(self) -> bytes:
|
|
210
|
-
assert isinstance(self.stream, AsyncIterator)
|
|
211
|
-
try:
|
|
212
|
-
chunk = await self.stream.__anext__()
|
|
213
|
-
self.gen.send(chunk)
|
|
214
|
-
except StopIteration as exc:
|
|
215
|
-
self.gen.send(None)
|
|
216
|
-
self.value = exc.value
|
|
217
|
-
raise
|
|
218
|
-
return chunk
|
|
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)
|