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.
- syshttp/AGENTS.md +817 -0
- syshttp/README.md +177 -0
- syshttp/__init__.py +71 -0
- syshttp/_fileio.py +78 -0
- syshttp/_parse.py +30 -0
- syshttp/auth.py +17 -0
- syshttp/autopatch.py +382 -0
- syshttp/client/__init__.py +70 -0
- syshttp/client/_body.py +156 -0
- syshttp/client/_client.py +413 -0
- syshttp/client/_context.py +107 -0
- syshttp/client/_deadline.py +94 -0
- syshttp/client/_default.py +72 -0
- syshttp/client/_fetch.py +98 -0
- syshttp/client/_follow.py +243 -0
- syshttp/client/_headers.py +238 -0
- syshttp/client/_pool.py +170 -0
- syshttp/client/_prepare.py +77 -0
- syshttp/client/_response.py +301 -0
- syshttp/client/_route.py +75 -0
- syshttp/client/_settings.py +12 -0
- syshttp/client/_state.py +322 -0
- syshttp/client/_status.py +59 -0
- syshttp/client/_tlstunnel.py +114 -0
- syshttp/client/_transport.py +550 -0
- syshttp/cookies.py +391 -0
- syshttp/env.py +144 -0
- syshttp/exceptions.py +29 -0
- syshttp/hooks.py +314 -0
- syshttp/jar.py +133 -0
- syshttp/proxy.py +341 -0
- syshttp/py.typed +0 -0
- syshttp/retry.py +34 -0
- syshttp/tls.py +60 -0
- syshttp-0.1.0.dist-info/METADATA +219 -0
- syshttp-0.1.0.dist-info/RECORD +39 -0
- syshttp-0.1.0.dist-info/WHEEL +4 -0
- syshttp-0.1.0.dist-info/entry_points.txt +6 -0
- syshttp-0.1.0.dist-info/licenses/LICENSE +21 -0
syshttp/client/_pool.py
ADDED
|
@@ -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
|
syshttp/client/_route.py
ADDED
|
@@ -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."""
|