syshttp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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)