xtr-http-client 4.0.2__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.
- xtr_http_client/.agents/skills/xtr-http-client/SKILL.md +289 -0
- xtr_http_client/__init__.py +47 -0
- xtr_http_client/bundle/__init__.py +17 -0
- xtr_http_client/bundle/_clients.py +217 -0
- xtr_http_client/bundle/client_options_config.py +79 -0
- xtr_http_client/bundle/http_client_bundle.py +85 -0
- xtr_http_client/bundle/http_client_config.py +75 -0
- xtr_http_client/bundle/retry_failed_config.py +77 -0
- xtr_http_client/bundle/scoped_client_config.py +99 -0
- xtr_http_client/exception/__init__.py +40 -0
- xtr_http_client/exception/_http_error_message.py +129 -0
- xtr_http_client/http_client.py +57 -0
- xtr_http_client/http_client_mixin.py +176 -0
- xtr_http_client/httpx_http_client.py +327 -0
- xtr_http_client/internal/__init__.py +13 -0
- xtr_http_client/internal/debug_type.py +14 -0
- xtr_http_client/internal/httpx_body.py +88 -0
- xtr_http_client/internal/httpx_clients.py +203 -0
- xtr_http_client/internal/httpx_errors.py +63 -0
- xtr_http_client/internal/httpx_redirect.py +63 -0
- xtr_http_client/internal/httpx_request.py +272 -0
- xtr_http_client/internal/mock_head.py +144 -0
- xtr_http_client/internal/mock_info.py +86 -0
- xtr_http_client/internal/option_merge.py +108 -0
- xtr_http_client/internal/response_body.py +270 -0
- xtr_http_client/internal/retry_count_response.py +148 -0
- xtr_http_client/internal/retry_delay.py +45 -0
- xtr_http_client/internal/retry_options.py +98 -0
- xtr_http_client/mock_http_client.py +253 -0
- xtr_http_client/no_private_network_http_client.py +342 -0
- xtr_http_client/py.typed +0 -0
- xtr_http_client/recipe/__init__.py +15 -0
- xtr_http_client/recipe/manifest.toml +23 -0
- xtr_http_client/response/__init__.py +10 -0
- xtr_http_client/response/http_response.py +412 -0
- xtr_http_client/response/httpx_response.py +173 -0
- xtr_http_client/response/json_mock_response.py +119 -0
- xtr_http_client/response/mock_response.py +375 -0
- xtr_http_client/retry/__init__.py +25 -0
- xtr_http_client/retry/generic_retry_strategy.py +215 -0
- xtr_http_client/retry/retry_context.py +73 -0
- xtr_http_client/retry/retry_strategy_interface.py +74 -0
- xtr_http_client/retryable_http_client.py +346 -0
- xtr_http_client/scoping_http_client.py +303 -0
- xtr_http_client/throttling_http_client.py +143 -0
- xtr_http_client/traceable_http_client.py +406 -0
- xtr_http_client-4.0.2.dist-info/METADATA +505 -0
- xtr_http_client-4.0.2.dist-info/RECORD +51 -0
- xtr_http_client-4.0.2.dist-info/WHEEL +4 -0
- xtr_http_client-4.0.2.dist-info/entry_points.txt +6 -0
- xtr_http_client-4.0.2.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""The backend clients a request is actually opened on, one per way of connecting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ssl
|
|
6
|
+
import urllib.request
|
|
7
|
+
from typing import TYPE_CHECKING, Final, final
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from xtr_http_client.exception import InvalidArgumentError
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from typing import TypeAlias
|
|
16
|
+
|
|
17
|
+
_Key: TypeAlias = tuple[object, ...]
|
|
18
|
+
|
|
19
|
+
__all__ = ["HttpxClients"]
|
|
20
|
+
|
|
21
|
+
# What `timeout` of `None` means: long enough for a slow server, short enough
|
|
22
|
+
# to free the connection. The contract names the number; this is where it is
|
|
23
|
+
# turned into one.
|
|
24
|
+
_IDLE_TIMEOUT: Final = 60.0
|
|
25
|
+
|
|
26
|
+
_HTTP_1_1: Final = (None, "1.1")
|
|
27
|
+
_HTTP_2: Final = ("2", "2.0")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@final
|
|
31
|
+
class HttpxClients:
|
|
32
|
+
"""One backend client per set of connection settings, opened when first asked for.
|
|
33
|
+
|
|
34
|
+
Two requests can only share a connection pool when they would be happy
|
|
35
|
+
with each other's connections. A proxy, a trust store, a client
|
|
36
|
+
certificate, the protocol version and the deadlines all decide what a
|
|
37
|
+
connection *is*, so each combination of them gets a pool of its own, made
|
|
38
|
+
the first time something asks for it — which is what keeps a client that
|
|
39
|
+
is never used from opening anything.
|
|
40
|
+
|
|
41
|
+
The environment's proxy settings are part of that too. The backend reads
|
|
42
|
+
them once, when a pool is made, so they are read here as well and a
|
|
43
|
+
change in them asks for a different pool rather than being ignored.
|
|
44
|
+
|
|
45
|
+
Each pool also remembers the addresses that requests pinned their hosts
|
|
46
|
+
to, for as long as the pool lives: a host reached at an address once is
|
|
47
|
+
reached at it again, without asking a resolver a second time.
|
|
48
|
+
|
|
49
|
+
``max_connections`` is the backend's pool-wide ceiling, not a per-host
|
|
50
|
+
one: it counts every connection the pool holds, whichever host each of
|
|
51
|
+
them goes to.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
__slots__ = ("_clients", "_max_connections", "_resolved")
|
|
55
|
+
|
|
56
|
+
def __init__(self, max_connections: int) -> None:
|
|
57
|
+
"""Open nothing yet.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
max_connections: How many connections one pool may hold at once,
|
|
61
|
+
counted across every host it reaches.
|
|
62
|
+
"""
|
|
63
|
+
self._max_connections = max_connections
|
|
64
|
+
self._clients: dict[_Key, httpx.AsyncClient] = {}
|
|
65
|
+
self._resolved: dict[_Key, dict[str, str]] = {}
|
|
66
|
+
|
|
67
|
+
def for_options(
|
|
68
|
+
self,
|
|
69
|
+
options: Mapping[str, object],
|
|
70
|
+
) -> tuple[httpx.AsyncClient, dict[str, str]]:
|
|
71
|
+
"""Return the pool a request made with ``options`` belongs to.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
options: The request's options, merged over the client's
|
|
75
|
+
defaults.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The pool's client, and the addresses it has had hosts pinned to.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
InvalidArgumentError: When the HTTP version asked for is not one
|
|
82
|
+
this client can send.
|
|
83
|
+
"""
|
|
84
|
+
key = _key(options)
|
|
85
|
+
client = self._clients.get(key)
|
|
86
|
+
if client is None:
|
|
87
|
+
client = _build(options, self._max_connections)
|
|
88
|
+
self._clients[key] = client
|
|
89
|
+
self._resolved[key] = {}
|
|
90
|
+
|
|
91
|
+
return client, self._resolved[key]
|
|
92
|
+
|
|
93
|
+
async def aclose(self) -> None:
|
|
94
|
+
"""Close every pool, and with it every connection any of them holds."""
|
|
95
|
+
clients = list(self._clients.values())
|
|
96
|
+
self._clients.clear()
|
|
97
|
+
self._resolved.clear()
|
|
98
|
+
|
|
99
|
+
for client in clients:
|
|
100
|
+
await client.aclose()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _key(options: Mapping[str, object]) -> _Key:
|
|
104
|
+
"""What makes two requests able to share a pool."""
|
|
105
|
+
return (
|
|
106
|
+
options.get("proxy"),
|
|
107
|
+
options.get("verify_peer"),
|
|
108
|
+
options.get("verify_host"),
|
|
109
|
+
options.get("cafile"),
|
|
110
|
+
options.get("capath"),
|
|
111
|
+
options.get("local_cert"),
|
|
112
|
+
options.get("local_pk"),
|
|
113
|
+
options.get("passphrase"),
|
|
114
|
+
_wants_http2(options.get("http_version")),
|
|
115
|
+
options.get("timeout"),
|
|
116
|
+
options.get("max_connect_duration"),
|
|
117
|
+
tuple(sorted(urllib.request.getproxies().items())),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _build(options: Mapping[str, object], max_connections: int) -> httpx.AsyncClient:
|
|
122
|
+
"""Open the pool ``options`` asks for."""
|
|
123
|
+
proxy = options.get("proxy")
|
|
124
|
+
timeout = options.get("timeout")
|
|
125
|
+
connect = options.get("max_connect_duration")
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
return httpx.AsyncClient(
|
|
129
|
+
trust_env=True,
|
|
130
|
+
follow_redirects=False,
|
|
131
|
+
http2=_wants_http2(options.get("http_version")),
|
|
132
|
+
limits=httpx.Limits(max_connections=max_connections),
|
|
133
|
+
timeout=httpx.Timeout(
|
|
134
|
+
float(timeout) if isinstance(timeout, (int, float)) else _IDLE_TIMEOUT,
|
|
135
|
+
connect=float(connect) if isinstance(connect, (int, float)) and connect else None,
|
|
136
|
+
),
|
|
137
|
+
proxy=str(proxy) if proxy else None,
|
|
138
|
+
verify=_verification(options),
|
|
139
|
+
)
|
|
140
|
+
except ImportError as error:
|
|
141
|
+
# The HTTP/2 framing is an optional install, and asking for a version
|
|
142
|
+
# that was never installed is an argument this client cannot honour.
|
|
143
|
+
raise InvalidArgumentError(
|
|
144
|
+
'Option "http_version" of "2" needs the "http2" extra: install xtr-http-client[http2].'
|
|
145
|
+
) from error
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _wants_http2(version: object) -> bool:
|
|
149
|
+
"""Whether ``version`` asks for HTTP/2.
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
InvalidArgumentError: When it asks for a version this client cannot
|
|
153
|
+
send. HTTP/1.0 is one of them: nothing here can be made to leave
|
|
154
|
+
out what 1.1 added.
|
|
155
|
+
"""
|
|
156
|
+
if version in _HTTP_1_1:
|
|
157
|
+
return False
|
|
158
|
+
if version in _HTTP_2:
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
raise InvalidArgumentError(
|
|
162
|
+
f'Invalid HTTP version "{version}", only "1.1" and "2" can be asked for.'
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _verification(options: Mapping[str, object]) -> ssl.SSLContext | bool:
|
|
167
|
+
"""How the peer's certificate is to be checked, as the backend takes it.
|
|
168
|
+
|
|
169
|
+
``True`` and ``False`` are the two plain answers — check with the
|
|
170
|
+
platform's trust store, or check nothing. Anything in between is a
|
|
171
|
+
context, because that is the only place another trust store, a client
|
|
172
|
+
certificate or a name that is not to be matched can be said.
|
|
173
|
+
"""
|
|
174
|
+
if not options.get("verify_peer", True):
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
verify_host = bool(options.get("verify_host", True))
|
|
178
|
+
cafile = _text(options.get("cafile"))
|
|
179
|
+
capath = _text(options.get("capath"))
|
|
180
|
+
local_cert = _text(options.get("local_cert"))
|
|
181
|
+
|
|
182
|
+
if verify_host and cafile is None and capath is None and local_cert is None:
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
context = ssl.create_default_context(cafile=cafile, capath=capath)
|
|
186
|
+
# Order matters: the hostname check cannot be turned off while the
|
|
187
|
+
# certificate is still being verified against a name.
|
|
188
|
+
context.check_hostname = verify_host
|
|
189
|
+
context.verify_mode = ssl.CERT_REQUIRED
|
|
190
|
+
|
|
191
|
+
if local_cert is not None:
|
|
192
|
+
context.load_cert_chain(
|
|
193
|
+
local_cert,
|
|
194
|
+
_text(options.get("local_pk")),
|
|
195
|
+
_text(options.get("passphrase")),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
return context
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _text(value: object) -> str | None:
|
|
202
|
+
"""``value`` as text, or ``None`` when it was left out."""
|
|
203
|
+
return value if isinstance(value, str) else None
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""What a failure of the backend transfer means in this package's own terms.
|
|
2
|
+
|
|
3
|
+
A caller holding the client interface has never heard of the backend's own
|
|
4
|
+
exceptions, so none of them may reach one. The translation is here, in one
|
|
5
|
+
place, because the same failure can surface while the head is being read and
|
|
6
|
+
again while the body is: the head is read where the request is made, the body
|
|
7
|
+
wherever the caller asks for it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from xtr_http_client.exception import (
|
|
15
|
+
InvalidArgumentError,
|
|
16
|
+
TransportError,
|
|
17
|
+
TransportTimeoutError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = ["timed_out", "transport_error"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def transport_error(error: Exception, url: str) -> TransportError:
|
|
24
|
+
"""Return the error to raise for a failed transfer, naming the URL it failed for.
|
|
25
|
+
|
|
26
|
+
Three kinds are told apart, because a caller does different things about
|
|
27
|
+
them: running out of time is worth trying again, a URL or a scheme the
|
|
28
|
+
backend cannot make a request from is worth correcting, and everything
|
|
29
|
+
else is the network.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
error: What the backend raised.
|
|
33
|
+
url: The URL the transfer was for, kept in the message so a failure
|
|
34
|
+
reported far from the call still says what it was about.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
The error to raise, which the caller is to chain onto ``error``.
|
|
38
|
+
"""
|
|
39
|
+
said = str(error) or type(error).__name__
|
|
40
|
+
reason = f'{said} for "{url}".'
|
|
41
|
+
|
|
42
|
+
if isinstance(error, httpx.TimeoutException):
|
|
43
|
+
return TransportTimeoutError(reason)
|
|
44
|
+
if isinstance(error, (httpx.UnsupportedProtocol, httpx.InvalidURL)):
|
|
45
|
+
return InvalidArgumentError(reason)
|
|
46
|
+
|
|
47
|
+
return TransportError(reason)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def timed_out(url: str) -> TransportTimeoutError:
|
|
51
|
+
"""Return the error for a transfer that outlasted ``max_duration``.
|
|
52
|
+
|
|
53
|
+
The deadline is this package's own rather than the backend's — the whole
|
|
54
|
+
request and response are measured, which nothing in the backend counts —
|
|
55
|
+
so there is no backend error to translate.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
url: The URL the transfer was for.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The error to raise, which the caller is to chain onto the deadline.
|
|
62
|
+
"""
|
|
63
|
+
return TransportTimeoutError(f'Maximum duration reached for "{url}".')
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Where an answer points, and whether following it stays where it started."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, cast
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
__all__ = ["next_hop", "same_origin"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def next_hop(response: httpx.Response) -> httpx.Request | None:
|
|
14
|
+
"""Return the request that following ``response`` would make.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
response: The answer to read a redirect off.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
The next request, or ``None`` when there is nowhere to go — either
|
|
21
|
+
because the answer is not a redirect, or because where it points is
|
|
22
|
+
not somewhere a request can be made to.
|
|
23
|
+
"""
|
|
24
|
+
following = response.next_request
|
|
25
|
+
if following is None:
|
|
26
|
+
return None
|
|
27
|
+
location = cast("str", response.headers.get("location", ""))
|
|
28
|
+
if _names_no_host(location) or not following.url.host:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
return following
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def same_origin(one: httpx.URL, other: httpx.URL) -> bool:
|
|
35
|
+
"""Tell whether two URLs name the same scheme, host and port.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
one: A URL.
|
|
39
|
+
other: The URL to compare it with.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
``True`` when a connection to either would be a connection to the
|
|
43
|
+
same place.
|
|
44
|
+
"""
|
|
45
|
+
return (one.scheme, one.host, one.port) == (other.scheme, other.host, other.port)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _names_no_host(location: str) -> bool:
|
|
49
|
+
"""Whether a Location names an authority and then leaves it empty.
|
|
50
|
+
|
|
51
|
+
``//?foo=bar`` says "another host, the same scheme" and names no host, so
|
|
52
|
+
there is nowhere for it to point; a relative reference such as ``..``
|
|
53
|
+
names no authority at all, which is not the same thing — it is resolved
|
|
54
|
+
against the URL it arrived on.
|
|
55
|
+
"""
|
|
56
|
+
if not location.startswith("//"):
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
authority = location[2:]
|
|
60
|
+
for delimiter in ("/", "?", "#"):
|
|
61
|
+
authority = authority.partition(delimiter)[0]
|
|
62
|
+
|
|
63
|
+
return authority == ""
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""What an outgoing request is assembled from, before a connection is opened.
|
|
2
|
+
|
|
3
|
+
The request is authoritative and the options fill what it leaves out, so each
|
|
4
|
+
of its four parts — the URL, the headers, the body and the address the host is
|
|
5
|
+
reached at — is worked out from the two of them together. That is one decision
|
|
6
|
+
per part, made here, away from the exchange and the redirect chain that follow.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import AsyncIterable, Mapping, Sequence
|
|
12
|
+
from typing import TYPE_CHECKING, Final, NamedTuple, cast
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from xtr_http_client.exception import InvalidArgumentError, TransportError
|
|
17
|
+
|
|
18
|
+
from .debug_type import debug_type
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import AsyncIterator
|
|
22
|
+
|
|
23
|
+
from xtr_http_message_contracts import RequestInterface
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Payload",
|
|
27
|
+
"assemble_headers",
|
|
28
|
+
"assemble_payload",
|
|
29
|
+
"base_uri",
|
|
30
|
+
"carries",
|
|
31
|
+
"checked_body",
|
|
32
|
+
"header_values",
|
|
33
|
+
"pin_host",
|
|
34
|
+
"target_url",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
# Quoted by the contract rather than described, so it is written out once and
|
|
38
|
+
# never composed.
|
|
39
|
+
_BOTH_BODIES: Final = (
|
|
40
|
+
'Define either the request body or the "body" or "json" option, setting both is not supported.'
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# What a body of bytes, text or pieces is taken to be when nobody said: the
|
|
44
|
+
# only shape a request body has without a content type to read it by.
|
|
45
|
+
_FORM: Final = "application/x-www-form-urlencoded"
|
|
46
|
+
|
|
47
|
+
_NO_BODY: Final = (None, b"", "")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Payload(NamedTuple):
|
|
51
|
+
"""The one body of a request, in whichever of three shapes it is to be sent."""
|
|
52
|
+
|
|
53
|
+
content: bytes | str | AsyncIterator[bytes] | None
|
|
54
|
+
form: Mapping[str, object] | None
|
|
55
|
+
document: object
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def target_url(request: RequestInterface, options: Mapping[str, object]) -> httpx.URL:
|
|
59
|
+
"""The URL to request: the request's own, resolved and with ``query`` merged in."""
|
|
60
|
+
url = httpx.URL(str(request.get_uri()))
|
|
61
|
+
|
|
62
|
+
base = options.get("base_uri")
|
|
63
|
+
if url.is_relative_url and base is not None:
|
|
64
|
+
url = httpx.URL(base_uri(base)).join(url)
|
|
65
|
+
|
|
66
|
+
query = options.get("query")
|
|
67
|
+
if query:
|
|
68
|
+
url = url.copy_merge_params(cast("Mapping[str, str]", query))
|
|
69
|
+
|
|
70
|
+
return url
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def base_uri(base: object) -> str:
|
|
74
|
+
"""The URI a relative URL is resolved against.
|
|
75
|
+
|
|
76
|
+
A sequence of them is for a client that retries, which moves along it
|
|
77
|
+
from one attempt to the next; this one makes a single attempt, so it
|
|
78
|
+
resolves against the first.
|
|
79
|
+
"""
|
|
80
|
+
if isinstance(base, str):
|
|
81
|
+
return base
|
|
82
|
+
if isinstance(base, Sequence) and base:
|
|
83
|
+
first = base[0]
|
|
84
|
+
if isinstance(first, str):
|
|
85
|
+
return first
|
|
86
|
+
|
|
87
|
+
raise InvalidArgumentError(
|
|
88
|
+
f'Option "base_uri" must be a URL or a list of them, "{debug_type(base)}" given.'
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def assemble_headers(
|
|
93
|
+
request: RequestInterface,
|
|
94
|
+
options: Mapping[str, object],
|
|
95
|
+
) -> list[tuple[str, str]]:
|
|
96
|
+
"""Every header to send: the request's, then the names it left out.
|
|
97
|
+
|
|
98
|
+
The ``Host`` the request worked out from its own URI is dropped, because
|
|
99
|
+
the URL actually requested may not be that one — a relative URI resolved
|
|
100
|
+
against a base, or a host pinned to an address, both move it — and a
|
|
101
|
+
header nobody asked for must not be the one that decides where a request
|
|
102
|
+
lands. One the caller set by hand is kept and sent exactly as given.
|
|
103
|
+
"""
|
|
104
|
+
headers = [
|
|
105
|
+
(name, value)
|
|
106
|
+
for name, values in request.get_headers().items()
|
|
107
|
+
for value in values
|
|
108
|
+
if not _is_implied_host(name, value, request)
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
for name, value in _mapping(options.get("headers")).items():
|
|
112
|
+
if carries(headers, name):
|
|
113
|
+
continue
|
|
114
|
+
headers += header_values(name, value)
|
|
115
|
+
|
|
116
|
+
bearer = options.get("auth_bearer")
|
|
117
|
+
if isinstance(bearer, str) and bearer and not carries(headers, "authorization"):
|
|
118
|
+
headers.append(("Authorization", f"Bearer {bearer}"))
|
|
119
|
+
|
|
120
|
+
return headers
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _is_implied_host(name: str, value: str, request: RequestInterface) -> bool:
|
|
124
|
+
"""Whether a header is the ``Host`` a request filled in from its own URI."""
|
|
125
|
+
if name.lower() != "host":
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
uri = request.get_uri()
|
|
129
|
+
host = uri.get_host()
|
|
130
|
+
port = uri.get_port()
|
|
131
|
+
|
|
132
|
+
return value == (host if port is None else f"{host}:{port}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def assemble_payload(
|
|
136
|
+
request: RequestInterface,
|
|
137
|
+
options: Mapping[str, object],
|
|
138
|
+
headers: list[tuple[str, str]],
|
|
139
|
+
) -> Payload:
|
|
140
|
+
"""What to send as the body, from the request or from the options, never both.
|
|
141
|
+
|
|
142
|
+
A request carrying a body has already said what to send, so a ``body`` or
|
|
143
|
+
``json`` option alongside it is two answers to one question and is
|
|
144
|
+
refused rather than one of them being picked.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
InvalidArgumentError: When the request carries a body and an option
|
|
148
|
+
gives another.
|
|
149
|
+
"""
|
|
150
|
+
carried = await request.get_body().get_contents()
|
|
151
|
+
if carried:
|
|
152
|
+
if options.get("body") not in _NO_BODY or options.get("json") is not None:
|
|
153
|
+
raise InvalidArgumentError(_BOTH_BODIES)
|
|
154
|
+
_imply_form(headers)
|
|
155
|
+
|
|
156
|
+
return Payload(carried, None, None)
|
|
157
|
+
|
|
158
|
+
return _asked_for(options, headers)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _asked_for(options: Mapping[str, object], headers: list[tuple[str, str]]) -> Payload:
|
|
162
|
+
"""What the options alone say to send, for a request that carried no body.
|
|
163
|
+
|
|
164
|
+
A mapping is a form, which the backend writes out; bytes, text and an
|
|
165
|
+
iterable are a body nobody labelled, so they are said to be a form here;
|
|
166
|
+
and a JSON document labels itself.
|
|
167
|
+
"""
|
|
168
|
+
document = options.get("json")
|
|
169
|
+
if document is not None:
|
|
170
|
+
return Payload(None, None, document)
|
|
171
|
+
|
|
172
|
+
body = options.get("body")
|
|
173
|
+
if isinstance(body, Mapping):
|
|
174
|
+
return Payload(None, cast("Mapping[str, object]", body), None)
|
|
175
|
+
if isinstance(body, (bytes, str)) and body:
|
|
176
|
+
_imply_form(headers)
|
|
177
|
+
|
|
178
|
+
return Payload(body, None, None)
|
|
179
|
+
if isinstance(body, AsyncIterable):
|
|
180
|
+
_imply_form(headers)
|
|
181
|
+
|
|
182
|
+
return Payload(checked_body(body), None, None)
|
|
183
|
+
|
|
184
|
+
return Payload(None, None, None)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def checked_body(source: AsyncIterable[object]) -> AsyncIterator[bytes]:
|
|
188
|
+
"""``source``, with a piece that is not bytes refused before it reaches the wire.
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
TransportError: When the body produces anything other than bytes.
|
|
192
|
+
The request fails rather than the backend tripping over it with
|
|
193
|
+
an error from somewhere a caller cannot place.
|
|
194
|
+
"""
|
|
195
|
+
async for piece in source:
|
|
196
|
+
if not isinstance(piece, (bytes, bytearray)):
|
|
197
|
+
raise TransportError(
|
|
198
|
+
f'The request body produced "{debug_type(piece)}", which is not bytes.'
|
|
199
|
+
)
|
|
200
|
+
yield bytes(piece)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def pin_host(
|
|
204
|
+
url: httpx.URL,
|
|
205
|
+
options: Mapping[str, object],
|
|
206
|
+
resolved: dict[str, str],
|
|
207
|
+
headers: list[tuple[str, str]],
|
|
208
|
+
) -> tuple[httpx.URL, dict[str, str]]:
|
|
209
|
+
"""The URL to connect to, with the host swapped for the address it is pinned to.
|
|
210
|
+
|
|
211
|
+
Pinning is what makes a request reach an address that has already been
|
|
212
|
+
checked, so nothing can change under it between the check and the
|
|
213
|
+
connection. The host survives the swap in two places that still need it:
|
|
214
|
+
the ``Host`` header, which is what the server routes on, and the name the
|
|
215
|
+
TLS handshake asks for.
|
|
216
|
+
"""
|
|
217
|
+
resolved.update(_text_mapping(options.get("resolve")))
|
|
218
|
+
|
|
219
|
+
host = url.raw_host.decode("ascii")
|
|
220
|
+
address = resolved.get(host)
|
|
221
|
+
if address is None:
|
|
222
|
+
return url, {}
|
|
223
|
+
|
|
224
|
+
_replace(headers, "Host", host if url.port is None else f"{host}:{url.port}")
|
|
225
|
+
|
|
226
|
+
return url.copy_with(host=address), {"sni_hostname": host}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def carries(headers: Sequence[tuple[str, str]], name: str) -> bool:
|
|
230
|
+
"""Whether a header of that name is already there, matched without case."""
|
|
231
|
+
wanted = name.lower()
|
|
232
|
+
|
|
233
|
+
return any(present.lower() == wanted for present, _ in headers)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def header_values(name: str, value: object) -> list[tuple[str, str]]:
|
|
237
|
+
"""One header name and every value given for it."""
|
|
238
|
+
if isinstance(value, str):
|
|
239
|
+
return [(name, value)]
|
|
240
|
+
if isinstance(value, Sequence):
|
|
241
|
+
return [(name, str(one)) for one in value]
|
|
242
|
+
|
|
243
|
+
raise InvalidArgumentError(
|
|
244
|
+
f'Option "headers" must map a name to a string or a list of them, '
|
|
245
|
+
f'"{debug_type(value)}" given for "{name}".'
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _replace(headers: list[tuple[str, str]], name: str, value: str) -> None:
|
|
250
|
+
"""Make ``name`` hold ``value`` and nothing else."""
|
|
251
|
+
wanted = name.lower()
|
|
252
|
+
headers[:] = [entry for entry in headers if entry[0].lower() != wanted]
|
|
253
|
+
headers.append((name, value))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _imply_form(headers: list[tuple[str, str]]) -> None:
|
|
257
|
+
"""Say what an unlabelled body is, since a server cannot read one unlabelled."""
|
|
258
|
+
if not carries(headers, "content-type"):
|
|
259
|
+
headers.append(("Content-Type", _FORM))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _mapping(value: object) -> Mapping[str, object]:
|
|
263
|
+
"""``value`` as a mapping, or an empty one when it was left out."""
|
|
264
|
+
if isinstance(value, Mapping):
|
|
265
|
+
return cast("Mapping[str, object]", value)
|
|
266
|
+
|
|
267
|
+
return {}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _text_mapping(value: object) -> dict[str, str]:
|
|
271
|
+
"""``value`` as a mapping of text to text, keeping only the entries that are."""
|
|
272
|
+
return {name: entry for name, entry in _mapping(value).items() if isinstance(entry, str)}
|