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/codings.py ADDED
@@ -0,0 +1,39 @@
1
+ import zlib
2
+ from collections.abc import Callable, Sequence
3
+ from compression import zstd
4
+ from typing import final
5
+
6
+ from boring_ssl2 import BrotliDecoder
7
+
8
+ from boring_http.errors import ProtocolError
9
+
10
+
11
+ @final
12
+ class ContentDecoder:
13
+ def __init__(self, codings: Sequence[str]) -> None:
14
+ self._codings = tuple(reversed(codings))
15
+ self._decoders = [self._decoder(coding) for coding in self._codings]
16
+
17
+ def decode(self, data: bytes) -> bytes:
18
+ for coding, decoder in zip(self._codings, self._decoders, strict=True):
19
+ try:
20
+ data = decoder(data)
21
+ except (zlib.error, ValueError, zstd.ZstdError) as error:
22
+ raise ProtocolError(f"the {coding} body is corrupt: {error}") from error
23
+ return data
24
+
25
+ @staticmethod
26
+ def _decoder(coding: str) -> Callable[[bytes], bytes]:
27
+ match coding:
28
+ case "gzip" | "x-gzip":
29
+ return zlib.decompressobj(16 + zlib.MAX_WBITS).decompress
30
+ case "deflate":
31
+ return zlib.decompressobj().decompress
32
+ case "br":
33
+ return BrotliDecoder().decode
34
+ case "zstd":
35
+ return zstd.ZstdDecompressor().decompress
36
+ case "identity":
37
+ return bytes
38
+ case _:
39
+ raise ProtocolError(f"unsupported content coding {coding!r}")
boring_http/cookies.py ADDED
@@ -0,0 +1,127 @@
1
+ import time
2
+ from collections.abc import Iterable, Iterator
3
+ from dataclasses import dataclass
4
+ from email.utils import parsedate_to_datetime
5
+ from typing import Self, final
6
+
7
+
8
+ @final
9
+ @dataclass(frozen=True, slots=True)
10
+ class Cookie:
11
+ name: str
12
+ value: str
13
+ domain: str
14
+ path: str
15
+ expires: float | None
16
+ secure: bool
17
+ http_only: bool
18
+ host_only: bool
19
+ created: float
20
+
21
+ @classmethod
22
+ def parse(cls, header: str, host: str, request_path: str) -> Self | None:
23
+ pair, _, rest = header.partition(";")
24
+ name, equals, value = pair.strip().partition("=")
25
+ if not equals or not name.strip():
26
+ return None
27
+ domain, path, expires, secure, http_only = "", "", None, False, False
28
+ for attribute in rest.split(";"):
29
+ key, _, argument = attribute.strip().partition("=")
30
+ match key.lower():
31
+ case "domain":
32
+ domain = argument.strip().lstrip(".").lower()
33
+ case "path":
34
+ path = argument.strip()
35
+ case "max-age" if (
36
+ argument.strip().lstrip("-").isascii()
37
+ and argument.strip().lstrip("-").isdigit()
38
+ ):
39
+ expires = time.time() + int(argument.strip())
40
+ case "expires" if expires is None:
41
+ expires = cls._date(argument.strip())
42
+ case "secure":
43
+ secure = True
44
+ case "httponly":
45
+ http_only = True
46
+ case _:
47
+ pass
48
+ if domain and not (host == domain or host.endswith("." + domain)):
49
+ return None
50
+ if not path.startswith("/"):
51
+ path = request_path[: request_path.rfind("/")] or "/"
52
+ return cls(
53
+ name.strip(),
54
+ value.strip().strip('"'),
55
+ domain or host,
56
+ path,
57
+ expires,
58
+ secure,
59
+ http_only,
60
+ not domain,
61
+ time.time(),
62
+ )
63
+
64
+ @property
65
+ def expired(self) -> bool:
66
+ return self.expires is not None and self.expires <= time.time()
67
+
68
+ def matches(self, host: str, path: str, secure: bool) -> bool:
69
+ if self.host_only:
70
+ domain_ok = host == self.domain
71
+ else:
72
+ domain_ok = host == self.domain or host.endswith("." + self.domain)
73
+ path_ok = path == self.path or (
74
+ path.startswith(self.path)
75
+ and (self.path.endswith("/") or path[len(self.path)] == "/")
76
+ )
77
+ return (
78
+ domain_ok and path_ok and (secure or not self.secure) and not self.expired
79
+ )
80
+
81
+ @staticmethod
82
+ def _date(text: str) -> float | None:
83
+ try:
84
+ return parsedate_to_datetime(text).timestamp()
85
+ except ValueError, TypeError:
86
+ return None
87
+
88
+
89
+ @final
90
+ class CookieJar:
91
+ def __init__(self) -> None:
92
+ self._cookies: dict[tuple[str, str, str], Cookie] = {}
93
+
94
+ def __iter__(self) -> Iterator[Cookie]:
95
+ return iter(tuple(self._cookies.values()))
96
+
97
+ def __len__(self) -> int:
98
+ return len(self._cookies)
99
+
100
+ def get(self, name: str, domain: str | None = None) -> str | None:
101
+ for cookie in self._cookies.values():
102
+ if cookie.name == name and (domain is None or cookie.domain == domain):
103
+ return cookie.value
104
+ return None
105
+
106
+ def set(self, cookie: Cookie) -> None:
107
+ key = (cookie.domain, cookie.path, cookie.name)
108
+ if cookie.expired:
109
+ _ = self._cookies.pop(key, None)
110
+ else:
111
+ self._cookies[key] = cookie
112
+
113
+ def store(self, cookies: Iterable[Cookie]) -> None:
114
+ for cookie in cookies:
115
+ self.set(cookie)
116
+
117
+ def clear(self) -> None:
118
+ self._cookies.clear()
119
+
120
+ def header(self, host: str, path: str, secure: bool) -> str | None:
121
+ matching = [
122
+ cookie
123
+ for cookie in self._cookies.values()
124
+ if cookie.matches(host, path, secure)
125
+ ]
126
+ matching.sort(key=lambda cookie: (-len(cookie.path), cookie.created))
127
+ return "; ".join(f"{cookie.name}={cookie.value}" for cookie in matching) or None
boring_http/errors.py ADDED
@@ -0,0 +1,49 @@
1
+ import builtins
2
+
3
+
4
+ class HTTPError(Exception):
5
+ pass
6
+
7
+
8
+ class ConnectError(HTTPError):
9
+ pass
10
+
11
+
12
+ class RemoteDisconnected(ConnectError):
13
+ pass
14
+
15
+
16
+ class StreamRefused(RemoteDisconnected):
17
+ # the server never processed the request, so sending it again is safe
18
+ pass
19
+
20
+
21
+ class StreamResetError(RemoteDisconnected):
22
+ def __init__(self, stream_id: int, code: int) -> None:
23
+ super().__init__(f"stream {stream_id} reset by the server with error {code:#x}")
24
+ self.stream_id: int = stream_id
25
+ self.code: int = code
26
+
27
+
28
+ class TimeoutError(HTTPError, builtins.TimeoutError):
29
+ def __init__(self, phase: str, seconds: float) -> None:
30
+ super().__init__(f"{phase} timed out after {seconds:g} seconds")
31
+ self.phase: str = phase
32
+ self.seconds: float = seconds
33
+
34
+
35
+ class ProtocolError(HTTPError):
36
+ pass
37
+
38
+
39
+ class TooManyRedirects(HTTPError):
40
+ pass
41
+
42
+
43
+ class WebSocketClosed(HTTPError):
44
+ def __init__(self, code: int, reason: str) -> None:
45
+ super().__init__(
46
+ f"websocket closed with code {code}{': ' + reason if reason else ''}"
47
+ )
48
+ self.code: int = code
49
+ self.reason: str = reason
boring_http/hpack.py ADDED
@@ -0,0 +1,247 @@
1
+ from collections.abc import Iterable
2
+ from typing import Final, final
3
+
4
+ from boring_http.errors import ProtocolError
5
+ from boring_http.hpack_tables import HUFFMAN_CODES, STATIC_TABLE
6
+
7
+ NEVER_INDEXED: Final = frozenset({b"authorization", b"proxy-authorization"})
8
+ ENTRY_OVERHEAD: Final = 32
9
+ DEFAULT_CAPACITY: Final = 4096
10
+ EOS: Final = 256
11
+ DECODE_TABLE: Final = {
12
+ (length, code): symbol for symbol, (code, length) in enumerate(HUFFMAN_CODES)
13
+ }
14
+ STATIC_PAIRS: Final = {pair: index for index, pair in enumerate(STATIC_TABLE, 1)}
15
+ STATIC_NAMES: Final = {
16
+ name: index for index, (name, _) in reversed(list(enumerate(STATIC_TABLE, 1)))
17
+ }
18
+
19
+
20
+ @final
21
+ class BlockWriter:
22
+ def __init__(self) -> None:
23
+ self._data = bytearray()
24
+
25
+ def integer(self, value: int, prefix_bits: int, first_byte: int) -> None:
26
+ limit = (1 << prefix_bits) - 1
27
+ if value < limit:
28
+ self._data.append(first_byte | value)
29
+ return
30
+ self._data.append(first_byte | limit)
31
+ value -= limit
32
+ while value >= 128:
33
+ self._data.append(value % 128 + 128)
34
+ value //= 128
35
+ self._data.append(value)
36
+
37
+ def string(self, value: bytes, prefix_bits: int = 7, pattern: int = 0x00) -> None:
38
+ encoded = self.huffman(value)
39
+ if len(encoded) < len(value):
40
+ self.integer(len(encoded), prefix_bits, pattern | (1 << prefix_bits))
41
+ self._data += encoded
42
+ else:
43
+ self.integer(len(value), prefix_bits, pattern)
44
+ self._data += value
45
+
46
+ def finish(self) -> bytes:
47
+ return bytes(self._data)
48
+
49
+ @staticmethod
50
+ def huffman(data: bytes) -> bytes:
51
+ out = bytearray()
52
+ bits, count = 0, 0
53
+ for byte in data:
54
+ code, length = HUFFMAN_CODES[byte]
55
+ bits = (bits << length) | code
56
+ count += length
57
+ while count >= 8:
58
+ count -= 8
59
+ out.append((bits >> count) & 0xFF)
60
+ bits &= (1 << count) - 1
61
+ if count:
62
+ out.append(((bits << (8 - count)) | ((1 << (8 - count)) - 1)) & 0xFF)
63
+ return bytes(out)
64
+
65
+
66
+ @final
67
+ class BlockReader:
68
+ def __init__(self, data: bytes) -> None:
69
+ self._data = data
70
+ self._position = 0
71
+
72
+ @property
73
+ def remaining(self) -> int:
74
+ return len(self._data) - self._position
75
+
76
+ def peek(self) -> int:
77
+ if self._position >= len(self._data):
78
+ raise ProtocolError("HPACK block ended inside a field")
79
+ return self._data[self._position]
80
+
81
+ def integer(self, prefix_bits: int) -> int:
82
+ limit = (1 << prefix_bits) - 1
83
+ value = self._byte() & limit
84
+ if value < limit:
85
+ return value
86
+ shift = 0
87
+ while True:
88
+ byte = self._byte()
89
+ value += (byte & 0x7F) << shift
90
+ shift += 7
91
+ if not byte & 0x80:
92
+ return value
93
+ if shift > 62:
94
+ raise ProtocolError("HPACK integer too large")
95
+
96
+ def string(self, prefix_bits: int = 7) -> bytes:
97
+ compressed = bool(self.peek() & (1 << prefix_bits))
98
+ length = self.integer(prefix_bits)
99
+ if length > self.remaining:
100
+ raise ProtocolError("HPACK string runs past the block")
101
+ value = self._data[self._position : self._position + length]
102
+ self._position += length
103
+ return self.huffman(value) if compressed else value
104
+
105
+ def _byte(self) -> int:
106
+ if self._position >= len(self._data):
107
+ raise ProtocolError("HPACK block ended inside a field")
108
+ value = self._data[self._position]
109
+ self._position += 1
110
+ return value
111
+
112
+ @staticmethod
113
+ def huffman(data: bytes) -> bytes:
114
+ out = bytearray()
115
+ code, length = 0, 0
116
+ for byte in data:
117
+ for bit in range(7, -1, -1):
118
+ code = (code << 1) | ((byte >> bit) & 1)
119
+ length += 1
120
+ symbol = DECODE_TABLE.get((length, code))
121
+ if symbol is not None:
122
+ if symbol == EOS:
123
+ raise ProtocolError("EOS inside HPACK huffman data")
124
+ out.append(symbol)
125
+ code, length = 0, 0
126
+ if length > 7 or code != (1 << length) - 1:
127
+ raise ProtocolError("invalid HPACK huffman padding")
128
+ return bytes(out)
129
+
130
+
131
+ @final
132
+ class Encoder:
133
+ def __init__(self) -> None:
134
+ self._table: list[tuple[bytes, bytes]] = []
135
+ self._size = 0
136
+ self._capacity = DEFAULT_CAPACITY
137
+ self._announce: int | None = None
138
+
139
+ def set_capacity(self, capacity: int) -> None:
140
+ capacity = min(capacity, DEFAULT_CAPACITY)
141
+ if capacity != self._capacity:
142
+ self._capacity = capacity
143
+ self._announce = capacity
144
+ self._evict()
145
+
146
+ def encode(self, headers: Iterable[tuple[bytes, bytes]]) -> bytes:
147
+ writer = BlockWriter()
148
+ if self._announce is not None:
149
+ writer.integer(self._announce, 5, 0x20)
150
+ self._announce = None
151
+ for name, value in headers:
152
+ pair_index, name_index = self._find(name, value)
153
+ if pair_index is not None:
154
+ writer.integer(pair_index, 7, 0x80)
155
+ continue
156
+ if name in NEVER_INDEXED:
157
+ writer.integer(name_index or 0, 4, 0x10)
158
+ else:
159
+ writer.integer(name_index or 0, 6, 0x40)
160
+ if name_index is None:
161
+ writer.string(name)
162
+ writer.string(value)
163
+ if name not in NEVER_INDEXED:
164
+ self._add(name, value)
165
+ return writer.finish()
166
+
167
+ def _find(self, name: bytes, value: bytes) -> tuple[int | None, int | None]:
168
+ pair_index = STATIC_PAIRS.get((name, value))
169
+ if pair_index is not None:
170
+ return pair_index, pair_index
171
+ name_index = STATIC_NAMES.get(name)
172
+ for position, entry in enumerate(self._table):
173
+ if entry == (name, value):
174
+ return len(STATIC_TABLE) + 1 + position, None
175
+ if name_index is None and entry[0] == name:
176
+ name_index = len(STATIC_TABLE) + 1 + position
177
+ return None, name_index
178
+
179
+ def _add(self, name: bytes, value: bytes) -> None:
180
+ self._table.insert(0, (name, value))
181
+ self._size += len(name) + len(value) + ENTRY_OVERHEAD
182
+ self._evict()
183
+
184
+ def _evict(self) -> None:
185
+ while self._size > self._capacity:
186
+ name, value = self._table.pop()
187
+ self._size -= len(name) + len(value) + ENTRY_OVERHEAD
188
+
189
+
190
+ @final
191
+ class Decoder:
192
+ def __init__(self, capacity: int, max_header_list_size: int) -> None:
193
+ self._table: list[tuple[bytes, bytes]] = []
194
+ self._size = 0
195
+ self._capacity = capacity
196
+ self._limit = capacity
197
+ self._max_list = max_header_list_size
198
+
199
+ def decode(self, block: bytes) -> list[tuple[bytes, bytes]]:
200
+ reader = BlockReader(block)
201
+ headers: list[tuple[bytes, bytes]] = []
202
+ total = 0
203
+ while reader.remaining:
204
+ first = reader.peek()
205
+ if first & 0x80:
206
+ name, value = self._lookup(reader.integer(7))
207
+ elif first & 0x40:
208
+ index = reader.integer(6)
209
+ name = self._lookup(index)[0] if index else reader.string()
210
+ value = reader.string()
211
+ self._add(name, value)
212
+ elif first & 0x20:
213
+ size = reader.integer(5)
214
+ if size > self._limit:
215
+ raise ProtocolError(
216
+ f"HPACK table size update {size} exceeds the advertised {self._limit}"
217
+ )
218
+ self._capacity = size
219
+ self._evict()
220
+ continue
221
+ else:
222
+ index = reader.integer(4)
223
+ name = self._lookup(index)[0] if index else reader.string()
224
+ value = reader.string()
225
+ headers.append((name, value))
226
+ total += len(name) + len(value) + ENTRY_OVERHEAD
227
+ if total > self._max_list:
228
+ raise ProtocolError(f"header list exceeds {self._max_list} bytes")
229
+ return headers
230
+
231
+ def _lookup(self, index: int) -> tuple[bytes, bytes]:
232
+ if 1 <= index <= len(STATIC_TABLE):
233
+ return STATIC_TABLE[index - 1]
234
+ position = index - len(STATIC_TABLE) - 1
235
+ if 0 <= position < len(self._table):
236
+ return self._table[position]
237
+ raise ProtocolError(f"HPACK index {index} is not in the table")
238
+
239
+ def _add(self, name: bytes, value: bytes) -> None:
240
+ self._table.insert(0, (name, value))
241
+ self._size += len(name) + len(value) + ENTRY_OVERHEAD
242
+ self._evict()
243
+
244
+ def _evict(self) -> None:
245
+ while self._size > self._capacity:
246
+ name, value = self._table.pop()
247
+ self._size -= len(name) + len(value) + ENTRY_OVERHEAD