boring-http 1.0.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.
- boring_http/__init__.py +58 -0
- boring_http/client.py +692 -0
- boring_http/codings.py +39 -0
- boring_http/cookies.py +127 -0
- boring_http/errors.py +49 -0
- boring_http/hpack.py +247 -0
- boring_http/hpack_tables.py +326 -0
- boring_http/http1.py +259 -0
- boring_http/http2.py +685 -0
- boring_http/http3.py +551 -0
- boring_http/message.py +598 -0
- boring_http/pool.py +96 -0
- boring_http/py.typed +0 -0
- boring_http/qpack.py +245 -0
- boring_http/qpack_tables.py +104 -0
- boring_http/quic/__init__.py +0 -0
- boring_http/quic/connection.py +1261 -0
- boring_http/quic/packet.py +655 -0
- boring_http/quic/protocol.py +474 -0
- boring_http/quic/recovery.py +303 -0
- boring_http/quic/streams.py +154 -0
- boring_http/timing.py +63 -0
- boring_http/websocket.py +216 -0
- boring_http-1.0.0.dist-info/METADATA +303 -0
- boring_http-1.0.0.dist-info/RECORD +26 -0
- boring_http-1.0.0.dist-info/WHEEL +4 -0
boring_http/message.py
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
import json as json_codec
|
|
2
|
+
import re
|
|
3
|
+
import secrets
|
|
4
|
+
from collections.abc import (
|
|
5
|
+
AsyncGenerator,
|
|
6
|
+
AsyncIterable,
|
|
7
|
+
AsyncIterator,
|
|
8
|
+
Iterator,
|
|
9
|
+
Mapping,
|
|
10
|
+
Sequence,
|
|
11
|
+
)
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from email.utils import parsedate_to_datetime
|
|
15
|
+
from enum import StrEnum
|
|
16
|
+
from functools import lru_cache
|
|
17
|
+
from time import monotonic, time
|
|
18
|
+
from typing import Final, Self, cast, final, overload, override
|
|
19
|
+
from urllib.parse import quote, urlencode, urljoin, urlsplit
|
|
20
|
+
|
|
21
|
+
from boring_http.codings import ContentDecoder
|
|
22
|
+
from boring_http.cookies import Cookie
|
|
23
|
+
from boring_http.errors import HTTPError
|
|
24
|
+
|
|
25
|
+
TOKEN: Final = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+")
|
|
26
|
+
PATH_SAFE: Final = "/%:@!$&'()*+,;=~-._"
|
|
27
|
+
QUERY_SAFE: Final = PATH_SAFE + "?"
|
|
28
|
+
CONNECTION_SPECIFIC: Final = frozenset(
|
|
29
|
+
{
|
|
30
|
+
"connection",
|
|
31
|
+
"keep-alive",
|
|
32
|
+
"proxy-connection",
|
|
33
|
+
"transfer-encoding",
|
|
34
|
+
"upgrade",
|
|
35
|
+
"host",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
BODY_METHODS: Final = frozenset({"POST", "PUT", "PATCH"})
|
|
39
|
+
REDIRECT_STATUSES: Final = frozenset({301, 302, 303, 307, 308})
|
|
40
|
+
DEFAULT_PORTS: Final = {"http": 80, "ws": 80, "https": 443, "wss": 443}
|
|
41
|
+
|
|
42
|
+
type HeaderItems = Mapping[str, str] | Sequence[tuple[str, str]] | Headers
|
|
43
|
+
type ParamValue = str | int | float | bool | None
|
|
44
|
+
type Params = Mapping[str, ParamValue] | Sequence[tuple[str, ParamValue]]
|
|
45
|
+
type FormFields = Mapping[str, str] | Sequence[tuple[str, str]]
|
|
46
|
+
type FileField = bytes | str | tuple[str, bytes | str] | tuple[str, bytes | str, str]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class HTTPVersion(StrEnum):
|
|
50
|
+
HTTP_1 = "http/1.1"
|
|
51
|
+
HTTP_2 = "h2"
|
|
52
|
+
HTTP_3 = "h3"
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
@override
|
|
56
|
+
def _missing_(cls, value: object) -> HTTPVersion | None:
|
|
57
|
+
spelled = str(value).lower().removeprefix("http/")
|
|
58
|
+
return {
|
|
59
|
+
"h1": cls.HTTP_1,
|
|
60
|
+
"1.1": cls.HTTP_1,
|
|
61
|
+
"1": cls.HTTP_1,
|
|
62
|
+
"2": cls.HTTP_2,
|
|
63
|
+
"3": cls.HTTP_3,
|
|
64
|
+
}.get(spelled)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@final
|
|
68
|
+
class Headers:
|
|
69
|
+
def __init__(self, items: HeaderItems | None = None) -> None:
|
|
70
|
+
self._items: list[tuple[str, str]] = []
|
|
71
|
+
self._lowered: list[str] = []
|
|
72
|
+
pairs = (
|
|
73
|
+
list(items.items())
|
|
74
|
+
if isinstance(items, Mapping)
|
|
75
|
+
else []
|
|
76
|
+
if items is None
|
|
77
|
+
else list(items)
|
|
78
|
+
)
|
|
79
|
+
for name, value in pairs:
|
|
80
|
+
self.add(name, value)
|
|
81
|
+
|
|
82
|
+
@overload
|
|
83
|
+
def __getitem__(self, key: int) -> tuple[str, str]: ...
|
|
84
|
+
|
|
85
|
+
@overload
|
|
86
|
+
def __getitem__(self, key: str) -> str: ...
|
|
87
|
+
|
|
88
|
+
def __getitem__(self, key: int | str) -> tuple[str, str] | str:
|
|
89
|
+
if isinstance(key, int):
|
|
90
|
+
return self._items[key]
|
|
91
|
+
value = self.get(key)
|
|
92
|
+
if value is None:
|
|
93
|
+
raise KeyError(key)
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
def __contains__(self, name: str) -> bool:
|
|
97
|
+
return self._index(name) is not None
|
|
98
|
+
|
|
99
|
+
def __iter__(self) -> Iterator[tuple[str, str]]:
|
|
100
|
+
return iter(self._items)
|
|
101
|
+
|
|
102
|
+
def __len__(self) -> int:
|
|
103
|
+
return len(self._items)
|
|
104
|
+
|
|
105
|
+
@override
|
|
106
|
+
def __eq__(self, other: object) -> bool:
|
|
107
|
+
return isinstance(other, Headers) and other._items == self._items
|
|
108
|
+
|
|
109
|
+
@override
|
|
110
|
+
def __repr__(self) -> str:
|
|
111
|
+
return f"Headers({self._items!r})"
|
|
112
|
+
|
|
113
|
+
@overload
|
|
114
|
+
def get(self, name: str) -> str | None: ...
|
|
115
|
+
|
|
116
|
+
@overload
|
|
117
|
+
def get(self, name: str, default: str) -> str: ...
|
|
118
|
+
|
|
119
|
+
def get(self, name: str, default: str | None = None) -> str | None:
|
|
120
|
+
index = self._index(name)
|
|
121
|
+
return default if index is None else self._items[index][1]
|
|
122
|
+
|
|
123
|
+
def get_all(self, name: str) -> list[str]:
|
|
124
|
+
lowered = name.lower()
|
|
125
|
+
return [
|
|
126
|
+
value
|
|
127
|
+
for (_, value), key in zip(self._items, self._lowered, strict=True)
|
|
128
|
+
if key == lowered
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
def add(self, name: str, value: str) -> None:
|
|
132
|
+
self._items.append(self._checked(name, value))
|
|
133
|
+
self._lowered.append(name.lower())
|
|
134
|
+
|
|
135
|
+
def set(self, name: str, value: str) -> None:
|
|
136
|
+
index = self._index(name)
|
|
137
|
+
if index is None:
|
|
138
|
+
self.add(name, value)
|
|
139
|
+
else:
|
|
140
|
+
self._items[index] = self._checked(name, value)
|
|
141
|
+
self._lowered[index] = name.lower()
|
|
142
|
+
|
|
143
|
+
def insert(self, index: int, name: str, value: str) -> None:
|
|
144
|
+
self._items.insert(index, self._checked(name, value))
|
|
145
|
+
self._lowered.insert(index, name.lower())
|
|
146
|
+
|
|
147
|
+
def remove(self, name: str) -> None:
|
|
148
|
+
lowered = name.lower()
|
|
149
|
+
self._items = [
|
|
150
|
+
pair
|
|
151
|
+
for pair, key in zip(self._items, self._lowered, strict=True)
|
|
152
|
+
if key != lowered
|
|
153
|
+
]
|
|
154
|
+
self._lowered = [key for key in self._lowered if key != lowered]
|
|
155
|
+
|
|
156
|
+
def copy(self) -> Self:
|
|
157
|
+
return type(self)(self._items)
|
|
158
|
+
|
|
159
|
+
def merged(self, other: Self) -> Self:
|
|
160
|
+
result = self.copy()
|
|
161
|
+
for lowered in dict.fromkeys(name.lower() for name, _ in other):
|
|
162
|
+
position = result._index(lowered)
|
|
163
|
+
result.remove(lowered)
|
|
164
|
+
replacements = [pair for pair in other if pair[0].lower() == lowered]
|
|
165
|
+
for offset, (name, value) in enumerate(replacements):
|
|
166
|
+
if position is None:
|
|
167
|
+
result.add(name, value)
|
|
168
|
+
else:
|
|
169
|
+
result.insert(position + offset, name, value)
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
def _index(self, name: str) -> int | None:
|
|
173
|
+
lowered = name.lower()
|
|
174
|
+
return self._lowered.index(lowered) if lowered in self._lowered else None
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _checked(name: str, value: str) -> tuple[str, str]:
|
|
178
|
+
if not TOKEN.fullmatch(name):
|
|
179
|
+
raise ValueError(f"invalid header name {name!r}")
|
|
180
|
+
value = value.strip()
|
|
181
|
+
if "\r" in value or "\n" in value or "\0" in value:
|
|
182
|
+
raise ValueError(f"invalid header value for {name}")
|
|
183
|
+
return name, value
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@final
|
|
187
|
+
@final
|
|
188
|
+
@dataclass(frozen=True, slots=True)
|
|
189
|
+
class URL:
|
|
190
|
+
scheme: str
|
|
191
|
+
host: str
|
|
192
|
+
port: int | None
|
|
193
|
+
path: str
|
|
194
|
+
query: str
|
|
195
|
+
fragment: str
|
|
196
|
+
username: str | None
|
|
197
|
+
password: str | None
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
@lru_cache(maxsize=512)
|
|
201
|
+
def parse(text: str) -> URL:
|
|
202
|
+
parts = urlsplit(text)
|
|
203
|
+
scheme = parts.scheme.lower()
|
|
204
|
+
if scheme not in DEFAULT_PORTS:
|
|
205
|
+
raise ValueError(f"unsupported URL scheme {parts.scheme!r} in {text!r}")
|
|
206
|
+
if not parts.hostname:
|
|
207
|
+
raise ValueError(f"URL without a host: {text!r}")
|
|
208
|
+
host = parts.hostname.encode("idna").decode("ascii")
|
|
209
|
+
return URL(
|
|
210
|
+
scheme,
|
|
211
|
+
host,
|
|
212
|
+
parts.port,
|
|
213
|
+
quote(parts.path, PATH_SAFE),
|
|
214
|
+
quote(parts.query, QUERY_SAFE),
|
|
215
|
+
parts.fragment,
|
|
216
|
+
parts.username,
|
|
217
|
+
parts.password,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def default_port(self) -> int:
|
|
222
|
+
return DEFAULT_PORTS[self.scheme]
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def secure(self) -> bool:
|
|
226
|
+
return self.scheme in ("https", "wss")
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def address(self) -> tuple[str, int]:
|
|
230
|
+
return self.host, self.port or self.default_port
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def authority(self) -> str:
|
|
234
|
+
host = f"[{self.host}]" if ":" in self.host else self.host
|
|
235
|
+
return (
|
|
236
|
+
host
|
|
237
|
+
if self.port is None or self.port == self.default_port
|
|
238
|
+
else f"{host}:{self.port}"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def origin(self) -> str:
|
|
243
|
+
return f"{self.scheme}://{self.authority}"
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def target(self) -> str:
|
|
247
|
+
return (self.path or "/") + (f"?{self.query}" if self.query else "")
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def text(self) -> str:
|
|
251
|
+
return f"{self.origin}{self.target}" + (
|
|
252
|
+
f"#{self.fragment}" if self.fragment else ""
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
@override
|
|
256
|
+
def __str__(self) -> str:
|
|
257
|
+
return self.text
|
|
258
|
+
|
|
259
|
+
def join(self, reference: str) -> URL:
|
|
260
|
+
return URL.parse(urljoin(self.text, reference))
|
|
261
|
+
|
|
262
|
+
def with_params(self, params: Params) -> URL:
|
|
263
|
+
pairs = params.items() if isinstance(params, Mapping) else params
|
|
264
|
+
rendered = [
|
|
265
|
+
(name, self._param(value)) for name, value in pairs if value is not None
|
|
266
|
+
]
|
|
267
|
+
query = "&".join(part for part in (self.query, urlencode(rendered)) if part)
|
|
268
|
+
return URL(
|
|
269
|
+
self.scheme,
|
|
270
|
+
self.host,
|
|
271
|
+
self.port,
|
|
272
|
+
self.path,
|
|
273
|
+
query,
|
|
274
|
+
self.fragment,
|
|
275
|
+
self.username,
|
|
276
|
+
self.password,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
@staticmethod
|
|
280
|
+
def _param(value: str | float | bool) -> str:
|
|
281
|
+
if isinstance(value, bool):
|
|
282
|
+
return "true" if value else "false"
|
|
283
|
+
return str(value)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@final
|
|
287
|
+
class Body:
|
|
288
|
+
def __init__(
|
|
289
|
+
self,
|
|
290
|
+
data: bytes | None = None,
|
|
291
|
+
stream: AsyncIterable[bytes] | None = None,
|
|
292
|
+
content_type: str | None = None,
|
|
293
|
+
) -> None:
|
|
294
|
+
self.data = data
|
|
295
|
+
self.stream = stream
|
|
296
|
+
self.content_type = content_type
|
|
297
|
+
|
|
298
|
+
@classmethod
|
|
299
|
+
def json(cls, value: object) -> Self:
|
|
300
|
+
return cls(
|
|
301
|
+
json_codec.dumps(value, separators=(",", ":"), ensure_ascii=False).encode(),
|
|
302
|
+
content_type="application/json",
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
@classmethod
|
|
306
|
+
def form(cls, fields: FormFields) -> Self:
|
|
307
|
+
pairs = fields.items() if isinstance(fields, Mapping) else fields
|
|
308
|
+
return cls(
|
|
309
|
+
urlencode(list(pairs)).encode(),
|
|
310
|
+
content_type="application/x-www-form-urlencoded",
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
@classmethod
|
|
314
|
+
def content(cls, value: bytes | str | AsyncIterable[bytes]) -> Self:
|
|
315
|
+
match value:
|
|
316
|
+
case bytes():
|
|
317
|
+
return cls(value)
|
|
318
|
+
case str():
|
|
319
|
+
return cls(value.encode())
|
|
320
|
+
case _:
|
|
321
|
+
return cls(stream=value)
|
|
322
|
+
|
|
323
|
+
@classmethod
|
|
324
|
+
def multipart(
|
|
325
|
+
cls, fields: FormFields | None, files: Mapping[str, FileField]
|
|
326
|
+
) -> Self:
|
|
327
|
+
boundary = secrets.token_hex(16)
|
|
328
|
+
parts: list[bytes] = []
|
|
329
|
+
pairs = fields.items() if isinstance(fields, Mapping) else fields or ()
|
|
330
|
+
for name, value in pairs:
|
|
331
|
+
parts.append(
|
|
332
|
+
f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode()
|
|
333
|
+
)
|
|
334
|
+
for name, field in files.items():
|
|
335
|
+
match field:
|
|
336
|
+
case (filename, content, content_type):
|
|
337
|
+
pass
|
|
338
|
+
case (filename, content):
|
|
339
|
+
content_type = "application/octet-stream"
|
|
340
|
+
case _:
|
|
341
|
+
filename, content, content_type = (
|
|
342
|
+
name,
|
|
343
|
+
field,
|
|
344
|
+
"application/octet-stream",
|
|
345
|
+
)
|
|
346
|
+
head = f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"; filename="{filename}"\r\nContent-Type: {content_type}\r\n\r\n'
|
|
347
|
+
parts.append(
|
|
348
|
+
head.encode()
|
|
349
|
+
+ (content.encode() if isinstance(content, str) else content)
|
|
350
|
+
+ b"\r\n"
|
|
351
|
+
)
|
|
352
|
+
parts.append(f"--{boundary}--\r\n".encode())
|
|
353
|
+
return cls(
|
|
354
|
+
b"".join(parts), content_type=f"multipart/form-data; boundary={boundary}"
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
@property
|
|
358
|
+
def length(self) -> int | None:
|
|
359
|
+
return None if self.data is None else len(self.data)
|
|
360
|
+
|
|
361
|
+
@property
|
|
362
|
+
def replayable(self) -> bool:
|
|
363
|
+
return self.data is not None
|
|
364
|
+
|
|
365
|
+
async def chunks(self) -> AsyncIterator[bytes]:
|
|
366
|
+
if self.data is not None:
|
|
367
|
+
if self.data:
|
|
368
|
+
yield self.data
|
|
369
|
+
elif self.stream is not None:
|
|
370
|
+
async for chunk in self.stream:
|
|
371
|
+
yield chunk
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@final
|
|
375
|
+
@dataclass(frozen=True, slots=True)
|
|
376
|
+
class Request:
|
|
377
|
+
method: str
|
|
378
|
+
url: URL
|
|
379
|
+
headers: Headers
|
|
380
|
+
body: Body | None = None
|
|
381
|
+
|
|
382
|
+
def wire_headers(self, version: HTTPVersion) -> Headers:
|
|
383
|
+
if version is HTTPVersion.HTTP_1:
|
|
384
|
+
wire = (
|
|
385
|
+
Headers()
|
|
386
|
+
if "host" in self.headers
|
|
387
|
+
else Headers([("Host", self.url.authority)])
|
|
388
|
+
)
|
|
389
|
+
for name, value in self.headers:
|
|
390
|
+
wire.add(name, value)
|
|
391
|
+
if "content-length" not in wire and "transfer-encoding" not in wire:
|
|
392
|
+
if self.body is None:
|
|
393
|
+
if self.method in BODY_METHODS:
|
|
394
|
+
wire.add("Content-Length", "0")
|
|
395
|
+
elif self.body.length is None:
|
|
396
|
+
wire.add("Transfer-Encoding", "chunked")
|
|
397
|
+
else:
|
|
398
|
+
wire.add("Content-Length", str(self.body.length))
|
|
399
|
+
return wire
|
|
400
|
+
wire = Headers()
|
|
401
|
+
for name, value in self.headers:
|
|
402
|
+
lowered = name.lower()
|
|
403
|
+
if lowered in CONNECTION_SPECIFIC or (
|
|
404
|
+
lowered == "te" and value.lower() != "trailers"
|
|
405
|
+
):
|
|
406
|
+
continue
|
|
407
|
+
wire.add(lowered, value)
|
|
408
|
+
if (
|
|
409
|
+
self.body is not None
|
|
410
|
+
and self.body.length is not None
|
|
411
|
+
and "content-length" not in wire
|
|
412
|
+
):
|
|
413
|
+
wire.add("content-length", str(self.body.length))
|
|
414
|
+
return wire
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@final
|
|
418
|
+
class Response:
|
|
419
|
+
def __init__(
|
|
420
|
+
self,
|
|
421
|
+
*,
|
|
422
|
+
status: int,
|
|
423
|
+
reason: str,
|
|
424
|
+
version: HTTPVersion,
|
|
425
|
+
headers: Headers,
|
|
426
|
+
request: Request,
|
|
427
|
+
raw: AsyncGenerator[bytes],
|
|
428
|
+
trailers: Headers,
|
|
429
|
+
decode: bool,
|
|
430
|
+
) -> None:
|
|
431
|
+
self.status = status
|
|
432
|
+
self.reason = reason
|
|
433
|
+
self.version = version
|
|
434
|
+
self.headers = headers
|
|
435
|
+
self.request = request
|
|
436
|
+
self.trailers = trailers
|
|
437
|
+
self.history: tuple[Response, ...] = ()
|
|
438
|
+
self.started = monotonic()
|
|
439
|
+
self.elapsed = 0.0
|
|
440
|
+
self._raw = raw
|
|
441
|
+
self._decode = decode
|
|
442
|
+
self._body: bytes | None = None
|
|
443
|
+
self._consumed = False
|
|
444
|
+
|
|
445
|
+
@override
|
|
446
|
+
def __repr__(self) -> str:
|
|
447
|
+
return f"<Response {self.status} {self.reason} {self.version} {self.url}>"
|
|
448
|
+
|
|
449
|
+
@property
|
|
450
|
+
def url(self) -> URL:
|
|
451
|
+
return self.request.url
|
|
452
|
+
|
|
453
|
+
@property
|
|
454
|
+
def ok(self) -> bool:
|
|
455
|
+
return self.status < 400
|
|
456
|
+
|
|
457
|
+
@property
|
|
458
|
+
def is_redirect(self) -> bool:
|
|
459
|
+
return self.status in REDIRECT_STATUSES and "location" in self.headers
|
|
460
|
+
|
|
461
|
+
@property
|
|
462
|
+
def location(self) -> URL | None:
|
|
463
|
+
location = self.headers.get("location")
|
|
464
|
+
if location is None:
|
|
465
|
+
return None
|
|
466
|
+
try:
|
|
467
|
+
return self.url.join(location)
|
|
468
|
+
except ValueError:
|
|
469
|
+
# a Location that is not a URL is no redirect target, the same way an unparseable Date is no date
|
|
470
|
+
return None
|
|
471
|
+
|
|
472
|
+
@property
|
|
473
|
+
def content_type(self) -> str | None:
|
|
474
|
+
value = self.headers.get("content-type")
|
|
475
|
+
return None if value is None else value.split(";", 1)[0].strip().lower()
|
|
476
|
+
|
|
477
|
+
@property
|
|
478
|
+
def charset(self) -> str | None:
|
|
479
|
+
value = self.headers.get("content-type", "")
|
|
480
|
+
for parameter in value.split(";")[1:]:
|
|
481
|
+
name, _, argument = parameter.strip().partition("=")
|
|
482
|
+
if name.lower() == "charset":
|
|
483
|
+
return argument.strip().strip('"').lower()
|
|
484
|
+
return None
|
|
485
|
+
|
|
486
|
+
@property
|
|
487
|
+
def content_length(self) -> int | None:
|
|
488
|
+
value = self.headers.get("content-length")
|
|
489
|
+
return (
|
|
490
|
+
int(value)
|
|
491
|
+
if value is not None and value.isascii() and value.isdigit()
|
|
492
|
+
else None
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
@property
|
|
496
|
+
def content_encoding(self) -> tuple[str, ...]:
|
|
497
|
+
value = self.headers.get("content-encoding")
|
|
498
|
+
return (
|
|
499
|
+
()
|
|
500
|
+
if value is None
|
|
501
|
+
else tuple(
|
|
502
|
+
coding.strip().lower() for coding in value.split(",") if coding.strip()
|
|
503
|
+
)
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
@property
|
|
507
|
+
def cookies(self) -> tuple[Cookie, ...]:
|
|
508
|
+
parsed = (
|
|
509
|
+
Cookie.parse(value, self.url.host, self.url.path or "/")
|
|
510
|
+
for value in self.headers.get_all("set-cookie")
|
|
511
|
+
)
|
|
512
|
+
return tuple(cookie for cookie in parsed if cookie is not None)
|
|
513
|
+
|
|
514
|
+
@property
|
|
515
|
+
def retry_after(self) -> float | None:
|
|
516
|
+
value = self.headers.get("retry-after")
|
|
517
|
+
if value is None:
|
|
518
|
+
return None
|
|
519
|
+
if value.isascii() and value.isdigit():
|
|
520
|
+
return float(value)
|
|
521
|
+
date = self._date(value)
|
|
522
|
+
return None if date is None else max(0.0, date.timestamp() - time())
|
|
523
|
+
|
|
524
|
+
@property
|
|
525
|
+
def etag(self) -> str | None:
|
|
526
|
+
return self.headers.get("etag")
|
|
527
|
+
|
|
528
|
+
@property
|
|
529
|
+
def last_modified(self) -> datetime | None:
|
|
530
|
+
return self._date(self.headers.get("last-modified"))
|
|
531
|
+
|
|
532
|
+
@property
|
|
533
|
+
def date(self) -> datetime | None:
|
|
534
|
+
return self._date(self.headers.get("date"))
|
|
535
|
+
|
|
536
|
+
@property
|
|
537
|
+
def body(self) -> bytes:
|
|
538
|
+
if self._body is None:
|
|
539
|
+
raise HTTPError(
|
|
540
|
+
"the body has not been read; use await response.read() or iterate aiter_bytes()"
|
|
541
|
+
)
|
|
542
|
+
return self._body
|
|
543
|
+
|
|
544
|
+
@property
|
|
545
|
+
def text(self) -> str:
|
|
546
|
+
return self.body.decode(self.charset or "utf-8", "replace")
|
|
547
|
+
|
|
548
|
+
def json(self) -> object:
|
|
549
|
+
return cast(object, json_codec.loads(self.body))
|
|
550
|
+
|
|
551
|
+
def raise_for_status(self) -> None:
|
|
552
|
+
if self.status >= 400:
|
|
553
|
+
raise HTTPStatusError(self)
|
|
554
|
+
|
|
555
|
+
async def read(self) -> bytes:
|
|
556
|
+
if self._body is None:
|
|
557
|
+
self._body = b"".join([chunk async for chunk in self.aiter_bytes()])
|
|
558
|
+
self.elapsed = monotonic() - self.started
|
|
559
|
+
return self._body
|
|
560
|
+
|
|
561
|
+
async def aiter_raw(self) -> AsyncIterator[bytes]:
|
|
562
|
+
if self._consumed:
|
|
563
|
+
raise HTTPError("the body stream was already consumed")
|
|
564
|
+
self._consumed = True
|
|
565
|
+
async for chunk in self._raw:
|
|
566
|
+
yield chunk
|
|
567
|
+
|
|
568
|
+
async def aiter_bytes(self) -> AsyncIterator[bytes]:
|
|
569
|
+
codings = self.content_encoding if self._decode else ()
|
|
570
|
+
decoder = ContentDecoder(codings)
|
|
571
|
+
async for chunk in self.aiter_raw():
|
|
572
|
+
decoded = decoder.decode(chunk)
|
|
573
|
+
if decoded:
|
|
574
|
+
yield decoded
|
|
575
|
+
|
|
576
|
+
async def close(self) -> None:
|
|
577
|
+
await self._raw.aclose()
|
|
578
|
+
|
|
579
|
+
async def __aenter__(self) -> Self:
|
|
580
|
+
return self
|
|
581
|
+
|
|
582
|
+
async def __aexit__(self, *_: object) -> None:
|
|
583
|
+
await self.close()
|
|
584
|
+
|
|
585
|
+
@staticmethod
|
|
586
|
+
def _date(value: str | None) -> datetime | None:
|
|
587
|
+
if value is None:
|
|
588
|
+
return None
|
|
589
|
+
try:
|
|
590
|
+
return parsedate_to_datetime(value)
|
|
591
|
+
except ValueError, TypeError:
|
|
592
|
+
return None
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
class HTTPStatusError(HTTPError):
|
|
596
|
+
def __init__(self, response: Response) -> None:
|
|
597
|
+
super().__init__(f"{response.status} {response.reason} for {response.url}")
|
|
598
|
+
self.response: Response = response
|
boring_http/pool.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import Awaitable, Callable
|
|
3
|
+
from typing import Protocol, final
|
|
4
|
+
|
|
5
|
+
from ssl2 import Proxy
|
|
6
|
+
|
|
7
|
+
from boring_http.message import URL, Request, Response
|
|
8
|
+
from boring_http.timing import Deadline
|
|
9
|
+
|
|
10
|
+
type Key = tuple[str, str, int, Proxy | None, bool]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Connection(Protocol):
|
|
14
|
+
closed: bool
|
|
15
|
+
reused: bool
|
|
16
|
+
released: Callable[[], None]
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def available(self) -> bool: ...
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def shared(self) -> bool: ...
|
|
23
|
+
|
|
24
|
+
def take(self) -> None: ...
|
|
25
|
+
|
|
26
|
+
async def send(
|
|
27
|
+
self, request: Request, deadline: Deadline, *, decode: bool
|
|
28
|
+
) -> Response: ...
|
|
29
|
+
|
|
30
|
+
async def close(self) -> None: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
type Connector = Callable[[URL, Proxy | None, bool], Awaitable[Connection]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@final
|
|
37
|
+
class Pool:
|
|
38
|
+
def __init__(
|
|
39
|
+
self, connector: Connector, max_connections: int, max_connections_per_host: int
|
|
40
|
+
) -> None:
|
|
41
|
+
self._connector = connector
|
|
42
|
+
self._per_host = max_connections_per_host
|
|
43
|
+
self._total = asyncio.Semaphore(max_connections)
|
|
44
|
+
self._limits: dict[Key, asyncio.Semaphore] = {}
|
|
45
|
+
self._locks: dict[Key, asyncio.Lock] = {}
|
|
46
|
+
self._changes: dict[Key, asyncio.Event] = {}
|
|
47
|
+
self._connections: dict[Key, list[Connection]] = {}
|
|
48
|
+
|
|
49
|
+
async def acquire(
|
|
50
|
+
self, url: URL, proxy: Proxy | None, deadline: Deadline, *, quic: bool = False
|
|
51
|
+
) -> Connection:
|
|
52
|
+
key: Key = (url.scheme, url.host, url.port or url.default_port, proxy, quic)
|
|
53
|
+
limit = self._limits.setdefault(key, asyncio.Semaphore(self._per_host))
|
|
54
|
+
changed = self._changes.setdefault(key, asyncio.Event())
|
|
55
|
+
async with self._locks.setdefault(key, asyncio.Lock()):
|
|
56
|
+
while True:
|
|
57
|
+
for connection in self._connections.get(key, []):
|
|
58
|
+
if connection.available:
|
|
59
|
+
connection.take()
|
|
60
|
+
connection.reused = True
|
|
61
|
+
return connection
|
|
62
|
+
if not limit.locked() and not self._total.locked():
|
|
63
|
+
break
|
|
64
|
+
# every connection is busy and no new one may open: wait for a stream to finish or a connection to close
|
|
65
|
+
changed.clear()
|
|
66
|
+
async with deadline.phase("connect"):
|
|
67
|
+
_ = await changed.wait()
|
|
68
|
+
async with deadline.phase("connect"):
|
|
69
|
+
_ = await limit.acquire()
|
|
70
|
+
_ = await self._total.acquire()
|
|
71
|
+
try:
|
|
72
|
+
connection = await self._connector(url, proxy, quic)
|
|
73
|
+
except BaseException:
|
|
74
|
+
limit.release()
|
|
75
|
+
self._total.release()
|
|
76
|
+
raise
|
|
77
|
+
connection.take()
|
|
78
|
+
connection.released = lambda: self._changed(key, connection, limit)
|
|
79
|
+
self._connections.setdefault(key, []).append(connection)
|
|
80
|
+
return connection
|
|
81
|
+
|
|
82
|
+
async def close(self) -> None:
|
|
83
|
+
for connections in list(self._connections.values()):
|
|
84
|
+
for connection in list(connections):
|
|
85
|
+
await connection.close()
|
|
86
|
+
self._connections.clear()
|
|
87
|
+
|
|
88
|
+
def _changed(
|
|
89
|
+
self, key: Key, connection: Connection, limit: asyncio.Semaphore
|
|
90
|
+
) -> None:
|
|
91
|
+
connections = self._connections.get(key, [])
|
|
92
|
+
if connection.closed and connection in connections:
|
|
93
|
+
connections.remove(connection)
|
|
94
|
+
limit.release()
|
|
95
|
+
self._total.release()
|
|
96
|
+
self._changes[key].set()
|
boring_http/py.typed
ADDED
|
File without changes
|