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/_fetch.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""`fetch()`: one request, one response, through a client made for the call or
|
|
2
|
+
one the caller keeps."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import collections.abc as _abc
|
|
7
|
+
import json as _json
|
|
8
|
+
import typing as _ty
|
|
9
|
+
import urllib.parse as _urlparse
|
|
10
|
+
|
|
11
|
+
from ._client import DEFAULT_TIMEOUT, HTTPClient
|
|
12
|
+
from ._headers import has_header
|
|
13
|
+
from ._response import HTTPResponse
|
|
14
|
+
|
|
15
|
+
REAUTH_STATUSES = (401, 403)
|
|
16
|
+
"""The statuses after which `fetch(reauth=...)` runs `reauth` and sends again."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _body(data: _ty.Any, json: _ty.Any, headers: "_ty.Mapping[str, str] | None") -> "tuple[_ty.Any, dict[str, str]]":
|
|
20
|
+
"""`data` or `json` as the body to send, and `headers` with the
|
|
21
|
+
`Content-Type` that goes with it unless they name one."""
|
|
22
|
+
if data is not None and json is not None:
|
|
23
|
+
raise ValueError("data and json are two bodies: give one")
|
|
24
|
+
sent = dict(headers or {})
|
|
25
|
+
if json is not None:
|
|
26
|
+
body: _ty.Any = _json.dumps(json).encode("utf-8")
|
|
27
|
+
kind = "application/json"
|
|
28
|
+
elif isinstance(data, _abc.Mapping):
|
|
29
|
+
body = _urlparse.urlencode(data, doseq=True).encode("ascii")
|
|
30
|
+
kind = "application/x-www-form-urlencoded"
|
|
31
|
+
else:
|
|
32
|
+
return data, sent
|
|
33
|
+
if not has_header(sent, "Content-Type"):
|
|
34
|
+
sent["Content-Type"] = kind
|
|
35
|
+
return body, sent
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _reauthenticate(reauth: _ty.Any, client: HTTPClient, url: str) -> None:
|
|
39
|
+
ensure = getattr(reauth, "ensure", None)
|
|
40
|
+
if callable(ensure):
|
|
41
|
+
ensure(client, url)
|
|
42
|
+
else:
|
|
43
|
+
reauth(client, url)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def fetch(
|
|
47
|
+
url: str,
|
|
48
|
+
method: str = "GET",
|
|
49
|
+
*,
|
|
50
|
+
headers: "_ty.Mapping[str, str] | None" = None,
|
|
51
|
+
data: _ty.Any = None,
|
|
52
|
+
json: _ty.Any = None,
|
|
53
|
+
timeout: "float | tuple | None" = DEFAULT_TIMEOUT,
|
|
54
|
+
client: "HTTPClient | None" = None,
|
|
55
|
+
transport: _ty.Any = None,
|
|
56
|
+
reauth: _ty.Any = None,
|
|
57
|
+
**client_options: _ty.Any,
|
|
58
|
+
) -> HTTPResponse:
|
|
59
|
+
"""Send `method` to `url` and return the response with its body read whole
|
|
60
|
+
(`MAX_REPLY_BYTES` bounds it), so the caller takes what it needs from it:
|
|
61
|
+
`.status`, `.headers`, `.content`, `.text()` or `.json()`. A status is never
|
|
62
|
+
an exception: `raise_for_status()` is the caller's choice.
|
|
63
|
+
|
|
64
|
+
`data` is bytes, text, a mapping (form-encoded) or a binary file; `json` a
|
|
65
|
+
value sent as JSON (`None` sends none: use `data=b"null"` for that);
|
|
66
|
+
giving both is `ValueError`. `timeout` is seconds or a `(connect, read)`
|
|
67
|
+
pair. The request goes through `client` when given (left open), else
|
|
68
|
+
through a new `HTTPClient(transport, **client_options)` closed after the
|
|
69
|
+
call: `transport` is any object implementing the transport protocol (`None`:
|
|
70
|
+
the `SYSHTTP_DEFAULT_TRANSPORT` setting's, else syshttp's own), and
|
|
71
|
+
`client_options` are the other keywords of `HTTPClient` (`env`, `proxymap`,
|
|
72
|
+
`ssl_context`, `retries`, ...). A `client` beside `transport` or
|
|
73
|
+
`client_options` is `ValueError`: a given client already has them.
|
|
74
|
+
|
|
75
|
+
`reauth` is called as `reauth(client, url)` (an object with
|
|
76
|
+
`ensure(client, url)`, an `AuthProvider`, is called through that) once after
|
|
77
|
+
a 401 or 403, and the request is sent one more time; its errors are the
|
|
78
|
+
caller's. The body must then be one that can be sent twice (bytes, text, a
|
|
79
|
+
mapping or `json`)."""
|
|
80
|
+
if client is not None and (transport is not None or client_options):
|
|
81
|
+
raise ValueError(
|
|
82
|
+
"a client was given, and it has its transport and settings already: "
|
|
83
|
+
"pass transport and client options to fetch() alone, or set them on the client"
|
|
84
|
+
)
|
|
85
|
+
body, sent = _body(data, json, headers)
|
|
86
|
+
if reauth is not None and not (body is None or isinstance(body, (bytes, bytearray, memoryview, str))):
|
|
87
|
+
raise ValueError("reauth sends the request twice: the body must be bytes, text, a mapping or json")
|
|
88
|
+
own = client is None
|
|
89
|
+
used = HTTPClient(transport, **client_options) if client is None else client
|
|
90
|
+
try:
|
|
91
|
+
response = used.request(method, url, data=body, headers=sent, timeout=timeout)
|
|
92
|
+
if reauth is not None and response.status in REAUTH_STATUSES:
|
|
93
|
+
_reauthenticate(reauth, used, url)
|
|
94
|
+
response = used.request(method, url, data=body, headers=sent, timeout=timeout)
|
|
95
|
+
return response
|
|
96
|
+
finally:
|
|
97
|
+
if own:
|
|
98
|
+
used.close()
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""What a backend does above a transport: credentials, redirects and the
|
|
2
|
+
errors it raises for a redirect it will not follow."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import base64 as _base64
|
|
7
|
+
import errno as _errno
|
|
8
|
+
import re as _re
|
|
9
|
+
import urllib.parse as _urlparse
|
|
10
|
+
|
|
11
|
+
from ._headers import check_url, drop_header, peer_text, shown
|
|
12
|
+
from ._response import HTTPResponse
|
|
13
|
+
|
|
14
|
+
MAX_REDIRECTS = 30
|
|
15
|
+
"""Most redirects a `GET`, `HEAD` or `OPTIONS` follows."""
|
|
16
|
+
|
|
17
|
+
CREDENTIAL_HEADERS = ("Authorization", "Cookie", "Proxy-Authorization")
|
|
18
|
+
"""The headers a caller keeps a secret in, which a redirect to another scheme,
|
|
19
|
+
host or port leaves off, for that hop and every later one. The cookies of the
|
|
20
|
+
client's jar are chosen again for each hop."""
|
|
21
|
+
|
|
22
|
+
FOLLOWED_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
23
|
+
"""Methods whose redirects are followed. Every other method goes through
|
|
24
|
+
`send_once()`: following would turn a state-changing one into a different
|
|
25
|
+
request, and a PROPFIND into a GET or one without its body."""
|
|
26
|
+
|
|
27
|
+
RESENT_STATUSES = {"PROPFIND": (301, 302, 303, 307, 308)}
|
|
28
|
+
"""The redirect statuses after which a method is sent again, same method and
|
|
29
|
+
body, to a Location on the same scheme, host and port. PROPFIND only reads,
|
|
30
|
+
so it is repeated for every redirect; any other method only for 307 and 308,
|
|
31
|
+
which are defined to keep the method and the body."""
|
|
32
|
+
|
|
33
|
+
_REDIRECTS = (301, 302, 303, 307, 308)
|
|
34
|
+
_DEFAULT_PORTS = {"http": 80, "https": 443}
|
|
35
|
+
_RE_URL_SCHEME = _re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://")
|
|
36
|
+
_DRAIN = 64 * 1024
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def credential(text: str) -> "str | bytes":
|
|
40
|
+
"""The octets a percent-encoded userinfo part stands for (RFC 3986 2.1),
|
|
41
|
+
as `str` when they are ASCII and as `bytes` otherwise."""
|
|
42
|
+
octets = _urlparse.unquote_to_bytes(text)
|
|
43
|
+
return octets.decode("ascii") if octets.isascii() else octets
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def split_userinfo(url: str) -> "tuple[str, tuple[str|bytes, str|bytes] | None]":
|
|
47
|
+
"""Split the userinfo out of an absolute URL: `(url_without_userinfo,
|
|
48
|
+
(user, password) | None)`, both parts percent-decoded to the octets they
|
|
49
|
+
stand for. Credentials inside a request URL would end up in
|
|
50
|
+
`HTTPResponse.url`, in error text and in redirect handling; they are sent
|
|
51
|
+
in an `Authorization` header instead."""
|
|
52
|
+
match = _RE_URL_SCHEME.match(url)
|
|
53
|
+
if not match:
|
|
54
|
+
return url, None
|
|
55
|
+
start = match.end()
|
|
56
|
+
end = len(url)
|
|
57
|
+
for sep in "/?#":
|
|
58
|
+
index = url.find(sep, start)
|
|
59
|
+
if index != -1 and index < end:
|
|
60
|
+
end = index
|
|
61
|
+
userinfo, at, hostport = url[start:end].rpartition("@")
|
|
62
|
+
if not at:
|
|
63
|
+
return url, None
|
|
64
|
+
user, _, password = userinfo.partition(":")
|
|
65
|
+
auth = (credential(user), credential(password))
|
|
66
|
+
return url[:start] + hostport + url[end:], (auth if any(auth) else None)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def basic_authorization(auth: "tuple[str | bytes, str | bytes]") -> str:
|
|
70
|
+
"""The `Authorization` value for Basic credentials (RFC 7617)."""
|
|
71
|
+
user, password = (p.encode("utf-8") if isinstance(p, str) else p for p in auth)
|
|
72
|
+
return "Basic " + _base64.b64encode(user + b":" + password).decode("ascii")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def origin(url: str) -> "tuple[str, str, int | None]":
|
|
76
|
+
"""The scheme, host and port a URL names."""
|
|
77
|
+
split = _urlparse.urlsplit(url)
|
|
78
|
+
scheme = split.scheme.lower()
|
|
79
|
+
try:
|
|
80
|
+
port = split.port
|
|
81
|
+
except ValueError:
|
|
82
|
+
port = -1
|
|
83
|
+
# `hostname` is lower case already; a host name is case-insensitive.
|
|
84
|
+
return scheme, (split.hostname or "").lower(), port or _DEFAULT_PORTS.get(scheme)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_redirect(resp: HTTPResponse) -> bool:
|
|
88
|
+
return resp.status in _REDIRECTS and "Location" in resp.headers
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def redirect_location(url: str, resp: HTTPResponse) -> "str | None":
|
|
92
|
+
"""`resp`'s `Location` as an absolute `http:`/`https:` URL without
|
|
93
|
+
userinfo, or `None` when there is none or it cannot be used."""
|
|
94
|
+
location = resp.headers.get("Location")
|
|
95
|
+
if not location:
|
|
96
|
+
return None
|
|
97
|
+
try:
|
|
98
|
+
# http.client decodes header bytes as Latin-1; servers send UTF-8.
|
|
99
|
+
location = location.encode("latin1").decode("utf-8")
|
|
100
|
+
except (UnicodeEncodeError, UnicodeDecodeError):
|
|
101
|
+
pass
|
|
102
|
+
if any(ord(c) < 0x20 or ord(c) == 0x7F for c in location):
|
|
103
|
+
return None
|
|
104
|
+
try:
|
|
105
|
+
absolute = split_userinfo(_urlparse.urljoin(url, location))[0]
|
|
106
|
+
# What a transport would refuse is not a place to go.
|
|
107
|
+
check_url(absolute)
|
|
108
|
+
return absolute
|
|
109
|
+
except ValueError:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def refused_redirect(method: str, url: str, resp: HTTPResponse, location, reason):
|
|
114
|
+
status = f"HTTP {resp.status} {peer_text(resp.reason or '')}".rstrip()
|
|
115
|
+
where = peer_text(shown(location), 200) if location else "(none)"
|
|
116
|
+
return OSError(
|
|
117
|
+
_errno.EIO,
|
|
118
|
+
f"{status} for {method} {shown(url)}: redirect to {where} " f"not followed ({reason})",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def discard(resp: HTTPResponse) -> None:
|
|
123
|
+
"""Gives up the rest of a body nobody wants, reading a small one so the
|
|
124
|
+
connection can serve the next request."""
|
|
125
|
+
try:
|
|
126
|
+
resp.read(_DRAIN)
|
|
127
|
+
except OSError:
|
|
128
|
+
pass
|
|
129
|
+
finally:
|
|
130
|
+
resp.close()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _capped(timeout, deadline):
|
|
134
|
+
"""`timeout` held to what remains of `deadline`; as it is without one."""
|
|
135
|
+
return timeout if deadline is None else deadline.cap(timeout)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def can_resend_body(body) -> bool:
|
|
139
|
+
return body is None or isinstance(body, (bytes, bytearray, memoryview))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def send_following(
|
|
143
|
+
transport,
|
|
144
|
+
method: str,
|
|
145
|
+
url: str,
|
|
146
|
+
*,
|
|
147
|
+
headers: "dict[str, str]",
|
|
148
|
+
timeout,
|
|
149
|
+
stream: bool,
|
|
150
|
+
first: "str | None" = None,
|
|
151
|
+
deadline=None,
|
|
152
|
+
) -> HTTPResponse:
|
|
153
|
+
"""Send a request that carries no body and follow its redirects, up to
|
|
154
|
+
`MAX_REDIRECTS`. `Authorization`, `Cookie` and `Proxy-Authorization` go
|
|
155
|
+
to the origin of `first` (the URL the caller asked for; `url` when it is
|
|
156
|
+
not given) and nowhere else: a request to another scheme, host or port
|
|
157
|
+
drops them, and for every later hop. A `303` becomes a `GET` (unless the
|
|
158
|
+
request was a `HEAD`)."""
|
|
159
|
+
home = origin(first or url)
|
|
160
|
+
current = url
|
|
161
|
+
hops = 0
|
|
162
|
+
while True:
|
|
163
|
+
if origin(current) != home:
|
|
164
|
+
for name in CREDENTIAL_HEADERS:
|
|
165
|
+
headers = drop_header(headers, name)
|
|
166
|
+
resp = transport.send(
|
|
167
|
+
method,
|
|
168
|
+
current,
|
|
169
|
+
headers=headers,
|
|
170
|
+
body=None,
|
|
171
|
+
timeout=_capped(timeout, deadline),
|
|
172
|
+
stream=stream,
|
|
173
|
+
)
|
|
174
|
+
if not is_redirect(resp):
|
|
175
|
+
return resp
|
|
176
|
+
location = redirect_location(current, resp)
|
|
177
|
+
if location is None:
|
|
178
|
+
discard(resp)
|
|
179
|
+
raise refused_redirect(method, current, resp, None, "no usable Location")
|
|
180
|
+
if hops >= MAX_REDIRECTS:
|
|
181
|
+
discard(resp)
|
|
182
|
+
raise OSError(
|
|
183
|
+
_errno.EIO,
|
|
184
|
+
f"more than {MAX_REDIRECTS} redirects from {shown(url)}",
|
|
185
|
+
)
|
|
186
|
+
discard(resp)
|
|
187
|
+
if resp.status == 303 and method != "HEAD":
|
|
188
|
+
method = "GET"
|
|
189
|
+
current = location
|
|
190
|
+
hops += 1
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def send_once(
|
|
194
|
+
transport,
|
|
195
|
+
method: str,
|
|
196
|
+
url: str,
|
|
197
|
+
*,
|
|
198
|
+
headers: "dict[str, str]",
|
|
199
|
+
body,
|
|
200
|
+
timeout,
|
|
201
|
+
stream: bool,
|
|
202
|
+
deadline=None,
|
|
203
|
+
) -> HTTPResponse:
|
|
204
|
+
"""Send a request that must not be redirected the way a browser does: a
|
|
205
|
+
redirect with a status in `RESENT_STATUSES` to the same scheme, host and
|
|
206
|
+
port is sent once more with the same method and body, and any other 3xx
|
|
207
|
+
raises `OSError(EIO)` naming the status and the `Location`."""
|
|
208
|
+
resp = transport.send(
|
|
209
|
+
method,
|
|
210
|
+
url,
|
|
211
|
+
headers=headers,
|
|
212
|
+
body=body,
|
|
213
|
+
timeout=_capped(timeout, deadline),
|
|
214
|
+
stream=stream,
|
|
215
|
+
)
|
|
216
|
+
if not 300 <= resp.status < 400:
|
|
217
|
+
return resp
|
|
218
|
+
location = redirect_location(url, resp)
|
|
219
|
+
resent = RESENT_STATUSES.get(method.upper(), (307, 308))
|
|
220
|
+
if resp.status not in resent:
|
|
221
|
+
reason = "only " + ", ".join(map(str, resent[:-1])) + f" and {resent[-1]} send the request again"
|
|
222
|
+
elif location is None:
|
|
223
|
+
reason = "no usable Location"
|
|
224
|
+
elif origin(location) != origin(url):
|
|
225
|
+
reason = "it leaves the scheme, host and port of the request"
|
|
226
|
+
elif not can_resend_body(body):
|
|
227
|
+
reason = "the request body cannot be sent twice"
|
|
228
|
+
else:
|
|
229
|
+
discard(resp)
|
|
230
|
+
resp = transport.send(
|
|
231
|
+
method,
|
|
232
|
+
location,
|
|
233
|
+
headers=headers,
|
|
234
|
+
body=body,
|
|
235
|
+
timeout=_capped(timeout, deadline),
|
|
236
|
+
stream=stream,
|
|
237
|
+
)
|
|
238
|
+
if not 300 <= resp.status < 400:
|
|
239
|
+
return resp
|
|
240
|
+
url, location = location, redirect_location(location, resp)
|
|
241
|
+
reason = "a second redirect"
|
|
242
|
+
discard(resp)
|
|
243
|
+
raise refused_redirect(method, url, resp, location, reason)
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Headers and the checks a request passes before anything is sent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import collections.abc as _abc
|
|
6
|
+
import math as _math
|
|
7
|
+
import re as _re
|
|
8
|
+
import typing as _ty
|
|
9
|
+
import urllib.parse as _urlparse
|
|
10
|
+
|
|
11
|
+
_TOKEN = _re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+")
|
|
12
|
+
_CONTROL = _re.compile(r"[\x00-\x08\x0a-\x1f\x7f]")
|
|
13
|
+
_URL_CONTROL = _re.compile(r"[\x00-\x1f\x7f]")
|
|
14
|
+
_NOT_PRINTABLE = _re.compile(r"[^\x21-\x7e]")
|
|
15
|
+
|
|
16
|
+
_DEFAULT_PORTS = {"http": 80, "https": 443}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Headers(_abc.Mapping):
|
|
20
|
+
"""The headers of a response: a read-only mapping whose names compare
|
|
21
|
+
without regard to case. A name sent more than once reads as its values
|
|
22
|
+
joined with `", "` (RFC 9110 5.3); `get_all()` returns them separately."""
|
|
23
|
+
|
|
24
|
+
__slots__ = ("_pairs", "_joined", "_spelling")
|
|
25
|
+
|
|
26
|
+
def __init__(self, pairs: "_ty.Iterable[tuple[str, str]] | _abc.Mapping" = ()):
|
|
27
|
+
if isinstance(pairs, _abc.Mapping):
|
|
28
|
+
pairs = pairs.items()
|
|
29
|
+
self._pairs = tuple((str(name), str(value)) for name, value in pairs)
|
|
30
|
+
grouped: "dict[str, list[str]]" = {}
|
|
31
|
+
self._spelling: "dict[str, str]" = {}
|
|
32
|
+
for name, value in self._pairs:
|
|
33
|
+
key = name.lower()
|
|
34
|
+
grouped.setdefault(key, []).append(value)
|
|
35
|
+
self._spelling.setdefault(key, name)
|
|
36
|
+
self._joined = {key: ", ".join(values) for key, values in grouped.items()}
|
|
37
|
+
|
|
38
|
+
def __getitem__(self, name: str) -> str:
|
|
39
|
+
return self._joined[name.lower()]
|
|
40
|
+
|
|
41
|
+
def __iter__(self) -> "_ty.Iterator[str]":
|
|
42
|
+
return iter(self._spelling.values())
|
|
43
|
+
|
|
44
|
+
def __len__(self) -> int:
|
|
45
|
+
return len(self._joined)
|
|
46
|
+
|
|
47
|
+
def __contains__(self, name: object) -> bool:
|
|
48
|
+
return isinstance(name, str) and name.lower() in self._joined
|
|
49
|
+
|
|
50
|
+
def get_all(self, name: str) -> "list[str]":
|
|
51
|
+
"""Every value sent for `name`, in order."""
|
|
52
|
+
key = name.lower()
|
|
53
|
+
return [value for field, value in self._pairs if field.lower() == key]
|
|
54
|
+
|
|
55
|
+
def __repr__(self) -> str:
|
|
56
|
+
# Values are left out: a reply can carry cookies and tokens.
|
|
57
|
+
return f"Headers({list(self._spelling.values())!r})"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def peer_text(text: object, limit: int = 120) -> str:
|
|
61
|
+
"""`text` that came from the other end (a reason phrase, a `Location`, a
|
|
62
|
+
header value) as an error message may show it: characters a terminal or a
|
|
63
|
+
log would act on (controls, line and bidirectional separators) escaped, and
|
|
64
|
+
cut to `limit` characters."""
|
|
65
|
+
text = str(text)
|
|
66
|
+
cut = len(text) > limit
|
|
67
|
+
shown = "".join(
|
|
68
|
+
char if char.isprintable() else char.encode("unicode_escape").decode("ascii") for char in text[:limit]
|
|
69
|
+
)
|
|
70
|
+
return shown + "..." if cut else shown
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def shown(url: str) -> str:
|
|
74
|
+
"""`url` as an error message may print it: scheme, host, port and path; no
|
|
75
|
+
userinfo, query or fragment, which can carry a credential."""
|
|
76
|
+
try:
|
|
77
|
+
split = _urlparse.urlsplit(url)
|
|
78
|
+
netloc = split.netloc.rpartition("@")[2]
|
|
79
|
+
return _urlparse.urlunsplit((split.scheme, netloc, split.path, "", ""))
|
|
80
|
+
except ValueError:
|
|
81
|
+
return "(unreadable URL)"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def has_header(headers: "_abc.Mapping[str, str]", name: str) -> bool:
|
|
85
|
+
key = name.lower()
|
|
86
|
+
return any(field.lower() == key for field in headers)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def wants_decoding(pairs: "_ty.Iterable[tuple[str, str]]") -> bool:
|
|
90
|
+
"""Whether a response is decoded for a request with these headers: unless
|
|
91
|
+
the request names a content coding of its own in `Accept-Encoding`
|
|
92
|
+
(`identity` is not one, so a server that encodes anyway is still decoded)."""
|
|
93
|
+
for name, value in pairs:
|
|
94
|
+
if name.lower() == "accept-encoding":
|
|
95
|
+
tokens = {part.split(";")[0].strip().lower() for part in value.split(",")}
|
|
96
|
+
return tokens <= {"", "identity"}
|
|
97
|
+
return True
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def drop_header(headers: "_abc.Mapping[str, str]", name: str) -> "dict[str, str]":
|
|
101
|
+
"""`headers` as a new dict without `name`, whatever its case."""
|
|
102
|
+
key = name.lower()
|
|
103
|
+
return {field: value for field, value in headers.items() if field.lower() != key}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def merge_headers(*layers: "_abc.Mapping[str, str] | None") -> "dict[str, str]":
|
|
107
|
+
"""The layers laid over one another, a later one winning on a name whatever
|
|
108
|
+
its case; a value of `None` removes the name."""
|
|
109
|
+
merged: "dict[str, tuple[str, str]]" = {}
|
|
110
|
+
for layer in layers:
|
|
111
|
+
for name, value in (layer or {}).items():
|
|
112
|
+
if value is None:
|
|
113
|
+
merged.pop(name.lower(), None)
|
|
114
|
+
else:
|
|
115
|
+
merged[name.lower()] = (name, value)
|
|
116
|
+
return dict(merged.values())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def check_timeout(timeout) -> "tuple[float | None, float | None]":
|
|
120
|
+
"""`timeout` (a number, `None`, or a `(connect, read)` pair) as that pair;
|
|
121
|
+
`ValueError` for anything else."""
|
|
122
|
+
if timeout is None:
|
|
123
|
+
return None, None
|
|
124
|
+
if isinstance(timeout, tuple):
|
|
125
|
+
if len(timeout) != 2:
|
|
126
|
+
raise ValueError("a timeout is a number, None or a (connect, read) pair")
|
|
127
|
+
connect, read = timeout
|
|
128
|
+
else:
|
|
129
|
+
connect = read = timeout
|
|
130
|
+
for value in (connect, read):
|
|
131
|
+
if value is not None and (
|
|
132
|
+
isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 < value < _math.inf
|
|
133
|
+
):
|
|
134
|
+
raise ValueError("a timeout is a positive finite number of seconds, or None")
|
|
135
|
+
return connect, read
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def check_method(method: str) -> str:
|
|
139
|
+
if not isinstance(method, str) or not _TOKEN.fullmatch(method):
|
|
140
|
+
raise ValueError("the request method is not an HTTP token")
|
|
141
|
+
return method.upper()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def check_body(body) -> bool:
|
|
145
|
+
"""Whether `body` (`None`, bytes, a binary file or an iterable of bytes)
|
|
146
|
+
can be sent twice; `TypeError` for text or anything else."""
|
|
147
|
+
if body is None or isinstance(body, (bytes, bytearray, memoryview)):
|
|
148
|
+
return True
|
|
149
|
+
if isinstance(body, str):
|
|
150
|
+
raise TypeError("the request body is bytes, not text")
|
|
151
|
+
if hasattr(body, "read"):
|
|
152
|
+
return False
|
|
153
|
+
if isinstance(body, (list, tuple)):
|
|
154
|
+
return all(isinstance(piece, (bytes, bytearray)) for piece in body)
|
|
155
|
+
if hasattr(body, "__iter__"):
|
|
156
|
+
return False
|
|
157
|
+
raise TypeError("the request body is bytes, a file or an iterable of bytes")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def check_headers(headers: "_abc.Mapping[str, str] | None") -> "list[tuple[str, str]]":
|
|
161
|
+
"""`headers` as `(name, value)` text pairs, or `ValueError` for a name that
|
|
162
|
+
is not an HTTP token or a value with a control character, a line break
|
|
163
|
+
included. The refused text is not quoted: it may be a credential."""
|
|
164
|
+
pairs = []
|
|
165
|
+
for name, value in (headers or {}).items():
|
|
166
|
+
if not isinstance(name, str) or not _TOKEN.fullmatch(name):
|
|
167
|
+
raise ValueError("a request header name is not an HTTP token")
|
|
168
|
+
if isinstance(value, bytes):
|
|
169
|
+
value = value.decode("latin-1")
|
|
170
|
+
elif isinstance(value, int) and not isinstance(value, bool):
|
|
171
|
+
value = str(value)
|
|
172
|
+
if not isinstance(value, str):
|
|
173
|
+
raise ValueError(f"the value of the {name} header is not text")
|
|
174
|
+
if _CONTROL.search(value):
|
|
175
|
+
raise ValueError(f"the value of the {name} header has a control character")
|
|
176
|
+
try:
|
|
177
|
+
value.encode("latin-1")
|
|
178
|
+
except UnicodeEncodeError:
|
|
179
|
+
raise ValueError(f"the value of the {name} header is not Latin-1") from None
|
|
180
|
+
pairs.append((name, value))
|
|
181
|
+
return pairs
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class Target(_ty.NamedTuple):
|
|
185
|
+
"""Where a request goes and how it names itself on the wire."""
|
|
186
|
+
|
|
187
|
+
scheme: str
|
|
188
|
+
host: str # IDNA, without brackets
|
|
189
|
+
port: int
|
|
190
|
+
path: str # the origin-form request target, percent-encoded
|
|
191
|
+
authority: str # the Host header value
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _percent(match: "_re.Match") -> str:
|
|
195
|
+
return "".join(f"%{byte:02X}" for byte in match.group().encode("utf-8", "surrogateescape"))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def bracket(host: str) -> str:
|
|
199
|
+
return f"[{host}]" if ":" in host else host
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def check_url(url: str) -> Target:
|
|
203
|
+
"""The parts of an `http:`/`https:` URL a request is made of. A control
|
|
204
|
+
character (CR and LF included), credentials in the URL, another scheme or
|
|
205
|
+
a missing host are `ValueError`; the URL is not quoted in the message."""
|
|
206
|
+
if not isinstance(url, str):
|
|
207
|
+
raise ValueError("the request URL is not text")
|
|
208
|
+
if _URL_CONTROL.search(url):
|
|
209
|
+
raise ValueError("the request URL has a control character")
|
|
210
|
+
try:
|
|
211
|
+
parts = _urlparse.urlsplit(url)
|
|
212
|
+
port = parts.port
|
|
213
|
+
except ValueError:
|
|
214
|
+
raise ValueError("the request URL cannot be parsed") from None
|
|
215
|
+
scheme = parts.scheme.lower()
|
|
216
|
+
if scheme not in _DEFAULT_PORTS:
|
|
217
|
+
raise ValueError(f"the request URL has scheme {scheme!r}, not http or https")
|
|
218
|
+
if "@" in parts.netloc:
|
|
219
|
+
raise ValueError("the request URL carries credentials; send them in an Authorization header")
|
|
220
|
+
# The connection pool, the Host header, SNI and the proxy rules key on this
|
|
221
|
+
# name, which is case-insensitive: `hostname` is lower case already.
|
|
222
|
+
host = (parts.hostname or "").lower()
|
|
223
|
+
if not host:
|
|
224
|
+
raise ValueError("the request URL has no host")
|
|
225
|
+
if not host.isascii():
|
|
226
|
+
try:
|
|
227
|
+
host = host.encode("idna").decode("ascii")
|
|
228
|
+
except UnicodeError:
|
|
229
|
+
raise ValueError("the host of the request URL is not a valid name") from None
|
|
230
|
+
port = port or _DEFAULT_PORTS[scheme]
|
|
231
|
+
path = parts.path or "/"
|
|
232
|
+
if parts.query:
|
|
233
|
+
path += "?" + parts.query
|
|
234
|
+
path = _NOT_PRINTABLE.sub(_percent, path)
|
|
235
|
+
authority = bracket(host)
|
|
236
|
+
if port != _DEFAULT_PORTS[scheme]:
|
|
237
|
+
authority += f":{port}"
|
|
238
|
+
return Target(scheme, host, port, path, authority)
|