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/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