syshttp 0.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.
@@ -0,0 +1,170 @@
1
+ """The idle connections of a transport: one per thread and server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import collections as _collections
6
+ import itertools as _itertools
7
+ import socket as _socket
8
+ import threading as _threading
9
+ import typing as _ty
10
+ import weakref as _weakref
11
+
12
+ MAX_IDLE_PER_THREAD = 8
13
+ """Most idle connections one thread keeps open; opening another closes the one
14
+ used longest ago."""
15
+
16
+
17
+ class Connection:
18
+ """An open `http.client.HTTPConnection` and what the pool knows of it."""
19
+
20
+ __slots__ = ("http", "key", "scope", "released_at", "generation", "__weakref__")
21
+
22
+ def __init__(self, http, key: tuple, scope: int, generation: int):
23
+ self.http = http
24
+ self.key = key
25
+ self.scope = scope
26
+ self.generation = generation
27
+ self.released_at = 0.0
28
+
29
+ def close(self, interrupt: bool = False) -> None:
30
+ """Closes the socket. `interrupt` also shuts it down first, which wakes
31
+ a thread blocked on it."""
32
+ http = self.http
33
+ sock = getattr(http, "sock", None)
34
+ try:
35
+ if interrupt and sock is not None:
36
+ try:
37
+ sock.shutdown(_socket.SHUT_RDWR)
38
+ except OSError:
39
+ pass
40
+ finally:
41
+ try:
42
+ http.close()
43
+ except Exception:
44
+ pass
45
+
46
+
47
+ _SCOPE_IDS = _itertools.count(1)
48
+ _THREAD = _threading.local()
49
+ _LIVE_SCOPES: "set[int]" = set()
50
+ _POOLS: "_weakref.WeakSet[Pool]" = _weakref.WeakSet()
51
+
52
+
53
+ def _drop_scope(scope: int) -> None:
54
+ _LIVE_SCOPES.discard(scope)
55
+ for pool in list(_POOLS):
56
+ pool.drop_scope(scope)
57
+
58
+
59
+ class _ThreadScope:
60
+ """Held in the thread's own storage, so it is released when the thread ends
61
+ and takes the thread's idle connections with it."""
62
+
63
+ def __init__(self):
64
+ self.id = next(_SCOPE_IDS)
65
+ _LIVE_SCOPES.add(self.id)
66
+ _weakref.finalize(self, _drop_scope, self.id).atexit = False
67
+
68
+
69
+ def scope_id() -> int:
70
+ """The calling thread's scope. Not the thread identifier, which the
71
+ operating system reuses for a later thread."""
72
+ scope = getattr(_THREAD, "scope", None)
73
+ if scope is None:
74
+ scope = _THREAD.scope = _ThreadScope()
75
+ return scope.id
76
+
77
+
78
+ class Pool:
79
+ """Idle connections by (thread scope, server), and every connection a
80
+ transport has open, so `close()` reaches the ones in use too. A connection
81
+ is taken out of the pool for the length of a response and given back once
82
+ the body has been read."""
83
+
84
+ def __init__(self):
85
+ self._lock = _threading.Lock()
86
+ self._idle: "dict[int, _collections.OrderedDict[tuple, Connection]]" = {}
87
+ self._open: "set[Connection]" = set()
88
+ self._generation = 0
89
+ _POOLS.add(self)
90
+
91
+ @property
92
+ def generation(self) -> int:
93
+ return self._generation
94
+
95
+ def take(self, key: tuple, scope: int) -> "Connection | None":
96
+ """The idle connection of the calling thread to `key`, removed from the
97
+ pool, or `None`."""
98
+ with self._lock:
99
+ idle = self._idle.get(scope)
100
+ return idle.pop(key, None) if idle else None
101
+
102
+ def has_idle(self, key: tuple, scope: int) -> bool:
103
+ """Whether the calling thread has an idle connection to `key`."""
104
+ with self._lock:
105
+ return key in self._idle.get(scope, ())
106
+
107
+ def register(self, connection: Connection) -> None:
108
+ with self._lock:
109
+ self._open.add(connection)
110
+
111
+ def give_back(self, connection: Connection, now: float) -> None:
112
+ """Keeps `connection` idle for the next request of its thread, or
113
+ closes it when its thread has ended, the transport was closed since it
114
+ was opened, or the thread already holds one to the same server."""
115
+ connection.released_at = now
116
+ evicted = []
117
+ with self._lock:
118
+ keep = connection.scope in _LIVE_SCOPES and connection.generation == self._generation
119
+ if keep:
120
+ idle = self._idle.setdefault(connection.scope, _collections.OrderedDict())
121
+ if connection.key in idle:
122
+ keep = False
123
+ else:
124
+ idle[connection.key] = connection
125
+ while len(idle) > MAX_IDLE_PER_THREAD:
126
+ evicted.append(idle.popitem(last=False)[1])
127
+ if not keep:
128
+ self._open.discard(connection)
129
+ evicted.append(connection)
130
+ for old in evicted:
131
+ self._open.discard(old)
132
+ for old in evicted:
133
+ old.close()
134
+
135
+ def discard(self, connection: Connection) -> None:
136
+ """Closes a connection that is not to be used again."""
137
+ with self._lock:
138
+ self._open.discard(connection)
139
+ connection.close()
140
+
141
+ def drop_scope(self, scope: int) -> None:
142
+ with self._lock:
143
+ idle: "dict[tuple, Connection]" = self._idle.pop(scope, {})
144
+ for connection in idle.values():
145
+ self._open.discard(connection)
146
+ for connection in list(idle.values()):
147
+ connection.close()
148
+
149
+ def close(self) -> None:
150
+ """Closes every connection, idle or in use. Harmless when called again;
151
+ a later request connects afresh."""
152
+ with self._lock:
153
+ self._generation += 1
154
+ doomed = list(self._open)
155
+ self._open.clear()
156
+ self._idle.clear()
157
+ for connection in doomed:
158
+ connection.close(interrupt=True)
159
+
160
+
161
+ def close_pool(pool: Pool) -> None:
162
+ pool.close()
163
+
164
+
165
+ def finalize_with(owner: object, pool: Pool) -> "_ty.Any":
166
+ """Closes `pool` when `owner` is collected, so a transport that was never
167
+ closed does not leave sockets for the interpreter to report."""
168
+ handle = _weakref.finalize(owner, close_pool, pool)
169
+ handle.atexit = False # type: ignore[misc]
170
+ return handle
@@ -0,0 +1,77 @@
1
+ """What a transport starts from: a request that passed the checks every transport
2
+ holds itself to."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import typing as _ty
7
+
8
+ from ._body import prepare_body as _prepare_body
9
+ from ._headers import (
10
+ check_body as _check_body,
11
+ check_headers as _check_headers,
12
+ check_method as _check_method,
13
+ check_timeout as _check_timeout,
14
+ check_url as _check_url,
15
+ has_header as _has_header,
16
+ wants_decoding as _wants_decoding,
17
+ )
18
+
19
+ # The methods whose request says `Content-Length: 0` when it has no body.
20
+ _EXPECTING_BODY = frozenset({"PATCH", "POST", "PUT"})
21
+
22
+
23
+ class PreparedRequest(_ty.NamedTuple):
24
+ """A request that passed the checks of the transport contract.
25
+
26
+ `headers` hold text values, with the `Content-Length` of a body whose
27
+ length is known (`0` for a `PATCH`, `POST` or `PUT` without a body) and an
28
+ `Accept-Encoding` of `gzip, deflate` unless the caller named one. `body` is
29
+ `None`, `bytes`, an object that sends exactly the `Content-Length` bytes
30
+ when it is read or iterated, or an iterable of `bytes` to send chunked.
31
+ `timeout` is `(connect, read)`. `decode` says whether the response decodes
32
+ a `gzip` or `deflate` body: build it with `HTTPResponse(...,
33
+ decode=decode)`."""
34
+
35
+ method: str
36
+ url: str
37
+ headers: "dict[str, str]"
38
+ body: _ty.Any
39
+ timeout: "tuple[float | None, float | None]"
40
+ decode: bool
41
+
42
+
43
+ def prepare_request(
44
+ method: str,
45
+ url: str,
46
+ *,
47
+ headers: "_ty.Mapping[str, str] | None",
48
+ body: "bytes | _ty.BinaryIO | _ty.Iterable[bytes] | None" = None,
49
+ timeout: "float | tuple | None",
50
+ ) -> PreparedRequest:
51
+ """The arguments of `send()` as the contract reads them.
52
+
53
+ `method` is upper-cased. Raises `ValueError`, before anything is sent, for
54
+ a method or header name that is not an HTTP token, a header value or URL
55
+ with a control character, a URL with credentials or another scheme, a
56
+ `Content-Length` that is not a count of bytes or exceeds the body, and a
57
+ `timeout` that is not a positive number, `None` or a pair of them;
58
+ `TypeError` for a `str` body. None of the messages quotes the URL or a
59
+ header value. The fragment is dropped from `url`."""
60
+ method = _check_method(method)
61
+ _check_url(url)
62
+ _check_body(body)
63
+ timeout = _check_timeout(timeout)
64
+ pairs = _check_headers(headers)
65
+ sent = dict(pairs)
66
+ body, length = _prepare_body(body, pairs)
67
+ if length is not None:
68
+ sent["Content-Length"] = str(length)
69
+ elif not _has_header(sent, "Content-Length"):
70
+ if isinstance(body, (bytes, bytearray, memoryview)):
71
+ sent["Content-Length"] = str(memoryview(body).nbytes)
72
+ elif body is None and method in _EXPECTING_BODY:
73
+ sent["Content-Length"] = "0"
74
+ decode = _wants_decoding(sent.items())
75
+ if not _has_header(sent, "Accept-Encoding"):
76
+ sent["Accept-Encoding"] = "gzip, deflate"
77
+ return PreparedRequest(method, url.split("#", 1)[0], sent, body, timeout, decode)
@@ -0,0 +1,301 @@
1
+ """The response every transport returns, with the decoding they share."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import codecs as _codecs
6
+ import email.message as _message
7
+ import errno as _errno
8
+ import json as _json
9
+ import typing as _ty
10
+ import zlib as _zlib
11
+
12
+ from ..exceptions import ResponseError
13
+ from . import _settings
14
+ from ._headers import Headers, peer_text, shown
15
+
16
+ CHUNK = 64 * 1024
17
+ """Most bytes read from a transport's body in one call."""
18
+
19
+ _STEP = 256 * 1024
20
+ """Most decoded bytes one decompression step produces. The compressed bytes
21
+ that are left wait for the next step, so a body that expands a thousandfold
22
+ is never held whole because its header said it was small."""
23
+
24
+
25
+ def too_large(what: object, limit: int) -> str:
26
+ """The message of the `OSError(EFBIG)` for a reply longer than `limit` bytes."""
27
+ return f"The reply for {peer_text(what, 300)} is larger than {limit} bytes (MAX_REPLY_BYTES)"
28
+
29
+
30
+ class _Memory:
31
+ """A body that is already in memory."""
32
+
33
+ def __init__(self, data):
34
+ self._data = memoryview(bytes(data))
35
+ self._at = 0
36
+
37
+ def read(self, amount: int) -> bytes:
38
+ chunk = bytes(self._data[self._at : self._at + amount])
39
+ self._at += len(chunk)
40
+ return chunk
41
+
42
+ def close(self) -> None:
43
+ pass
44
+
45
+
46
+ class _Inflater:
47
+ """Decompresses one `gzip` or `deflate` body in bounded steps. `deflate` is
48
+ read as zlib-wrapped data, or as raw data when the zlib header is absent,
49
+ which is what servers send under that name; concatenated gzip members are
50
+ one body."""
51
+
52
+ def __init__(self, gzip: bool):
53
+ self._gzip = gzip
54
+ self._object: _ty.Any = None
55
+ self._input = b""
56
+ self._members = 0
57
+
58
+ @property
59
+ def has_input(self) -> bool:
60
+ return bool(self._input)
61
+
62
+ def feed(self, data: bytes) -> None:
63
+ self._input += data
64
+
65
+ def _new(self, data: bytes):
66
+ if self._gzip:
67
+ return _zlib.decompressobj(16 + _zlib.MAX_WBITS)
68
+ wrapped = len(data) < 2 or (data[0] & 0x0F == 8 and ((data[0] << 8) | data[1]) % 31 == 0)
69
+ return _zlib.decompressobj(_zlib.MAX_WBITS if wrapped else -_zlib.MAX_WBITS)
70
+
71
+ def step(self) -> bytes:
72
+ data = self._input
73
+ if self._object is None:
74
+ self._object = self._new(data)
75
+ try:
76
+ out = self._object.decompress(data, _STEP)
77
+ except _zlib.error:
78
+ if self._members:
79
+ # Bytes after the last complete member are not a body.
80
+ self._input, self._object = b"", None
81
+ return b""
82
+ kind = "gzip" if self._gzip else "deflate"
83
+ raise OSError(_errno.EIO, f"the response body is not valid {kind} data") from None
84
+ if self._object.eof:
85
+ self._members += 1
86
+ self._input = self._object.unused_data
87
+ self._object = None
88
+ else:
89
+ self._input = self._object.unconsumed_tail
90
+ if not out and len(self._input) >= len(data):
91
+ self._input = b""
92
+ return out
93
+
94
+ def finish(self) -> None:
95
+ if self._object is not None:
96
+ raise OSError(_errno.EIO, "the compressed response body ended early")
97
+
98
+
99
+ def _inflater_for(headers: Headers) -> "_Inflater | None":
100
+ names = [
101
+ name.strip().lower()
102
+ for name in headers.get("Content-Encoding", "").split(",")
103
+ if name.strip().lower() not in ("", "identity")
104
+ ]
105
+ if len(names) != 1:
106
+ return None
107
+ if names[0] in ("gzip", "x-gzip"):
108
+ return _Inflater(gzip=True)
109
+ if names[0] == "deflate":
110
+ return _Inflater(gzip=False)
111
+ return None
112
+
113
+
114
+ class HTTPResponse:
115
+ """One response: `status`, `reason`, `headers` (a read-only mapping that
116
+ ignores the case of a name), `url` (the URL the request went to) and the
117
+ body, read with `read()` or `iter_bytes()`. A context manager that closes.
118
+
119
+ The body is decoded when the response says it is `gzip` or `deflate`
120
+ encoded, unless the request set its own `Accept-Encoding`; `headers` are
121
+ the headers as received, so `Content-Length` counts the encoded bytes. A
122
+ body that ends before the length the server stated is `OSError(EIO)`, never
123
+ a shorter body that reads as complete.
124
+
125
+ A transport builds one with `body` as `bytes`, or as an object with
126
+ `read(amount) -> bytes` (empty at the end) and `close()`, and
127
+ `decode=False` for a body it already decoded or that was not asked for
128
+ in a coding."""
129
+
130
+ status: int
131
+ reason: str
132
+ headers: Headers
133
+ url: str
134
+
135
+ def __init__(
136
+ self,
137
+ status: int,
138
+ reason: str,
139
+ headers: "_ty.Mapping[str, str] | _ty.Iterable[tuple[str, str]]",
140
+ url: str,
141
+ body: "bytes | _ty.Any" = b"",
142
+ *,
143
+ decode: bool = True,
144
+ ):
145
+ self._closed = False
146
+ self._ended = False
147
+ self.status = int(status)
148
+ self.reason = reason or ""
149
+ self.headers = headers if isinstance(headers, Headers) else Headers(headers)
150
+ self.url = url
151
+ self._kept = False
152
+ self._content: "bytes | None" = None
153
+ if isinstance(body, (bytes, bytearray, memoryview)):
154
+ body = _Memory(body)
155
+ self._kept = True # in memory: readable after close
156
+ self._source = body
157
+ self._inflater = _inflater_for(self.headers) if decode else None
158
+ self._leftover = b""
159
+
160
+ def __repr__(self) -> str:
161
+ return f"HTTPResponse({self.status}, {self.reason!r})"
162
+
163
+ def __enter__(self) -> "HTTPResponse":
164
+ return self
165
+
166
+ def __exit__(self, *exc_info) -> None:
167
+ self.close()
168
+
169
+ def __del__(self):
170
+ try:
171
+ self.close()
172
+ except Exception:
173
+ pass
174
+
175
+ def _check_open(self) -> None:
176
+ if self._closed and not self._kept:
177
+ raise ValueError("read of a closed response")
178
+
179
+ def _next(self, limit: int = CHUNK) -> bytes:
180
+ """The next decoded bytes of the body; empty at its end. Without a
181
+ coding, no more than `limit` bytes are asked of the transport."""
182
+ if self._ended:
183
+ return b""
184
+ self._check_open()
185
+ inflater = self._inflater
186
+ while True:
187
+ if inflater is not None and inflater.has_input:
188
+ out = inflater.step()
189
+ if out:
190
+ return out
191
+ continue
192
+ out = self._source.read(CHUNK if inflater else min(CHUNK, limit))
193
+ if not out:
194
+ if inflater is not None:
195
+ inflater.finish()
196
+ self._ended = True
197
+ return b""
198
+ if inflater is None:
199
+ return out
200
+ inflater.feed(out)
201
+
202
+ def read(self, size: "int | None" = -1) -> bytes:
203
+ """Up to `size` bytes of the decoded body, fewer only at its end; the
204
+ whole rest of it when `size` is negative or `None`."""
205
+ self._check_open()
206
+ parts = []
207
+ have = 0
208
+ while size is None or size < 0 or have < size:
209
+ wanted = CHUNK if size is None or size < 0 else size - have
210
+ chunk = self._leftover or self._next(wanted)
211
+ if not chunk:
212
+ break
213
+ if size is not None and 0 <= size < have + len(chunk):
214
+ self._leftover = chunk[size - have :]
215
+ chunk = chunk[: size - have]
216
+ else:
217
+ self._leftover = b""
218
+ parts.append(chunk)
219
+ have += len(chunk)
220
+ return b"".join(parts)
221
+
222
+ def iter_bytes(self, chunk_size: int = CHUNK) -> "_ty.Iterator[bytes]":
223
+ """The rest of the body in pieces of at most `chunk_size` bytes, each
224
+ as soon as it has arrived."""
225
+ self._check_open()
226
+ while True:
227
+ chunk = self._leftover or self._next(chunk_size)
228
+ if not chunk:
229
+ return
230
+ self._leftover = chunk[chunk_size:]
231
+ yield chunk[:chunk_size]
232
+
233
+ @property
234
+ def content(self) -> bytes:
235
+ """The rest of the decoded body, read on first use and kept, so that
236
+ `content`, `text()` and `json()` agree however often they are used. A
237
+ body longer than `MAX_REPLY_BYTES` is `OSError(EFBIG)`. Bytes already
238
+ taken with `read()` or `iter_bytes()` are not in it."""
239
+ if self._content is None:
240
+ limit = _settings.MAX_REPLY_BYTES
241
+ self._buffered(limit, too_large(shown(self.url), limit))
242
+ self._content = self.read()
243
+ return self._content
244
+
245
+ def text(self, encoding: "str | None" = None, errors: str = "replace") -> str:
246
+ """`content` decoded with `encoding`, else the `charset` of the
247
+ `Content-Type` header, else UTF-8; characters that do not decode are
248
+ replaced unless `errors` says otherwise."""
249
+ charset = encoding or self._charset() or "utf-8"
250
+ try:
251
+ _codecs.lookup(charset)
252
+ except LookupError:
253
+ charset = "utf-8"
254
+ return self.content.decode(charset, errors)
255
+
256
+ def json(self) -> _ty.Any:
257
+ """`content` parsed as JSON; `None` for an empty body. A body that is
258
+ not JSON is `ResponseError` (a `ValueError`) naming the status."""
259
+ data = self.content
260
+ if not data.strip():
261
+ return None
262
+ try:
263
+ return _json.loads(data)
264
+ except ValueError as error:
265
+ raise ResponseError(f"response was not valid JSON (HTTP {self.status})") from error
266
+
267
+ def _charset(self) -> "str | None":
268
+ message = _message.Message()
269
+ message["Content-Type"] = self.headers.get("Content-Type", "")
270
+ return message.get_content_charset()
271
+
272
+ def close(self) -> None:
273
+ """Releases the connection. Harmless when called again. A body that was
274
+ not read to its end is abandoned, and its connection is closed."""
275
+ if not getattr(self, "_closed", True):
276
+ self._closed = True
277
+ self._source.close()
278
+
279
+ def buffered(self, limit: "int | None" = None) -> "HTTPResponse":
280
+ """Reads the rest of the body into memory, closes the connection and
281
+ returns this response, which can then be read after it is closed. A
282
+ transport returns its response this way when `send()` is called with
283
+ `stream=False`. A body that decodes to more than `limit` bytes (default
284
+ `MAX_REPLY_BYTES`) is `OSError(EFBIG)`, and its connection is closed."""
285
+ limit = _settings.MAX_REPLY_BYTES if limit is None else limit
286
+ return self._buffered(limit, too_large(shown(self.url), limit))
287
+
288
+ def _buffered(self, limit: "int | None" = None, message: str = "") -> "HTTPResponse":
289
+ """Reads the rest of the body into memory and closes the source, so the
290
+ response can be read after the connection has been given back. A
291
+ decoded body longer than `limit` bytes is `OSError(EFBIG)` with
292
+ `message`, and its connection is closed."""
293
+ try:
294
+ data = self.read(-1 if limit is None else limit + 1)
295
+ if limit is not None and len(data) > limit:
296
+ raise OSError(_errno.EFBIG, message or f"larger than {limit} bytes")
297
+ self._leftover = data
298
+ self._kept = True
299
+ finally:
300
+ self.close()
301
+ return self
@@ -0,0 +1,75 @@
1
+ """The route of a request: what the proxy map answers, narrowed to the entries
2
+ the transport speaks and to the first one that is reachable."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import base64 as _base64
7
+ import typing as _ty
8
+
9
+ import proxylib
10
+
11
+ DEFAULT_PORTS = {"http": 80, "https": 443, "prefix+http": 80, "prefix+https": 443}
12
+ """The proxy schemes the transport speaks, and their ports."""
13
+
14
+ PROBE_TIMEOUT = 5.0
15
+ """Most seconds spent finding out whether one proxy of an answer accepts a
16
+ connection."""
17
+
18
+ Entry = _ty.Optional["proxylib.Proxy"]
19
+
20
+
21
+ def entries(proxymap: _ty.Any, url: str) -> "tuple[Entry, ...]":
22
+ """The entries of `proxymap`'s answer for `url` that the transport can use,
23
+ in order (`None` is a direct connection); `(None,)` when the map has no
24
+ opinion. `proxylib.UnsupportedProxyError` when it names only proxies the
25
+ transport cannot speak (SOCKS)."""
26
+ try:
27
+ answer = proxylib.lookup(proxymap, url)
28
+ except KeyError:
29
+ return (None,)
30
+ usable: "list[Entry]" = []
31
+ refused: "list[tuple[proxylib.Proxy, str]]" = []
32
+ for entry in answer:
33
+ why = None if entry is None else _refusal(entry, url)
34
+ if why is None:
35
+ usable.append(entry)
36
+ else:
37
+ refused.append((_ty.cast("proxylib.Proxy", entry), why))
38
+ if usable:
39
+ return tuple(usable)
40
+ raise proxylib.UnsupportedProxyError("syshttp.client", refused)
41
+
42
+
43
+ def _refusal(entry: "proxylib.Proxy", url: str) -> "str | None":
44
+ """Why the transport does not send `url` through `entry`, or `None`."""
45
+ if entry.scheme not in DEFAULT_PORTS:
46
+ return "only http and https proxies and URL-prefix gateways are spoken"
47
+ if entry.scheme == "prefix+http" and url[:6].lower() == "https:":
48
+ return "an https origin's request would cross a plain-http gateway in clear text"
49
+ return None
50
+
51
+
52
+ def reachable(candidates: "tuple[Entry, ...]", url: str, connect_timeout: "float | None") -> Entry:
53
+ """The first of `candidates` that accepts a TCP connection (a direct entry
54
+ always does). `proxylib.NoReachableProxyError` when none does."""
55
+ timeout = PROBE_TIMEOUT if connect_timeout is None else min(connect_timeout, PROBE_TIMEOUT)
56
+ return proxylib.lookup(proxylib.Probed(proxylib.SimpleProxyMap(candidates), timeout=timeout), url)[0]
57
+
58
+
59
+ def port(proxy: "proxylib.Proxy") -> int:
60
+ """The port a connection to `proxy` is made to."""
61
+ return proxy.port or DEFAULT_PORTS[proxy.scheme]
62
+
63
+
64
+ def credentials(proxy: "proxylib.Proxy") -> "list[tuple[str, str]]":
65
+ """The headers a request to `proxy` carries for it: its own headers, and
66
+ its userinfo as Basic `Proxy-Authorization` unless one is among them. A
67
+ value that is not ASCII is `ValueError`, without the value."""
68
+ found = list(proxy.headers.items())
69
+ if proxy.username is not None and not any(name.lower() == "proxy-authorization" for name, _ in found):
70
+ token = f"{proxy.username}:{proxy.password or ''}".encode("utf-8")
71
+ found.append(("Proxy-Authorization", "Basic " + _base64.b64encode(token).decode("ascii")))
72
+ for name, value in found:
73
+ if not value.isascii():
74
+ raise ValueError(f"the value of the proxy header {name} is not ASCII")
75
+ return found
@@ -0,0 +1,12 @@
1
+ """The module-level setting of the HTTP client. Code reads it as an attribute
2
+ of this module, so assigning it applies at once."""
3
+
4
+ from __future__ import annotations
5
+
6
+ MAX_REPLY_BYTES = 8 * 1024 * 1024
7
+ """Most bytes of a reply the library reads whole, after any content decoding:
8
+ what `HTTPClient.request(stream=False)` returns, and a reply read for what it
9
+ says (`read_limited()`, `json_body()`). A longer body raises `OSError(EFBIG)`
10
+ naming the request and closes the connection. The body of a reply nobody looks
11
+ at is read up to this many bytes and dropped (`drop_body()`). Raise it for a
12
+ document that is legitimately larger. Read at the time of each use."""