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.
Files changed (51) hide show
  1. xtr_http_client/.agents/skills/xtr-http-client/SKILL.md +289 -0
  2. xtr_http_client/__init__.py +47 -0
  3. xtr_http_client/bundle/__init__.py +17 -0
  4. xtr_http_client/bundle/_clients.py +217 -0
  5. xtr_http_client/bundle/client_options_config.py +79 -0
  6. xtr_http_client/bundle/http_client_bundle.py +85 -0
  7. xtr_http_client/bundle/http_client_config.py +75 -0
  8. xtr_http_client/bundle/retry_failed_config.py +77 -0
  9. xtr_http_client/bundle/scoped_client_config.py +99 -0
  10. xtr_http_client/exception/__init__.py +40 -0
  11. xtr_http_client/exception/_http_error_message.py +129 -0
  12. xtr_http_client/http_client.py +57 -0
  13. xtr_http_client/http_client_mixin.py +176 -0
  14. xtr_http_client/httpx_http_client.py +327 -0
  15. xtr_http_client/internal/__init__.py +13 -0
  16. xtr_http_client/internal/debug_type.py +14 -0
  17. xtr_http_client/internal/httpx_body.py +88 -0
  18. xtr_http_client/internal/httpx_clients.py +203 -0
  19. xtr_http_client/internal/httpx_errors.py +63 -0
  20. xtr_http_client/internal/httpx_redirect.py +63 -0
  21. xtr_http_client/internal/httpx_request.py +272 -0
  22. xtr_http_client/internal/mock_head.py +144 -0
  23. xtr_http_client/internal/mock_info.py +86 -0
  24. xtr_http_client/internal/option_merge.py +108 -0
  25. xtr_http_client/internal/response_body.py +270 -0
  26. xtr_http_client/internal/retry_count_response.py +148 -0
  27. xtr_http_client/internal/retry_delay.py +45 -0
  28. xtr_http_client/internal/retry_options.py +98 -0
  29. xtr_http_client/mock_http_client.py +253 -0
  30. xtr_http_client/no_private_network_http_client.py +342 -0
  31. xtr_http_client/py.typed +0 -0
  32. xtr_http_client/recipe/__init__.py +15 -0
  33. xtr_http_client/recipe/manifest.toml +23 -0
  34. xtr_http_client/response/__init__.py +10 -0
  35. xtr_http_client/response/http_response.py +412 -0
  36. xtr_http_client/response/httpx_response.py +173 -0
  37. xtr_http_client/response/json_mock_response.py +119 -0
  38. xtr_http_client/response/mock_response.py +375 -0
  39. xtr_http_client/retry/__init__.py +25 -0
  40. xtr_http_client/retry/generic_retry_strategy.py +215 -0
  41. xtr_http_client/retry/retry_context.py +73 -0
  42. xtr_http_client/retry/retry_strategy_interface.py +74 -0
  43. xtr_http_client/retryable_http_client.py +346 -0
  44. xtr_http_client/scoping_http_client.py +303 -0
  45. xtr_http_client/throttling_http_client.py +143 -0
  46. xtr_http_client/traceable_http_client.py +406 -0
  47. xtr_http_client-4.0.2.dist-info/METADATA +505 -0
  48. xtr_http_client-4.0.2.dist-info/RECORD +51 -0
  49. xtr_http_client-4.0.2.dist-info/WHEEL +4 -0
  50. xtr_http_client-4.0.2.dist-info/entry_points.txt +6 -0
  51. 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)}