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.
Files changed (104) hide show
  1. pathapi/AGENTS.md +2355 -0
  2. pathapi/README.md +205 -0
  3. pathapi/__init__.py +62 -0
  4. pathapi/_commit.py +116 -0
  5. pathapi/_discard.py +92 -0
  6. pathapi/_errorpolicy.py +106 -0
  7. pathapi/_filestat.py +194 -0
  8. pathapi/_lru.py +138 -0
  9. pathapi/_names.py +70 -0
  10. pathapi/_permissions.py +102 -0
  11. pathapi/_stubs.py +76 -0
  12. pathapi/_tree.py +136 -0
  13. pathapi/archive/__init__.py +44 -0
  14. pathapi/archive/_backend.py +393 -0
  15. pathapi/archive/_names.py +63 -0
  16. pathapi/archive/_pack.py +394 -0
  17. pathapi/archive/_path.py +352 -0
  18. pathapi/archive/_snapshot.py +269 -0
  19. pathapi/archive/_tar.py +126 -0
  20. pathapi/archive/_write.py +359 -0
  21. pathapi/archive/_zip.py +563 -0
  22. pathapi/archive/_zipentries.py +272 -0
  23. pathapi/checksum.py +61 -0
  24. pathapi/exceptions.py +22 -0
  25. pathapi/fspath.py +513 -0
  26. pathapi/ftp/__init__.py +27 -0
  27. pathapi/ftp/_backend.py +418 -0
  28. pathapi/ftp/_facts.py +117 -0
  29. pathapi/ftp/_path.py +582 -0
  30. pathapi/glob/__init__.py +27 -0
  31. pathapi/glob/_entries.py +103 -0
  32. pathapi/glob/_patterns.py +164 -0
  33. pathapi/glob/_recursion.py +180 -0
  34. pathapi/glob/_selection.py +303 -0
  35. pathapi/http/__init__.py +84 -0
  36. pathapi/http/_body.py +156 -0
  37. pathapi/http/_builtin.py +553 -0
  38. pathapi/http/_client.py +211 -0
  39. pathapi/http/_errors.py +79 -0
  40. pathapi/http/_follow.py +230 -0
  41. pathapi/http/_headers.py +233 -0
  42. pathapi/http/_pool.py +169 -0
  43. pathapi/http/_proxies.py +142 -0
  44. pathapi/http/_response.py +255 -0
  45. pathapi/http/_settings.py +12 -0
  46. pathapi/http/_status.py +68 -0
  47. pathapi/http/_transports.py +173 -0
  48. pathapi/http/testing/__init__.py +80 -0
  49. pathapi/http/testing/__main__.py +10 -0
  50. pathapi/http/testing/suite/conftest.py +98 -0
  51. pathapi/http/testing/suite/test_client_default.py +285 -0
  52. pathapi/http/testing/suite/test_client_errors.py +181 -0
  53. pathapi/http/testing/suite/test_client_peer_text.py +86 -0
  54. pathapi/http/testing/suite/test_client_redirect_origin.py +134 -0
  55. pathapi/http/testing/suite/test_client_redirects.py +231 -0
  56. pathapi/http/testing/suite/test_client_reply_limits.py +201 -0
  57. pathapi/http/testing/suite/test_client_write_redirects.py +239 -0
  58. pathapi/http/testing/suite/test_transport_body.py +302 -0
  59. pathapi/http/testing/suite/test_transport_send.py +662 -0
  60. pathapi/http/testing/suite/transport_fakes.py +133 -0
  61. pathapi/http/testing/suite/transport_guard.py +174 -0
  62. pathapi/http/testing/suite/transport_wire.py +511 -0
  63. pathapi/mempath.py +438 -0
  64. pathapi/path/__init__.py +19 -0
  65. pathapi/path/_info.py +10 -0
  66. pathapi/path/_path.py +198 -0
  67. pathapi/path/_pure.py +522 -0
  68. pathapi/path/_relation.py +10 -0
  69. pathapi/path/_transfer.py +10 -0
  70. pathapi/protocols/__init__.py +35 -0
  71. pathapi/protocols/_bundles.py +22 -0
  72. pathapi/protocols/_errors.py +12 -0
  73. pathapi/protocols/_info.py +65 -0
  74. pathapi/protocols/_relation.py +191 -0
  75. pathapi/protocols/_removal.py +69 -0
  76. pathapi/protocols/_support.py +122 -0
  77. pathapi/protocols/_transfer.py +100 -0
  78. pathapi/protocols/checksum.py +80 -0
  79. pathapi/protocols/fs.py +429 -0
  80. pathapi/protocols/io.py +224 -0
  81. pathapi/protocols/links.py +135 -0
  82. pathapi/protocols/pure.py +31 -0
  83. pathapi/protocols/resolve.py +97 -0
  84. pathapi/protocols/transfer.py +656 -0
  85. pathapi/protocols/tree.py +492 -0
  86. pathapi/providers.py +48 -0
  87. pathapi/py.typed +1 -0
  88. pathapi/sync/__init__.py +8 -0
  89. pathapi/sync/_compare.py +157 -0
  90. pathapi/sync/_events.py +97 -0
  91. pathapi/sync/_links.py +93 -0
  92. pathapi/sync/_offer.py +33 -0
  93. pathapi/sync/_paths.py +50 -0
  94. pathapi/sync/_replace.py +98 -0
  95. pathapi/sync/_syncer.py +989 -0
  96. pathapi/testing/__init__.py +96 -0
  97. pathapi/testing/_contract.py +522 -0
  98. pathapi/testing/_fixtures.py +83 -0
  99. pathapi/testing/_read.py +279 -0
  100. pathapi-0.1.0.dist-info/METADATA +242 -0
  101. pathapi-0.1.0.dist-info/RECORD +104 -0
  102. pathapi-0.1.0.dist-info/WHEEL +4 -0
  103. pathapi-0.1.0.dist-info/entry_points.txt +2 -0
  104. pathapi-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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
+ )
@@ -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
@@ -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)