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.
- hishel/__init__.py +26 -17
- hishel/_async_cache.py +104 -65
- hishel/_async_httpx.py +236 -0
- hishel/_core/_headers.py +11 -1
- hishel/_core/_spec.py +101 -120
- hishel/_core/_storages/_async_base.py +71 -0
- hishel/_core/{_async/_storages/_sqlite.py → _storages/_async_sqlite.py} +100 -134
- 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} +100 -134
- hishel/_core/models.py +93 -33
- hishel/_policies.py +49 -0
- hishel/_sync_cache.py +104 -65
- hishel/_sync_httpx.py +236 -0
- hishel/_utils.py +49 -2
- hishel/asgi.py +400 -0
- hishel/fastapi.py +263 -0
- hishel/httpx.py +3 -326
- hishel/requests.py +28 -22
- {hishel-1.0.0.dev2.dist-info → hishel-1.1.0.dist-info}/METADATA +225 -18
- hishel-1.1.0.dist-info/RECORD +24 -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.dev2.dist-info/RECORD +0 -19
- {hishel-1.0.0.dev2.dist-info → hishel-1.1.0.dist-info}/WHEEL +0 -0
- {hishel-1.0.0.dev2.dist-info → hishel-1.1.0.dist-info}/licenses/LICENSE +0 -0
hishel/_policies.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
import typing as t
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Generic
|
|
7
|
+
|
|
8
|
+
from hishel import Request, Response
|
|
9
|
+
from hishel._core._spec import (
|
|
10
|
+
CacheOptions,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
logger = __import__("logging").getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
T = t.TypeVar("T", Request, Response)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CachePolicy(abc.ABC):
|
|
19
|
+
use_body_key: bool = False
|
|
20
|
+
"""Whether to include request body in cache key calculation."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BaseFilter(abc.ABC, Generic[T]):
|
|
24
|
+
@abc.abstractmethod
|
|
25
|
+
def needs_body(self) -> bool:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
@abc.abstractmethod
|
|
29
|
+
def apply(self, item: T, body: bytes | None) -> bool:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class SpecificationPolicy(CachePolicy):
|
|
35
|
+
"""
|
|
36
|
+
Caching policy that respects HTTP caching specification.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
cache_options: CacheOptions = field(default_factory=CacheOptions)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class FilterPolicy(CachePolicy):
|
|
44
|
+
"""
|
|
45
|
+
Caching policy that applies user-defined filtering logic.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
request_filters: list[BaseFilter[Request]] = field(default_factory=list)
|
|
49
|
+
response_filters: list[BaseFilter[Response]] = field(default_factory=list)
|
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
|
|
|
@@ -13,7 +13,6 @@ from hishel import (
|
|
|
13
13
|
SyncBaseStorage,
|
|
14
14
|
SyncSqliteStorage,
|
|
15
15
|
CacheMiss,
|
|
16
|
-
CacheOptions,
|
|
17
16
|
CouldNotBeStored,
|
|
18
17
|
FromCache,
|
|
19
18
|
IdleClient,
|
|
@@ -22,10 +21,11 @@ from hishel import (
|
|
|
22
21
|
Request,
|
|
23
22
|
Response,
|
|
24
23
|
StoreAndUse,
|
|
25
|
-
create_idle_state,
|
|
26
24
|
)
|
|
27
|
-
from hishel._core._spec import
|
|
28
|
-
from hishel._core.models import
|
|
25
|
+
from hishel._core._spec import InvalidateEntries, vary_headers_match
|
|
26
|
+
from hishel._core.models import Entry, ResponseMetadata
|
|
27
|
+
from hishel._policies import CachePolicy, FilterPolicy, SpecificationPolicy
|
|
28
|
+
from hishel._utils import make_sync_iterator
|
|
29
29
|
|
|
30
30
|
logger = logging.getLogger("hishel.integrations.clients")
|
|
31
31
|
|
|
@@ -36,84 +36,121 @@ class SyncCacheProxy:
|
|
|
36
36
|
|
|
37
37
|
This class is independent of any specific HTTP library and works only with internal models.
|
|
38
38
|
It delegates request execution to a user-provided callable, making it compatible with any
|
|
39
|
-
HTTP client. Caching behavior
|
|
40
|
-
|
|
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 SyncSqliteStorage.
|
|
44
|
+
policy: Caching policy to use. Can be SpecificationPolicy (respects RFC 9111) or
|
|
45
|
+
FilterPolicy (user-defined filtering). Defaults to SpecificationPolicy().
|
|
41
46
|
"""
|
|
42
47
|
|
|
43
48
|
def __init__(
|
|
44
49
|
self,
|
|
45
|
-
|
|
50
|
+
request_sender: Callable[[Request], Response],
|
|
46
51
|
storage: SyncBaseStorage | None = None,
|
|
47
|
-
|
|
48
|
-
ignore_specification: bool = False,
|
|
52
|
+
policy: CachePolicy | None = None,
|
|
49
53
|
) -> None:
|
|
50
|
-
self.send_request =
|
|
54
|
+
self.send_request = request_sender
|
|
51
55
|
self.storage = storage if storage is not None else SyncSqliteStorage()
|
|
52
|
-
self.
|
|
53
|
-
self.ignore_specification = ignore_specification
|
|
56
|
+
self.policy = policy if policy is not None else SpecificationPolicy()
|
|
54
57
|
|
|
55
58
|
def handle_request(self, request: Request) -> Response:
|
|
56
|
-
if self.
|
|
57
|
-
return self.
|
|
59
|
+
if isinstance(self.policy, FilterPolicy):
|
|
60
|
+
return self._handle_request_with_filters(request)
|
|
58
61
|
return self._handle_request_respecting_spec(request)
|
|
59
62
|
|
|
60
63
|
def _get_key_for_request(self, request: Request) -> str:
|
|
61
|
-
if request.metadata.get("hishel_body_key"):
|
|
62
|
-
assert isinstance(request.stream, Iterator)
|
|
64
|
+
if self.policy.use_body_key or request.metadata.get("hishel_body_key"):
|
|
65
|
+
assert isinstance(request.stream, (Iterator, Iterable))
|
|
63
66
|
collected = b"".join([chunk for chunk in request.stream])
|
|
64
67
|
hash_ = hashlib.sha256(collected).hexdigest()
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
request.stream = make_sync_iterator([collected])
|
|
69
|
+
return hash_
|
|
70
|
+
return hashlib.sha256(str(request.url).encode("utf-8")).hexdigest()
|
|
71
|
+
|
|
72
|
+
def _maybe_refresh_entry_ttl(self, entry: Entry) -> None:
|
|
73
|
+
if entry.request.metadata.get("hishel_refresh_ttl_on_access"):
|
|
74
|
+
self.storage.update_entry(
|
|
75
|
+
entry.id,
|
|
76
|
+
lambda current_entry: replace(
|
|
77
|
+
current_entry,
|
|
78
|
+
meta=replace(current_entry.meta, created_at=time.time()),
|
|
79
|
+
),
|
|
73
80
|
)
|
|
74
81
|
|
|
75
|
-
def
|
|
82
|
+
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 = request.read()
|
|
88
|
+
if not request_filter.apply(request, body):
|
|
89
|
+
logger.debug("Request filtered out by request filter")
|
|
90
|
+
return 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 self.send_request(request)
|
|
95
|
+
|
|
76
96
|
logger.debug("Trying to get cached response ignoring specification")
|
|
77
|
-
|
|
97
|
+
cache_key = self._get_key_for_request(request)
|
|
98
|
+
entries = self.storage.get_entries(cache_key)
|
|
78
99
|
|
|
79
|
-
logger.debug(f"Found {len(
|
|
100
|
+
logger.debug(f"Found {len(entries)} cached entries for the request")
|
|
80
101
|
|
|
81
|
-
for
|
|
102
|
+
for entry in entries:
|
|
82
103
|
if (
|
|
83
|
-
str(
|
|
84
|
-
and
|
|
104
|
+
str(entry.request.url) == str(request.url)
|
|
105
|
+
and entry.request.method == request.method
|
|
85
106
|
and vary_headers_match(
|
|
86
107
|
request,
|
|
87
|
-
|
|
108
|
+
entry,
|
|
88
109
|
)
|
|
89
110
|
):
|
|
90
111
|
logger.debug(
|
|
91
112
|
"Found matching cached response for the request",
|
|
92
113
|
)
|
|
93
114
|
response_meta = ResponseMetadata(
|
|
94
|
-
hishel_spec_ignored=True,
|
|
95
115
|
hishel_from_cache=True,
|
|
96
|
-
hishel_created_at=
|
|
116
|
+
hishel_created_at=entry.meta.created_at,
|
|
97
117
|
hishel_revalidated=False,
|
|
98
118
|
hishel_stored=False,
|
|
99
119
|
)
|
|
100
|
-
|
|
101
|
-
self.
|
|
102
|
-
return
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
120
|
+
entry.response.metadata.update(response_meta) # type: ignore
|
|
121
|
+
self._maybe_refresh_entry_ttl(entry)
|
|
122
|
+
return entry.response
|
|
123
|
+
|
|
124
|
+
response = self.send_request(request)
|
|
125
|
+
for response_filter in self.policy.response_filters:
|
|
126
|
+
if response_filter.needs_body():
|
|
127
|
+
body = response.read()
|
|
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,
|
|
106
140
|
)
|
|
107
|
-
response
|
|
141
|
+
response.metadata.update(response_meta) # type: ignore
|
|
108
142
|
|
|
109
143
|
logger.debug("Storing response in cache ignoring specification")
|
|
110
|
-
|
|
111
|
-
|
|
144
|
+
entry = self.storage.create_entry(
|
|
145
|
+
request,
|
|
146
|
+
response,
|
|
147
|
+
cache_key,
|
|
112
148
|
)
|
|
113
|
-
return
|
|
149
|
+
return entry.response
|
|
114
150
|
|
|
115
151
|
def _handle_request_respecting_spec(self, request: Request) -> Response:
|
|
116
|
-
|
|
152
|
+
assert isinstance(self.policy, SpecificationPolicy)
|
|
153
|
+
state: AnyState = IdleClient(options=self.policy.cache_options)
|
|
117
154
|
|
|
118
155
|
while state:
|
|
119
156
|
logger.debug(f"Handling state: {state.__class__.__name__}")
|
|
@@ -128,47 +165,49 @@ class SyncCacheProxy:
|
|
|
128
165
|
elif isinstance(state, NeedRevalidation):
|
|
129
166
|
state = self._handle_revalidation(state)
|
|
130
167
|
elif isinstance(state, FromCache):
|
|
131
|
-
self.
|
|
132
|
-
return state.
|
|
168
|
+
self._maybe_refresh_entry_ttl(state.entry)
|
|
169
|
+
return state.entry.response
|
|
133
170
|
elif isinstance(state, NeedToBeUpdated):
|
|
134
171
|
state = self._handle_update(state)
|
|
135
|
-
elif isinstance(state,
|
|
136
|
-
state = self.
|
|
172
|
+
elif isinstance(state, InvalidateEntries):
|
|
173
|
+
state = self._handle_invalidate_entries(state)
|
|
137
174
|
else:
|
|
138
175
|
assert_never(state)
|
|
139
176
|
|
|
140
177
|
raise RuntimeError("Unreachable")
|
|
141
178
|
|
|
142
179
|
def _handle_idle_state(self, state: IdleClient, request: Request) -> AnyState:
|
|
143
|
-
|
|
144
|
-
return state.next(request,
|
|
180
|
+
stored_entries = self.storage.get_entries(self._get_key_for_request(request))
|
|
181
|
+
return state.next(request, stored_entries)
|
|
145
182
|
|
|
146
183
|
def _handle_cache_miss(self, state: CacheMiss) -> AnyState:
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return state.next(response, incomplete_pair.id)
|
|
184
|
+
response = self.send_request(state.request)
|
|
185
|
+
return state.next(response)
|
|
150
186
|
|
|
151
187
|
def _handle_store_and_use(self, state: StoreAndUse, request: Request) -> Response:
|
|
152
|
-
|
|
153
|
-
|
|
188
|
+
entry = self.storage.create_entry(
|
|
189
|
+
request,
|
|
190
|
+
state.response,
|
|
191
|
+
self._get_key_for_request(request),
|
|
154
192
|
)
|
|
155
|
-
return
|
|
193
|
+
return entry.response
|
|
156
194
|
|
|
157
195
|
def _handle_revalidation(self, state: NeedRevalidation) -> AnyState:
|
|
158
196
|
revalidation_response = self.send_request(state.request)
|
|
159
197
|
return state.next(revalidation_response)
|
|
160
198
|
|
|
161
199
|
def _handle_update(self, state: NeedToBeUpdated) -> AnyState:
|
|
162
|
-
for
|
|
163
|
-
self.storage.
|
|
164
|
-
|
|
165
|
-
lambda
|
|
166
|
-
|
|
200
|
+
for entry in state.updating_entries:
|
|
201
|
+
self.storage.update_entry(
|
|
202
|
+
entry.id,
|
|
203
|
+
lambda entry: replace(
|
|
204
|
+
entry,
|
|
205
|
+
response=replace(entry.response, headers=entry.response.headers),
|
|
167
206
|
),
|
|
168
207
|
)
|
|
169
208
|
return state.next()
|
|
170
209
|
|
|
171
|
-
def
|
|
172
|
-
for
|
|
173
|
-
self.storage.
|
|
210
|
+
def _handle_invalidate_entries(self, state: InvalidateEntries) -> AnyState:
|
|
211
|
+
for entry_id in state.entry_ids:
|
|
212
|
+
self.storage.remove_entry(entry_id)
|
|
174
213
|
return state.next()
|
hishel/_sync_httpx.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
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._storages._sync_base import SyncBaseStorage
|
|
18
|
+
from hishel._core.models import RequestMetadata, extract_metadata_from_headers
|
|
19
|
+
from hishel._policies import CachePolicy
|
|
20
|
+
from hishel._utils import (
|
|
21
|
+
filter_mapping,
|
|
22
|
+
make_sync_iterator,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
import httpx
|
|
27
|
+
except ImportError as e:
|
|
28
|
+
raise ImportError(
|
|
29
|
+
"httpx is required to use hishel.httpx module. "
|
|
30
|
+
"Please install hishel with the 'httpx' extra, "
|
|
31
|
+
"e.g., 'pip install hishel[httpx]'."
|
|
32
|
+
) from e
|
|
33
|
+
|
|
34
|
+
SOCKET_OPTION = t.Union[
|
|
35
|
+
t.Tuple[int, int, int],
|
|
36
|
+
t.Tuple[int, int, t.Union[bytes, bytearray]],
|
|
37
|
+
t.Tuple[int, int, None, int],
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
# 128 KB
|
|
41
|
+
CHUNK_SIZE = 131072
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@overload
|
|
45
|
+
def _internal_to_httpx(
|
|
46
|
+
value: Request,
|
|
47
|
+
) -> httpx.Request: ...
|
|
48
|
+
@overload
|
|
49
|
+
def _internal_to_httpx(
|
|
50
|
+
value: Response,
|
|
51
|
+
) -> httpx.Response: ...
|
|
52
|
+
def _internal_to_httpx(
|
|
53
|
+
value: Union[Request, Response],
|
|
54
|
+
) -> Union[httpx.Request, httpx.Response]:
|
|
55
|
+
"""
|
|
56
|
+
Convert internal Request/Response to httpx.Request/httpx.Response.
|
|
57
|
+
"""
|
|
58
|
+
if isinstance(value, Request):
|
|
59
|
+
return httpx.Request(
|
|
60
|
+
method=value.method,
|
|
61
|
+
url=value.url,
|
|
62
|
+
headers=value.headers,
|
|
63
|
+
stream=_IteratorStream(value._iter_stream()),
|
|
64
|
+
extensions=value.metadata,
|
|
65
|
+
)
|
|
66
|
+
elif isinstance(value, Response):
|
|
67
|
+
return httpx.Response(
|
|
68
|
+
status_code=value.status_code,
|
|
69
|
+
headers=value.headers,
|
|
70
|
+
stream=_IteratorStream(value._iter_stream()),
|
|
71
|
+
extensions=value.metadata,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@overload
|
|
76
|
+
def _httpx_to_internal(
|
|
77
|
+
value: httpx.Request,
|
|
78
|
+
) -> Request: ...
|
|
79
|
+
@overload
|
|
80
|
+
def _httpx_to_internal(
|
|
81
|
+
value: httpx.Response,
|
|
82
|
+
) -> Response: ...
|
|
83
|
+
def _httpx_to_internal(
|
|
84
|
+
value: Union[httpx.Request, httpx.Response],
|
|
85
|
+
) -> Union[Request, Response]:
|
|
86
|
+
"""
|
|
87
|
+
Convert httpx.Request/httpx.Response to internal Request/Response.
|
|
88
|
+
"""
|
|
89
|
+
headers = Headers(
|
|
90
|
+
filter_mapping(
|
|
91
|
+
Headers({key: value for key, value in value.headers.items()}),
|
|
92
|
+
["Transfer-Encoding"],
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
if isinstance(value, httpx.Request):
|
|
96
|
+
extension_metadata = RequestMetadata(
|
|
97
|
+
hishel_refresh_ttl_on_access=value.extensions.get("hishel_refresh_ttl_on_access"),
|
|
98
|
+
hishel_ttl=value.extensions.get("hishel_ttl"),
|
|
99
|
+
hishel_spec_ignore=value.extensions.get("hishel_spec_ignore"),
|
|
100
|
+
hishel_body_key=value.extensions.get("hishel_body_key"),
|
|
101
|
+
)
|
|
102
|
+
headers_metadata = extract_metadata_from_headers(value.headers)
|
|
103
|
+
|
|
104
|
+
for key, val in extension_metadata.items():
|
|
105
|
+
if key in value.extensions:
|
|
106
|
+
headers_metadata[key] = val # type: ignore
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
stream = make_sync_iterator([value.content])
|
|
110
|
+
except RequestNotRead:
|
|
111
|
+
stream = cast(Iterator[bytes], value.stream)
|
|
112
|
+
|
|
113
|
+
return Request(
|
|
114
|
+
method=value.method,
|
|
115
|
+
url=str(value.url),
|
|
116
|
+
headers=headers,
|
|
117
|
+
stream=stream,
|
|
118
|
+
metadata=headers_metadata,
|
|
119
|
+
)
|
|
120
|
+
elif isinstance(value, httpx.Response):
|
|
121
|
+
if value.is_stream_consumed and "content-encoding" in value.headers:
|
|
122
|
+
raise RuntimeError("Can't get the raw stream of a response with `Content-Encoding` header.")
|
|
123
|
+
stream = (
|
|
124
|
+
make_sync_iterator([value.content]) if value.is_stream_consumed else value.iter_raw(chunk_size=CHUNK_SIZE)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return Response(
|
|
128
|
+
status_code=value.status_code,
|
|
129
|
+
headers=headers,
|
|
130
|
+
stream=stream,
|
|
131
|
+
metadata={},
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class _IteratorStream(httpx.SyncByteStream, httpx.AsyncByteStream):
|
|
136
|
+
def __init__(self, iterator: Iterator[bytes] | Iterator[bytes]) -> None:
|
|
137
|
+
self.iterator = iterator
|
|
138
|
+
|
|
139
|
+
def __iter__(self) -> Iterator[bytes]:
|
|
140
|
+
assert isinstance(self.iterator, (Iterator, Iterable))
|
|
141
|
+
for chunk in self.iterator:
|
|
142
|
+
yield chunk
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class SyncCacheTransport(httpx.BaseTransport):
|
|
146
|
+
def __init__(
|
|
147
|
+
self,
|
|
148
|
+
next_transport: httpx.BaseTransport,
|
|
149
|
+
storage: SyncBaseStorage | None = None,
|
|
150
|
+
policy: CachePolicy | None = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
self.next_transport = next_transport
|
|
153
|
+
self._cache_proxy: SyncCacheProxy = SyncCacheProxy(
|
|
154
|
+
request_sender=self.request_sender,
|
|
155
|
+
storage=storage,
|
|
156
|
+
policy=policy,
|
|
157
|
+
)
|
|
158
|
+
self.storage = self._cache_proxy.storage
|
|
159
|
+
|
|
160
|
+
def handle_request(
|
|
161
|
+
self,
|
|
162
|
+
request: httpx.Request,
|
|
163
|
+
) -> httpx.Response:
|
|
164
|
+
internal_request = _httpx_to_internal(request)
|
|
165
|
+
internal_response = self._cache_proxy.handle_request(internal_request)
|
|
166
|
+
response = _internal_to_httpx(internal_response)
|
|
167
|
+
return response
|
|
168
|
+
|
|
169
|
+
def close(self) -> None:
|
|
170
|
+
self.next_transport.close()
|
|
171
|
+
self.storage.close()
|
|
172
|
+
super().close()
|
|
173
|
+
|
|
174
|
+
def request_sender(self, request: Request) -> Response:
|
|
175
|
+
httpx_request = _internal_to_httpx(request)
|
|
176
|
+
httpx_response = self.next_transport.handle_request(httpx_request)
|
|
177
|
+
return _httpx_to_internal(httpx_response)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class SyncCacheClient(httpx.Client):
|
|
181
|
+
def __init__(self, *args: t.Any, **kwargs: t.Any) -> None:
|
|
182
|
+
self.storage: SyncBaseStorage | None = kwargs.pop("storage", None)
|
|
183
|
+
self.policy: CachePolicy | None = kwargs.pop("policy", None)
|
|
184
|
+
super().__init__(*args, **kwargs)
|
|
185
|
+
|
|
186
|
+
def _init_transport(
|
|
187
|
+
self,
|
|
188
|
+
verify: ssl.SSLContext | str | bool = True,
|
|
189
|
+
cert: t.Union[str, t.Tuple[str, str], t.Tuple[str, str, str], None] = None,
|
|
190
|
+
trust_env: bool = True,
|
|
191
|
+
http1: bool = True,
|
|
192
|
+
http2: bool = False,
|
|
193
|
+
limits: httpx.Limits = httpx.Limits(max_connections=100, max_keepalive_connections=20),
|
|
194
|
+
transport: httpx.BaseTransport | None = None,
|
|
195
|
+
**kwargs: t.Any,
|
|
196
|
+
) -> httpx.BaseTransport:
|
|
197
|
+
if transport is not None:
|
|
198
|
+
return transport
|
|
199
|
+
|
|
200
|
+
return SyncCacheTransport(
|
|
201
|
+
next_transport=httpx.HTTPTransport(
|
|
202
|
+
verify=verify,
|
|
203
|
+
cert=cert,
|
|
204
|
+
trust_env=trust_env,
|
|
205
|
+
http1=http1,
|
|
206
|
+
http2=http2,
|
|
207
|
+
limits=limits,
|
|
208
|
+
),
|
|
209
|
+
storage=self.storage,
|
|
210
|
+
policy=self.policy,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def _init_proxy_transport(
|
|
214
|
+
self,
|
|
215
|
+
proxy: httpx.Proxy,
|
|
216
|
+
verify: ssl.SSLContext | str | bool = True,
|
|
217
|
+
cert: t.Union[str, t.Tuple[str, str], t.Tuple[str, str, str], None] = None,
|
|
218
|
+
trust_env: bool = True,
|
|
219
|
+
http1: bool = True,
|
|
220
|
+
http2: bool = False,
|
|
221
|
+
limits: httpx.Limits = httpx.Limits(max_connections=100, max_keepalive_connections=20),
|
|
222
|
+
**kwargs: t.Any,
|
|
223
|
+
) -> httpx.BaseTransport:
|
|
224
|
+
return SyncCacheTransport(
|
|
225
|
+
next_transport=httpx.HTTPTransport(
|
|
226
|
+
verify=verify,
|
|
227
|
+
cert=cert,
|
|
228
|
+
trust_env=trust_env,
|
|
229
|
+
http1=http1,
|
|
230
|
+
http2=http2,
|
|
231
|
+
limits=limits,
|
|
232
|
+
proxy=proxy,
|
|
233
|
+
),
|
|
234
|
+
storage=self.storage,
|
|
235
|
+
policy=self.policy,
|
|
236
|
+
)
|
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(
|
|
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: Path | None = None) -> Path:
|
|
111
|
+
_base_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)
|