pathapi 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.
- pathapi/AGENTS.md +2355 -0
- pathapi/README.md +205 -0
- pathapi/__init__.py +62 -0
- pathapi/_commit.py +116 -0
- pathapi/_discard.py +92 -0
- pathapi/_errorpolicy.py +106 -0
- pathapi/_filestat.py +194 -0
- pathapi/_lru.py +138 -0
- pathapi/_names.py +70 -0
- pathapi/_permissions.py +102 -0
- pathapi/_stubs.py +76 -0
- pathapi/_tree.py +136 -0
- pathapi/archive/__init__.py +44 -0
- pathapi/archive/_backend.py +393 -0
- pathapi/archive/_names.py +63 -0
- pathapi/archive/_pack.py +394 -0
- pathapi/archive/_path.py +352 -0
- pathapi/archive/_snapshot.py +269 -0
- pathapi/archive/_tar.py +126 -0
- pathapi/archive/_write.py +359 -0
- pathapi/archive/_zip.py +563 -0
- pathapi/archive/_zipentries.py +272 -0
- pathapi/checksum.py +61 -0
- pathapi/exceptions.py +22 -0
- pathapi/fspath.py +513 -0
- pathapi/ftp/__init__.py +27 -0
- pathapi/ftp/_backend.py +418 -0
- pathapi/ftp/_facts.py +117 -0
- pathapi/ftp/_path.py +582 -0
- pathapi/glob/__init__.py +27 -0
- pathapi/glob/_entries.py +103 -0
- pathapi/glob/_patterns.py +164 -0
- pathapi/glob/_recursion.py +180 -0
- pathapi/glob/_selection.py +303 -0
- pathapi/http/__init__.py +84 -0
- pathapi/http/_body.py +156 -0
- pathapi/http/_builtin.py +553 -0
- pathapi/http/_client.py +211 -0
- pathapi/http/_errors.py +79 -0
- pathapi/http/_follow.py +230 -0
- pathapi/http/_headers.py +233 -0
- pathapi/http/_pool.py +169 -0
- pathapi/http/_proxies.py +142 -0
- pathapi/http/_response.py +255 -0
- pathapi/http/_settings.py +12 -0
- pathapi/http/_status.py +68 -0
- pathapi/http/_transports.py +173 -0
- pathapi/http/testing/__init__.py +80 -0
- pathapi/http/testing/__main__.py +10 -0
- pathapi/http/testing/suite/conftest.py +98 -0
- pathapi/http/testing/suite/test_client_default.py +285 -0
- pathapi/http/testing/suite/test_client_errors.py +181 -0
- pathapi/http/testing/suite/test_client_peer_text.py +86 -0
- pathapi/http/testing/suite/test_client_redirect_origin.py +134 -0
- pathapi/http/testing/suite/test_client_redirects.py +231 -0
- pathapi/http/testing/suite/test_client_reply_limits.py +201 -0
- pathapi/http/testing/suite/test_client_write_redirects.py +239 -0
- pathapi/http/testing/suite/test_transport_body.py +302 -0
- pathapi/http/testing/suite/test_transport_send.py +662 -0
- pathapi/http/testing/suite/transport_fakes.py +133 -0
- pathapi/http/testing/suite/transport_guard.py +174 -0
- pathapi/http/testing/suite/transport_wire.py +511 -0
- pathapi/mempath.py +438 -0
- pathapi/path/__init__.py +19 -0
- pathapi/path/_info.py +10 -0
- pathapi/path/_path.py +198 -0
- pathapi/path/_pure.py +522 -0
- pathapi/path/_relation.py +10 -0
- pathapi/path/_transfer.py +10 -0
- pathapi/protocols/__init__.py +35 -0
- pathapi/protocols/_bundles.py +22 -0
- pathapi/protocols/_errors.py +12 -0
- pathapi/protocols/_info.py +65 -0
- pathapi/protocols/_relation.py +191 -0
- pathapi/protocols/_removal.py +69 -0
- pathapi/protocols/_support.py +122 -0
- pathapi/protocols/_transfer.py +100 -0
- pathapi/protocols/checksum.py +80 -0
- pathapi/protocols/fs.py +429 -0
- pathapi/protocols/io.py +224 -0
- pathapi/protocols/links.py +135 -0
- pathapi/protocols/pure.py +31 -0
- pathapi/protocols/resolve.py +97 -0
- pathapi/protocols/transfer.py +656 -0
- pathapi/protocols/tree.py +492 -0
- pathapi/providers.py +48 -0
- pathapi/py.typed +1 -0
- pathapi/sync/__init__.py +8 -0
- pathapi/sync/_compare.py +157 -0
- pathapi/sync/_events.py +97 -0
- pathapi/sync/_links.py +93 -0
- pathapi/sync/_offer.py +33 -0
- pathapi/sync/_paths.py +50 -0
- pathapi/sync/_replace.py +98 -0
- pathapi/sync/_syncer.py +989 -0
- pathapi/testing/__init__.py +96 -0
- pathapi/testing/_contract.py +522 -0
- pathapi/testing/_fixtures.py +83 -0
- pathapi/testing/_read.py +279 -0
- pathapi-0.1.0.dist-info/METADATA +242 -0
- pathapi-0.1.0.dist-info/RECORD +104 -0
- pathapi-0.1.0.dist-info/WHEEL +4 -0
- pathapi-0.1.0.dist-info/entry_points.txt +2 -0
- pathapi-0.1.0.dist-info/licenses/LICENSE +21 -0
pathapi/http/_client.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""The client: a transport, the headers and timeout every request is sent with,
|
|
2
|
+
and the redirect rules above the transport."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import threading as _threading
|
|
7
|
+
import typing as _ty
|
|
8
|
+
|
|
9
|
+
from .. import __version__
|
|
10
|
+
from . import _follow, _settings
|
|
11
|
+
from ._headers import check_headers, check_url, has_header, merge_headers
|
|
12
|
+
from ._response import HTTPResponse
|
|
13
|
+
from ._status import too_large
|
|
14
|
+
from ._transports import default_transport
|
|
15
|
+
|
|
16
|
+
DEFAULT_TIMEOUT = (10, 60)
|
|
17
|
+
"""`(connect, read)` timeout, in seconds, a client sends with every request
|
|
18
|
+
unless it is given `timeout` (`HTTPClient(timeout=...)` or per request);
|
|
19
|
+
`timeout=None` waits without a limit."""
|
|
20
|
+
|
|
21
|
+
_UNSET = object()
|
|
22
|
+
"""The `timeout` of a call that did not give one: the client's own."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HTTPClient:
|
|
26
|
+
"""A transport with the settings every request is sent with, and the rules
|
|
27
|
+
for what happens after a redirect.
|
|
28
|
+
|
|
29
|
+
`transport` is any object with a `send()` method as `HTTPTransport.send()`;
|
|
30
|
+
without one the client builds the transport that `PATHAPI_HTTP_TRANSPORT`
|
|
31
|
+
names (the standard-library `HTTPTransport` by default) on first use and
|
|
32
|
+
closes it with `close()` or at the end of a `with` block. A transport it
|
|
33
|
+
was given stays the caller's. `headers` are sent with every request,
|
|
34
|
+
request-specific headers merged over them by name, over a `User-Agent` of
|
|
35
|
+
`user_agent` (`pathapi/<version>` by default) and `Accept: */*`.
|
|
36
|
+
Every request gets `timeout=DEFAULT_TIMEOUT` (`(10, 60)` seconds) unless
|
|
37
|
+
`timeout` here or in the call says otherwise (`None` waits without limit).
|
|
38
|
+
|
|
39
|
+
The client, not the transport, follows redirects. `GET`, `HEAD` and
|
|
40
|
+
`OPTIONS` follow up to 30; a redirect that changes the scheme, host or
|
|
41
|
+
port drops `Authorization` (and nothing else). Any other method is sent
|
|
42
|
+
without following: a 307 or 308 (for `PROPFIND` also a 301, 302 or 303) to
|
|
43
|
+
the same scheme, host and port is sent once more with the same method and
|
|
44
|
+
body, and any other 3xx, a redirect to another origin or a second redirect
|
|
45
|
+
raises `OSError(EIO)`."""
|
|
46
|
+
|
|
47
|
+
__slots__ = (
|
|
48
|
+
"headers",
|
|
49
|
+
"timeout",
|
|
50
|
+
"user_agent",
|
|
51
|
+
"_transport",
|
|
52
|
+
"_built",
|
|
53
|
+
"_transport_lock",
|
|
54
|
+
"__weakref__",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
transport: "_ty.Any" = None,
|
|
60
|
+
*,
|
|
61
|
+
headers: "_ty.Mapping[str, str] | None" = None,
|
|
62
|
+
timeout: "float | tuple | None" = DEFAULT_TIMEOUT,
|
|
63
|
+
user_agent: "str | None" = None,
|
|
64
|
+
):
|
|
65
|
+
if transport is not None and not callable(getattr(transport, "send", None)):
|
|
66
|
+
raise TypeError("a transport is an object with a send() method")
|
|
67
|
+
self._transport = transport
|
|
68
|
+
self._built = None
|
|
69
|
+
self._transport_lock = _threading.Lock()
|
|
70
|
+
self.headers = dict(headers or {})
|
|
71
|
+
self.timeout = timeout
|
|
72
|
+
self.user_agent = user_agent or f"pathapi/{__version__}"
|
|
73
|
+
|
|
74
|
+
def __repr__(self) -> str:
|
|
75
|
+
# Headers are left out: they carry credentials.
|
|
76
|
+
return f"{type(self).__name__}()"
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def transport(self):
|
|
80
|
+
"""The transport: the one the client was given, or one built on first
|
|
81
|
+
use. Read once per request."""
|
|
82
|
+
transport = self._transport
|
|
83
|
+
if transport is None:
|
|
84
|
+
with self._transport_lock:
|
|
85
|
+
if self._transport is None:
|
|
86
|
+
self._built = self._transport = default_transport()
|
|
87
|
+
transport = self._transport
|
|
88
|
+
return transport
|
|
89
|
+
|
|
90
|
+
def close(self) -> None:
|
|
91
|
+
"""Closes the transport this client built for itself; one it was given
|
|
92
|
+
stays the caller's. Harmless when called again."""
|
|
93
|
+
with self._transport_lock:
|
|
94
|
+
built, self._built = self._built, None
|
|
95
|
+
if built is not None:
|
|
96
|
+
self._transport = None
|
|
97
|
+
if built is not None:
|
|
98
|
+
built.close()
|
|
99
|
+
|
|
100
|
+
def __enter__(self):
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def __exit__(self, *exc_info) -> None:
|
|
104
|
+
self.close()
|
|
105
|
+
|
|
106
|
+
def _sent_headers(self, headers, auth) -> "dict[str, str]":
|
|
107
|
+
"""The headers every request starts from: the base ones, the client's,
|
|
108
|
+
the call's, and the `Authorization` that `auth` stands for."""
|
|
109
|
+
base = {"User-Agent": self.user_agent, "Accept": "*/*"}
|
|
110
|
+
sent = merge_headers(base, self.headers, headers)
|
|
111
|
+
if auth is not None and not has_header(sent, "Authorization"):
|
|
112
|
+
sent["Authorization"] = (
|
|
113
|
+
auth if isinstance(auth, str) else _follow.basic_authorization(auth)
|
|
114
|
+
)
|
|
115
|
+
return sent
|
|
116
|
+
|
|
117
|
+
def _dispatch(
|
|
118
|
+
self, method, url, sent, data, allow_redirects, stream, timeout, first=None
|
|
119
|
+
) -> HTTPResponse:
|
|
120
|
+
check_url(url)
|
|
121
|
+
check_headers(sent)
|
|
122
|
+
if timeout is _UNSET:
|
|
123
|
+
timeout = self.timeout
|
|
124
|
+
if isinstance(data, str):
|
|
125
|
+
data = data.encode("utf-8")
|
|
126
|
+
transport = self.transport
|
|
127
|
+
# Always streamed from the transport, so that no reply is read whole
|
|
128
|
+
# before it is known to be small enough (see `stream` below).
|
|
129
|
+
if method.upper() in _follow.FOLLOWED_METHODS and data is None:
|
|
130
|
+
if allow_redirects is False and first is None:
|
|
131
|
+
resp = transport.send(
|
|
132
|
+
method, url, headers=sent, timeout=timeout, stream=True
|
|
133
|
+
)
|
|
134
|
+
else:
|
|
135
|
+
resp = _follow.send_following(
|
|
136
|
+
transport,
|
|
137
|
+
method,
|
|
138
|
+
url,
|
|
139
|
+
headers=sent,
|
|
140
|
+
timeout=timeout,
|
|
141
|
+
stream=True,
|
|
142
|
+
first=first,
|
|
143
|
+
)
|
|
144
|
+
else:
|
|
145
|
+
resp = _follow.send_once(
|
|
146
|
+
transport,
|
|
147
|
+
method,
|
|
148
|
+
url,
|
|
149
|
+
headers=sent,
|
|
150
|
+
body=data,
|
|
151
|
+
timeout=timeout,
|
|
152
|
+
stream=True,
|
|
153
|
+
)
|
|
154
|
+
if not stream:
|
|
155
|
+
limit = _settings.MAX_REPLY_BYTES
|
|
156
|
+
resp._buffered(limit, too_large(_follow.shown(url), limit))
|
|
157
|
+
return resp
|
|
158
|
+
|
|
159
|
+
def request(
|
|
160
|
+
self,
|
|
161
|
+
method: str,
|
|
162
|
+
url: str,
|
|
163
|
+
*,
|
|
164
|
+
data=None,
|
|
165
|
+
headers: "_ty.Mapping[str, str] | None" = None,
|
|
166
|
+
stream: bool = False,
|
|
167
|
+
allow_redirects: "bool | None" = None,
|
|
168
|
+
timeout=_UNSET,
|
|
169
|
+
auth: "tuple[str | bytes, str | bytes] | str | None" = None,
|
|
170
|
+
) -> HTTPResponse:
|
|
171
|
+
"""Send `method` to `url` and return the final response. A `data` that
|
|
172
|
+
is not `bytes` (a file, an iterable) cannot be sent twice, so it is not
|
|
173
|
+
re-sent after a redirect or a dropped connection.
|
|
174
|
+
`allow_redirects=False` returns the first answer of a `GET`, `HEAD` or
|
|
175
|
+
`OPTIONS` as it is. `auth` is a `(user, password)` pair sent as
|
|
176
|
+
`Authorization: Basic`, or a `str` sent as the `Authorization` value
|
|
177
|
+
(`"Bearer <token>"`), to the origin of `url` unless a header names an
|
|
178
|
+
`Authorization`. `ValueError` for a URL (credentials in it included)
|
|
179
|
+
or a header with a control character, before anything is sent.
|
|
180
|
+
|
|
181
|
+
With `stream=False` the body is read before this returns, and a decoded
|
|
182
|
+
body over `MAX_REPLY_BYTES` is `OSError(EFBIG)`; with `stream=True` the
|
|
183
|
+
caller reads it, and closes the response."""
|
|
184
|
+
sent = self._sent_headers(headers, auth)
|
|
185
|
+
return self._dispatch(method, url, sent, data, allow_redirects, stream, timeout)
|
|
186
|
+
|
|
187
|
+
def redirect(
|
|
188
|
+
self,
|
|
189
|
+
method: str,
|
|
190
|
+
url: str,
|
|
191
|
+
resp: HTTPResponse,
|
|
192
|
+
*,
|
|
193
|
+
headers: "_ty.Mapping[str, str] | None" = None,
|
|
194
|
+
stream: bool = False,
|
|
195
|
+
timeout=_UNSET,
|
|
196
|
+
auth: "tuple[str | bytes, str | bytes] | str | None" = None,
|
|
197
|
+
) -> HTTPResponse:
|
|
198
|
+
"""Send `method` to where `resp`, the answer to a request for `url`,
|
|
199
|
+
redirects to, and follow what it redirects to in turn. The request
|
|
200
|
+
carries what `url` is sent with, except that `Authorization` is left
|
|
201
|
+
off whenever it goes to another scheme, host or port than `url`'s. A
|
|
202
|
+
redirect with no usable `Location` is `OSError(EIO)`."""
|
|
203
|
+
sent = self._sent_headers(headers, auth)
|
|
204
|
+
location = _follow.redirect_location(url, resp)
|
|
205
|
+
if location is None:
|
|
206
|
+
raise _follow.refused_redirect(
|
|
207
|
+
method, url, resp, None, "no usable Location"
|
|
208
|
+
)
|
|
209
|
+
return self._dispatch(
|
|
210
|
+
method, location, sent, None, None, stream, timeout, first=url
|
|
211
|
+
)
|
pathapi/http/_errors.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""How a failed request becomes the `OSError` a path raises."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib as _contextlib
|
|
6
|
+
import errno as _errno
|
|
7
|
+
import os as _os
|
|
8
|
+
|
|
9
|
+
from ._headers import peer_text
|
|
10
|
+
from ._status import HTTPStatusError as _HTTPStatusError
|
|
11
|
+
|
|
12
|
+
_ERRNOS = {
|
|
13
|
+
FileNotFoundError: _errno.ENOENT,
|
|
14
|
+
PermissionError: _errno.EACCES,
|
|
15
|
+
FileExistsError: _errno.EEXIST,
|
|
16
|
+
IsADirectoryError: _errno.EISDIR,
|
|
17
|
+
NotADirectoryError: _errno.ENOTDIR,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def os_error(error_class, subject) -> OSError:
|
|
22
|
+
"""`error_class` built as pathlib builds it -- `(errno, strerror,
|
|
23
|
+
filename)` -- for `subject` (a path or a URL, shown through `str()`), so
|
|
24
|
+
`e.errno` and `e.filename` are set."""
|
|
25
|
+
code = _ERRNOS.get(error_class, _errno.EIO)
|
|
26
|
+
return error_class(code, _os.strerror(code), str(subject))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def server_size(text: "str | None") -> "int | None":
|
|
30
|
+
"""A byte count a server stated (`Content-Length`, `getcontentlength`) as
|
|
31
|
+
an int, or `None` when it is not a plain non-negative decimal number."""
|
|
32
|
+
text = (text or "").strip()
|
|
33
|
+
if text.isascii() and text.isdigit() and len(text) <= 19:
|
|
34
|
+
return int(text)
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@_contextlib.contextmanager
|
|
39
|
+
def translate_errors(subject, *, conflict=FileExistsError):
|
|
40
|
+
"""Map a failed request to the exception `pathlib` raises for `subject`.
|
|
41
|
+
|
|
42
|
+
An `HTTPStatusError` becomes `FileNotFoundError` (404, 410),
|
|
43
|
+
`PermissionError` (401, 403; 405 and 501 too, naming the status),
|
|
44
|
+
`conflict` (409) or `OSError(EIO)` naming the status; a `TimeoutError` or
|
|
45
|
+
`ConnectionError` from the transport is raised again with a message that
|
|
46
|
+
holds `subject` and the exception's type name. `conflict` is what a 409
|
|
47
|
+
means: for a write (PUT, MKCOL, MOVE) RFC 4918 uses 409 for a missing
|
|
48
|
+
parent collection, so writers pass `FileNotFoundError`. The cause is not
|
|
49
|
+
chained: a traceback would otherwise print a URL, a header or a
|
|
50
|
+
credential that the transport's own error carries."""
|
|
51
|
+
try:
|
|
52
|
+
yield
|
|
53
|
+
except _HTTPStatusError as e:
|
|
54
|
+
response = e.response
|
|
55
|
+
status = response.status
|
|
56
|
+
reason = peer_text(response.reason or "")
|
|
57
|
+
if status in (404, 410):
|
|
58
|
+
raise os_error(FileNotFoundError, subject) from None
|
|
59
|
+
elif status in (401, 403):
|
|
60
|
+
raise os_error(PermissionError, subject) from None
|
|
61
|
+
elif status == 409:
|
|
62
|
+
raise os_error(conflict, subject) from None
|
|
63
|
+
elif status in (405, 501):
|
|
64
|
+
raise PermissionError(
|
|
65
|
+
_errno.EACCES,
|
|
66
|
+
f"Method not allowed (HTTP {status})",
|
|
67
|
+
str(subject),
|
|
68
|
+
) from None
|
|
69
|
+
else:
|
|
70
|
+
raise OSError(
|
|
71
|
+
_errno.EIO,
|
|
72
|
+
f"HTTP Error {status} {reason}".rstrip() + f" for {subject}",
|
|
73
|
+
) from None
|
|
74
|
+
except TimeoutError as e:
|
|
75
|
+
raise TimeoutError(f"Timeout for {subject} ({type(e).__name__})") from None
|
|
76
|
+
except ConnectionError as e:
|
|
77
|
+
raise ConnectionError(
|
|
78
|
+
f"Connection error for {subject} ({type(e).__name__})"
|
|
79
|
+
) from None
|
pathapi/http/_follow.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
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
|
|
12
|
+
from ._response import HTTPResponse
|
|
13
|
+
|
|
14
|
+
MAX_REDIRECTS = 30
|
|
15
|
+
"""Most redirects a `GET`, `HEAD` or `OPTIONS` follows."""
|
|
16
|
+
|
|
17
|
+
FOLLOWED_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
18
|
+
"""Methods whose redirects are followed. Every other method goes through
|
|
19
|
+
`send_once()`: following would turn a state-changing one into a different
|
|
20
|
+
request, and a PROPFIND into a GET or one without its body."""
|
|
21
|
+
|
|
22
|
+
RESENT_STATUSES = {"PROPFIND": (301, 302, 303, 307, 308)}
|
|
23
|
+
"""The redirect statuses after which a method is sent again, same method and
|
|
24
|
+
body, to a Location on the same scheme, host and port. PROPFIND only reads,
|
|
25
|
+
so it is repeated for every redirect; any other method only for 307 and 308,
|
|
26
|
+
which are defined to keep the method and the body."""
|
|
27
|
+
|
|
28
|
+
_REDIRECTS = (301, 302, 303, 307, 308)
|
|
29
|
+
_DEFAULT_PORTS = {"http": 80, "https": 443}
|
|
30
|
+
_RE_URL_SCHEME = _re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://")
|
|
31
|
+
_DRAIN = 64 * 1024
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def credential(text: str) -> "str | bytes":
|
|
35
|
+
"""The octets a percent-encoded userinfo part stands for (RFC 3986 2.1),
|
|
36
|
+
as `str` when they are ASCII and as `bytes` otherwise."""
|
|
37
|
+
octets = _urlparse.unquote_to_bytes(text)
|
|
38
|
+
return octets.decode("ascii") if octets.isascii() else octets
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def split_userinfo(url: str) -> "tuple[str, tuple[str|bytes, str|bytes] | None]":
|
|
42
|
+
"""Split the userinfo out of an absolute URL: `(url_without_userinfo,
|
|
43
|
+
(user, password) | None)`, both parts percent-decoded to the octets they
|
|
44
|
+
stand for. Credentials inside a request URL would end up in
|
|
45
|
+
`HTTPResponse.url`, in error text and in redirect handling; they are sent
|
|
46
|
+
in an `Authorization` header instead."""
|
|
47
|
+
match = _RE_URL_SCHEME.match(url)
|
|
48
|
+
if not match:
|
|
49
|
+
return url, None
|
|
50
|
+
start = match.end()
|
|
51
|
+
end = len(url)
|
|
52
|
+
for sep in "/?#":
|
|
53
|
+
index = url.find(sep, start)
|
|
54
|
+
if index != -1 and index < end:
|
|
55
|
+
end = index
|
|
56
|
+
userinfo, at, hostport = url[start:end].rpartition("@")
|
|
57
|
+
if not at:
|
|
58
|
+
return url, None
|
|
59
|
+
user, _, password = userinfo.partition(":")
|
|
60
|
+
auth = (credential(user), credential(password))
|
|
61
|
+
return url[:start] + hostport + url[end:], (auth if any(auth) else None)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def basic_authorization(auth: "tuple[str | bytes, str | bytes]") -> str:
|
|
65
|
+
"""The `Authorization` value for Basic credentials (RFC 7617)."""
|
|
66
|
+
user, password = (p.encode("utf-8") if isinstance(p, str) else p for p in auth)
|
|
67
|
+
return "Basic " + _base64.b64encode(user + b":" + password).decode("ascii")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def origin(url: str) -> "tuple[str, str, int | None]":
|
|
71
|
+
"""The scheme, host and port a URL names."""
|
|
72
|
+
split = _urlparse.urlsplit(url)
|
|
73
|
+
scheme = split.scheme.lower()
|
|
74
|
+
try:
|
|
75
|
+
port = split.port
|
|
76
|
+
except ValueError:
|
|
77
|
+
port = -1
|
|
78
|
+
# `hostname` is lower case already; a host name is case-insensitive.
|
|
79
|
+
return scheme, (split.hostname or "").lower(), port or _DEFAULT_PORTS.get(scheme)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def shown(url: str) -> str:
|
|
83
|
+
"""`url` as an error message may print it: no userinfo, query or fragment,
|
|
84
|
+
which can carry a credential."""
|
|
85
|
+
try:
|
|
86
|
+
split = _urlparse.urlsplit(url)
|
|
87
|
+
netloc = split.netloc.rpartition("@")[2]
|
|
88
|
+
return _urlparse.urlunsplit((split.scheme, netloc, split.path, "", ""))
|
|
89
|
+
except ValueError:
|
|
90
|
+
return "(unreadable URL)"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def is_redirect(resp: HTTPResponse) -> bool:
|
|
94
|
+
return resp.status in _REDIRECTS and "Location" in resp.headers
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def redirect_location(url: str, resp: HTTPResponse) -> "str | None":
|
|
98
|
+
"""`resp`'s `Location` as an absolute `http:`/`https:` URL without
|
|
99
|
+
userinfo, or `None` when there is none or it cannot be used."""
|
|
100
|
+
location = resp.headers.get("Location")
|
|
101
|
+
if not location:
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
# http.client decodes header bytes as Latin-1; servers send UTF-8.
|
|
105
|
+
location = location.encode("latin1").decode("utf-8")
|
|
106
|
+
except (UnicodeEncodeError, UnicodeDecodeError):
|
|
107
|
+
pass
|
|
108
|
+
if any(ord(c) < 0x20 or ord(c) == 0x7F for c in location):
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
absolute = split_userinfo(_urlparse.urljoin(url, location))[0]
|
|
112
|
+
# What a transport would refuse is not a place to go.
|
|
113
|
+
check_url(absolute)
|
|
114
|
+
return absolute
|
|
115
|
+
except ValueError:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def refused_redirect(method: str, url: str, resp: HTTPResponse, location, reason):
|
|
120
|
+
status = f"HTTP {resp.status} {peer_text(resp.reason or '')}".rstrip()
|
|
121
|
+
where = peer_text(shown(location), 200) if location else "(none)"
|
|
122
|
+
return OSError(
|
|
123
|
+
_errno.EIO,
|
|
124
|
+
f"{status} for {method} {shown(url)}: redirect to {where} "
|
|
125
|
+
f"not followed ({reason})",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _discard(resp: HTTPResponse) -> None:
|
|
130
|
+
"""Gives up the rest of a body nobody wants, reading a small one so the
|
|
131
|
+
connection can serve the next request."""
|
|
132
|
+
try:
|
|
133
|
+
resp.read(_DRAIN)
|
|
134
|
+
except OSError:
|
|
135
|
+
pass
|
|
136
|
+
finally:
|
|
137
|
+
resp.close()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def can_resend_body(body) -> bool:
|
|
141
|
+
return body is None or isinstance(body, (bytes, bytearray, memoryview))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def send_following(
|
|
145
|
+
transport,
|
|
146
|
+
method: str,
|
|
147
|
+
url: str,
|
|
148
|
+
*,
|
|
149
|
+
headers: "dict[str, str]",
|
|
150
|
+
timeout,
|
|
151
|
+
stream: bool,
|
|
152
|
+
first: "str | None" = None,
|
|
153
|
+
) -> HTTPResponse:
|
|
154
|
+
"""Send a request that carries no body and follow its redirects, up to
|
|
155
|
+
`MAX_REDIRECTS`. `Authorization` goes to the origin of `first` (the URL
|
|
156
|
+
the caller asked for; `url` when it is not given) and nowhere else: a
|
|
157
|
+
request to another scheme, host or port drops it, and for every later
|
|
158
|
+
hop. A `303` becomes a `GET` (unless the 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
|
+
headers = drop_header(headers, "Authorization")
|
|
165
|
+
resp = transport.send(
|
|
166
|
+
method, current, headers=headers, body=None, timeout=timeout, stream=stream
|
|
167
|
+
)
|
|
168
|
+
if not is_redirect(resp):
|
|
169
|
+
return resp
|
|
170
|
+
location = redirect_location(current, resp)
|
|
171
|
+
if location is None:
|
|
172
|
+
_discard(resp)
|
|
173
|
+
raise refused_redirect(method, current, resp, None, "no usable Location")
|
|
174
|
+
if hops >= MAX_REDIRECTS:
|
|
175
|
+
_discard(resp)
|
|
176
|
+
raise OSError(
|
|
177
|
+
_errno.EIO,
|
|
178
|
+
f"more than {MAX_REDIRECTS} redirects from {shown(url)}",
|
|
179
|
+
)
|
|
180
|
+
_discard(resp)
|
|
181
|
+
if resp.status == 303 and method != "HEAD":
|
|
182
|
+
method = "GET"
|
|
183
|
+
current = location
|
|
184
|
+
hops += 1
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def send_once(
|
|
188
|
+
transport,
|
|
189
|
+
method: str,
|
|
190
|
+
url: str,
|
|
191
|
+
*,
|
|
192
|
+
headers: "dict[str, str]",
|
|
193
|
+
body,
|
|
194
|
+
timeout,
|
|
195
|
+
stream: bool,
|
|
196
|
+
) -> HTTPResponse:
|
|
197
|
+
"""Send a request that must not be redirected the way a browser does: a
|
|
198
|
+
redirect with a status in `RESENT_STATUSES` to the same scheme, host and
|
|
199
|
+
port is sent once more with the same method and body, and any other 3xx
|
|
200
|
+
raises `OSError(EIO)` naming the status and the `Location`."""
|
|
201
|
+
resp = transport.send(
|
|
202
|
+
method, url, headers=headers, body=body, timeout=timeout, stream=stream
|
|
203
|
+
)
|
|
204
|
+
if not 300 <= resp.status < 400:
|
|
205
|
+
return resp
|
|
206
|
+
location = redirect_location(url, resp)
|
|
207
|
+
resent = RESENT_STATUSES.get(method.upper(), (307, 308))
|
|
208
|
+
if resp.status not in resent:
|
|
209
|
+
reason = (
|
|
210
|
+
"only "
|
|
211
|
+
+ ", ".join(map(str, resent[:-1]))
|
|
212
|
+
+ f" and {resent[-1]} send the request again"
|
|
213
|
+
)
|
|
214
|
+
elif location is None:
|
|
215
|
+
reason = "no usable Location"
|
|
216
|
+
elif origin(location) != origin(url):
|
|
217
|
+
reason = "it leaves the scheme, host and port of the request"
|
|
218
|
+
elif not can_resend_body(body):
|
|
219
|
+
reason = "the request body cannot be sent twice"
|
|
220
|
+
else:
|
|
221
|
+
_discard(resp)
|
|
222
|
+
resp = transport.send(
|
|
223
|
+
method, location, headers=headers, body=body, timeout=timeout, stream=stream
|
|
224
|
+
)
|
|
225
|
+
if not 300 <= resp.status < 400:
|
|
226
|
+
return resp
|
|
227
|
+
url, location = location, redirect_location(location, resp)
|
|
228
|
+
reason = "a second redirect"
|
|
229
|
+
_discard(resp)
|
|
230
|
+
raise refused_redirect(method, url, resp, location, reason)
|