boring-http 1.0.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.
boring_http/client.py ADDED
@@ -0,0 +1,692 @@
1
+ import asyncio
2
+ import base64
3
+ from collections.abc import AsyncGenerator, AsyncIterable, Mapping, Sequence
4
+ from contextlib import asynccontextmanager
5
+ from dataclasses import replace
6
+ from time import monotonic
7
+ from typing import Final, Self, TypedDict, Unpack, final
8
+
9
+ from boring_ssl2 import ClientContext as TLSClientContext
10
+ from ssl2 import (
11
+ AsyncTransport,
12
+ ClientHello,
13
+ ConfigurationError,
14
+ ExtensionType,
15
+ NextProtocol,
16
+ Proxy,
17
+ ProxyError,
18
+ ProxyScheme,
19
+ SessionCache,
20
+ StreamTransport,
21
+ TLSClientConfiguration,
22
+ TLSError,
23
+ TLSStream,
24
+ TrustStore,
25
+ )
26
+
27
+ from boring_http.cookies import CookieJar
28
+ from boring_http.errors import (
29
+ ConnectError,
30
+ HTTPError,
31
+ ProtocolError,
32
+ RemoteDisconnected,
33
+ StreamRefused,
34
+ TimeoutError,
35
+ TooManyRedirects,
36
+ )
37
+ from boring_http.http1 import Http1Connection
38
+ from boring_http.http2 import CHROME, Http2Connection, Http2Profile
39
+ from boring_http.http3 import DEFAULT as HTTP3_DEFAULT
40
+ from boring_http.http3 import Http3Connection, Http3Profile
41
+ from boring_http.message import (
42
+ URL,
43
+ Body,
44
+ FileField,
45
+ FormFields,
46
+ HeaderItems,
47
+ Headers,
48
+ HTTPVersion,
49
+ Params,
50
+ Request,
51
+ Response,
52
+ )
53
+ from boring_http.pool import Connection, Pool
54
+ from boring_http.quic.protocol import OriginMemory, QuicClient
55
+ from boring_http.timing import Deadline, Timeout
56
+ from boring_http.websocket import WebSocket
57
+
58
+ IMPLEMENTED: Final = (HTTPVersion.HTTP_3, HTTPVersion.HTTP_2, HTTPVersion.HTTP_1)
59
+ ALPN: Final = {
60
+ HTTPVersion.HTTP_2: NextProtocol.H2,
61
+ HTTPVersion.HTTP_1: NextProtocol.HTTP1,
62
+ }
63
+ ACCEPTED_ENCODINGS: Final = "gzip, deflate, br, zstd"
64
+ USER_AGENT: Final = "boring-http"
65
+ DEFAULT_TIMEOUT: Final = Timeout()
66
+
67
+
68
+ @final
69
+ class Unset:
70
+ pass
71
+
72
+
73
+ UNSET: Final = Unset()
74
+
75
+
76
+ class RequestOptions(TypedDict, total=False):
77
+ params: Params | None
78
+ headers: HeaderItems | None
79
+ json: object
80
+ data: FormFields | bytes | AsyncIterable[bytes] | None
81
+ content: bytes | str | AsyncIterable[bytes] | None
82
+ files: Mapping[str, FileField] | None
83
+ auth: tuple[str, str] | None
84
+ cookies: Mapping[str, str] | None
85
+ timeout: float | Timeout | None | Unset
86
+ allow_redirects: bool
87
+ proxy: Proxy | str | None | Unset
88
+
89
+
90
+ @final
91
+ class Client:
92
+ def __init__(
93
+ self,
94
+ *,
95
+ headers: HeaderItems | None = None,
96
+ timeout: float | Timeout | None = DEFAULT_TIMEOUT,
97
+ proxy: Proxy | str | None = None,
98
+ tls: ClientHello | None = None,
99
+ http2: Http2Profile = CHROME,
100
+ http3: Http3Profile = HTTP3_DEFAULT,
101
+ versions: Sequence[HTTPVersion | str] = (
102
+ HTTPVersion.HTTP_2,
103
+ HTTPVersion.HTTP_1,
104
+ ),
105
+ trust_store: TrustStore | None = None,
106
+ verify: bool = True,
107
+ cookies: CookieJar | None = None,
108
+ base_url: str | None = None,
109
+ max_connections: int = 100,
110
+ max_connections_per_host: int = 10,
111
+ max_redirects: int = 20,
112
+ max_message_size: int = 16 * 1024 * 1024,
113
+ decode: bool = True,
114
+ ) -> None:
115
+ self._headers = Headers(headers)
116
+ self._timeout = Timeout.of(timeout)
117
+ self._proxy = Proxy.parse(proxy) if isinstance(proxy, str) else proxy
118
+ self._tls = tls
119
+ self._http2 = http2
120
+ self._http3 = http3
121
+ self._alt_svc: dict[tuple[str, int], float] = {}
122
+ self._versions = tuple(HTTPVersion(version) for version in versions)
123
+ self._trust_store = trust_store
124
+ self._verify = verify
125
+ self._base_url = base_url
126
+ self._max_redirects = max_redirects
127
+ self._max_message_size = max_message_size
128
+ self._decode = decode
129
+ self._contexts: dict[tuple[NextProtocol, ...] | None, TLSClientContext] = {}
130
+ self._memory: dict[tuple[str, int], OriginMemory] = {}
131
+ self._pool = Pool(self._connect, max_connections, max_connections_per_host)
132
+ self.cookies = CookieJar() if cookies is None else cookies
133
+
134
+ async def __aenter__(self) -> Self:
135
+ return self
136
+
137
+ async def __aexit__(self, *_: object) -> None:
138
+ await self.close()
139
+
140
+ async def close(self) -> None:
141
+ await self._pool.close()
142
+
143
+ async def request(
144
+ self, method: str, url: str, **options: Unpack[RequestOptions]
145
+ ) -> Response:
146
+ request = self.build(
147
+ method,
148
+ url,
149
+ params=options.get("params"),
150
+ headers=options.get("headers"),
151
+ json=options.get("json"),
152
+ data=options.get("data"),
153
+ content=options.get("content"),
154
+ files=options.get("files"),
155
+ auth=options.get("auth"),
156
+ )
157
+ return await self.send(
158
+ request,
159
+ cookies=options.get("cookies"),
160
+ timeout=options.get("timeout", UNSET),
161
+ allow_redirects=options.get("allow_redirects", True),
162
+ proxy=options.get("proxy", UNSET),
163
+ )
164
+
165
+ async def get(self, url: str, **options: Unpack[RequestOptions]) -> Response:
166
+ return await self.request("GET", url, **options)
167
+
168
+ async def post(self, url: str, **options: Unpack[RequestOptions]) -> Response:
169
+ return await self.request("POST", url, **options)
170
+
171
+ async def put(self, url: str, **options: Unpack[RequestOptions]) -> Response:
172
+ return await self.request("PUT", url, **options)
173
+
174
+ async def patch(self, url: str, **options: Unpack[RequestOptions]) -> Response:
175
+ return await self.request("PATCH", url, **options)
176
+
177
+ async def delete(self, url: str, **options: Unpack[RequestOptions]) -> Response:
178
+ return await self.request("DELETE", url, **options)
179
+
180
+ async def head(self, url: str, **options: Unpack[RequestOptions]) -> Response:
181
+ return await self.request("HEAD", url, **options)
182
+
183
+ async def options(self, url: str, **options: Unpack[RequestOptions]) -> Response:
184
+ return await self.request("OPTIONS", url, **options)
185
+
186
+ @asynccontextmanager
187
+ async def stream(
188
+ self, method: str, url: str, **options: Unpack[RequestOptions]
189
+ ) -> AsyncGenerator[Response]:
190
+ request = self.build(
191
+ method,
192
+ url,
193
+ params=options.get("params"),
194
+ headers=options.get("headers"),
195
+ json=options.get("json"),
196
+ data=options.get("data"),
197
+ content=options.get("content"),
198
+ files=options.get("files"),
199
+ auth=options.get("auth"),
200
+ )
201
+ response = await self.send(
202
+ request,
203
+ cookies=options.get("cookies"),
204
+ timeout=options.get("timeout", UNSET),
205
+ allow_redirects=options.get("allow_redirects", True),
206
+ proxy=options.get("proxy", UNSET),
207
+ stream=True,
208
+ )
209
+ try:
210
+ yield response
211
+ finally:
212
+ await response.close()
213
+
214
+ def build(
215
+ self,
216
+ method: str,
217
+ url: str,
218
+ *,
219
+ params: Params | None = None,
220
+ headers: HeaderItems | None = None,
221
+ json: object = None,
222
+ data: FormFields | bytes | AsyncIterable[bytes] | None = None,
223
+ content: bytes | str | AsyncIterable[bytes] | None = None,
224
+ files: Mapping[str, FileField] | None = None,
225
+ auth: tuple[str, str] | None = None,
226
+ ) -> Request:
227
+ target = URL.parse(
228
+ url if self._base_url is None else URL.parse(self._base_url).join(url).text
229
+ )
230
+ if params is not None:
231
+ target = target.with_params(params)
232
+ body = self._body(json, data, content, files)
233
+ merged = self._headers.merged(Headers(headers))
234
+ if (
235
+ body is not None
236
+ and body.content_type is not None
237
+ and "content-type" not in merged
238
+ ):
239
+ merged.add("Content-Type", body.content_type)
240
+ if auth is not None:
241
+ merged.set(
242
+ "Authorization",
243
+ "Basic "
244
+ + base64.b64encode(f"{auth[0]}:{auth[1]}".encode()).decode("ascii"),
245
+ )
246
+ if "user-agent" not in merged:
247
+ merged.insert(0, "User-Agent", USER_AGENT)
248
+ if self._decode and "accept-encoding" not in merged:
249
+ merged.add("Accept-Encoding", ACCEPTED_ENCODINGS)
250
+ return Request(method.upper(), target, merged, body)
251
+
252
+ async def send(
253
+ self,
254
+ request: Request,
255
+ *,
256
+ cookies: Mapping[str, str] | None = None,
257
+ timeout: float | Timeout | None | Unset = UNSET,
258
+ allow_redirects: bool = True,
259
+ proxy: Proxy | str | None | Unset = UNSET,
260
+ stream: bool = False,
261
+ ) -> Response:
262
+ deadline = Deadline(
263
+ self._timeout if isinstance(timeout, Unset) else Timeout.of(timeout)
264
+ )
265
+ route = (
266
+ self._proxy
267
+ if isinstance(proxy, Unset)
268
+ else Proxy.parse(proxy)
269
+ if isinstance(proxy, str)
270
+ else proxy
271
+ )
272
+ history: list[Response] = []
273
+ while True:
274
+ response = await self._once(
275
+ self._with_cookies(request, cookies), deadline, route
276
+ )
277
+ self.cookies.store(response.cookies)
278
+ self._learn_alt_svc(response)
279
+ if not allow_redirects or not response.is_redirect:
280
+ response.history = tuple(history)
281
+ if not stream:
282
+ try:
283
+ _ = await response.read()
284
+ except BaseException:
285
+ await response.close()
286
+ raise
287
+ return response
288
+ if len(history) >= self._max_redirects:
289
+ await response.close()
290
+ raise TooManyRedirects(
291
+ f"more than {self._max_redirects} redirects from {history[0].url if history else request.url}"
292
+ )
293
+ _ = await response.read()
294
+ history.append(response)
295
+ request = self._redirect(request, response)
296
+
297
+ async def websocket(
298
+ self,
299
+ url: str,
300
+ *,
301
+ headers: HeaderItems | None = None,
302
+ subprotocols: Sequence[str] = (),
303
+ timeout: float | Timeout | None | Unset = UNSET,
304
+ proxy: Proxy | str | None | Unset = UNSET,
305
+ ) -> WebSocket:
306
+ request = self.build("GET", url, headers=headers)
307
+ if request.url.scheme not in ("ws", "wss"):
308
+ raise ValueError(
309
+ f"websocket needs a ws or wss URL, got {request.url.scheme}"
310
+ )
311
+ deadline = Deadline(
312
+ self._timeout if isinstance(timeout, Unset) else Timeout.of(timeout)
313
+ )
314
+ route = (
315
+ self._proxy
316
+ if isinstance(proxy, Unset)
317
+ else Proxy.parse(proxy)
318
+ if isinstance(proxy, str)
319
+ else proxy
320
+ )
321
+ request = Request("GET", request.url, request.headers)
322
+ request.headers.remove("Accept-Encoding")
323
+ async with deadline.phase("connect"):
324
+ connection = await self._connect(
325
+ request.url, route, self._prefers_quic(request.url, route)
326
+ )
327
+ if (
328
+ isinstance(connection, (Http2Connection, Http3Connection))
329
+ and connection.connect_protocol
330
+ ):
331
+ return await self._websocket_tunnel(
332
+ connection,
333
+ self._with_cookies(request, None),
334
+ deadline,
335
+ tuple(subprotocols),
336
+ )
337
+ if isinstance(connection, (Http2Connection, Http3Connection)):
338
+ await connection.close()
339
+ async with deadline.phase("connect"):
340
+ connection = await self._connect(request.url, route, http1_only=True)
341
+ if not isinstance(connection, Http1Connection):
342
+ raise ProtocolError(
343
+ "websocket needs an HTTP/1.1 or Extended CONNECT capable connection"
344
+ )
345
+ handshake, key = WebSocket.handshake(request.headers, tuple(subprotocols))
346
+ request = Request("GET", request.url, handshake)
347
+ try:
348
+ response = await connection.send(
349
+ self._with_cookies(request, None), deadline, decode=False
350
+ )
351
+ self.cookies.store(response.cookies)
352
+ if response.status != 101:
353
+ _ = await response.read()
354
+ raise ProtocolError(
355
+ f"websocket handshake answered {response.status} {response.reason}: {response.text[:200]}"
356
+ )
357
+ if response.headers.get("sec-websocket-accept") != WebSocket.accept(key):
358
+ raise ProtocolError(
359
+ "websocket handshake answered with a wrong Sec-WebSocket-Accept"
360
+ )
361
+ chosen = response.headers.get("sec-websocket-protocol")
362
+ if chosen is not None and chosen not in subprotocols:
363
+ raise ProtocolError(
364
+ f"websocket handshake chose an unoffered subprotocol {chosen!r}"
365
+ )
366
+ except BaseException:
367
+ await connection.close()
368
+ raise
369
+ transport, leftover = connection.detach()
370
+ return WebSocket(
371
+ transport,
372
+ leftover,
373
+ deadline,
374
+ subprotocol=chosen,
375
+ max_message_size=self._max_message_size,
376
+ )
377
+
378
+ async def _websocket_tunnel(
379
+ self,
380
+ connection: Http2Connection | Http3Connection,
381
+ request: Request,
382
+ deadline: Deadline,
383
+ subprotocols: tuple[str, ...],
384
+ ) -> WebSocket:
385
+ headers = request.headers.copy()
386
+ for name in ("Upgrade", "Connection", "Sec-WebSocket-Key"):
387
+ headers.remove(name)
388
+ headers.set("Sec-WebSocket-Version", "13")
389
+ if subprotocols:
390
+ headers.set("Sec-WebSocket-Protocol", ", ".join(subprotocols))
391
+ status, response_headers, transport = await connection.tunnel(
392
+ Request("CONNECT", request.url, headers), deadline
393
+ )
394
+ if status != 200:
395
+ await transport.close()
396
+ raise ProtocolError(f"websocket Extended CONNECT answered {status}")
397
+ chosen = response_headers.get("sec-websocket-protocol")
398
+ if chosen is not None and chosen not in subprotocols:
399
+ await transport.close()
400
+ raise ProtocolError(
401
+ f"websocket handshake chose an unoffered subprotocol {chosen!r}"
402
+ )
403
+ return WebSocket(
404
+ transport,
405
+ b"",
406
+ deadline,
407
+ subprotocol=chosen,
408
+ max_message_size=self._max_message_size,
409
+ )
410
+
411
+ def _context(
412
+ self, protocols: tuple[NextProtocol, ...] | None = None
413
+ ) -> TLSClientContext:
414
+ if protocols not in self._contexts:
415
+ hello = (
416
+ self._http3.tls
417
+ if protocols == (NextProtocol.H3,)
418
+ else self._adapted(protocols)
419
+ )
420
+ if hello is None:
421
+ offered = protocols or tuple(
422
+ ALPN[version] for version in self._versions if version in ALPN
423
+ )
424
+ configuration = TLSClientConfiguration(
425
+ trust_store=self._trust_store,
426
+ inner_protocols=offered,
427
+ verify_certificates=self._verify,
428
+ )
429
+ else:
430
+ configuration = TLSClientConfiguration(
431
+ trust_store=self._trust_store,
432
+ client_hello=hello,
433
+ verify_certificates=self._verify,
434
+ )
435
+ cache = (
436
+ SessionCache()
437
+ if hello is None or hello.offers(ExtensionType.pre_shared_key)
438
+ else None
439
+ )
440
+ self._contexts[protocols] = TLSClientContext(
441
+ configuration, session_cache=cache
442
+ )
443
+ return self._contexts[protocols]
444
+
445
+ def _adapted(
446
+ self, protocols: tuple[NextProtocol, ...] | None
447
+ ) -> ClientHello | None:
448
+ if self._tls is None or protocols is None:
449
+ return self._tls
450
+ # an HTTP/1.1-only connection keeps the profile but can offer only its own protocol
451
+ names = tuple(protocol.value for protocol in protocols)
452
+ return replace(
453
+ self._tls,
454
+ application_protocols=names,
455
+ application_settings=tuple(
456
+ name for name in self._tls.application_settings if name in names
457
+ ),
458
+ )
459
+
460
+ async def _connect(
461
+ self,
462
+ url: URL,
463
+ proxy: Proxy | None,
464
+ quic: bool = False,
465
+ *,
466
+ http1_only: bool = False,
467
+ ) -> Connection:
468
+ try:
469
+ if quic:
470
+ memory = self._memory.setdefault(url.address, OriginMemory())
471
+ client = await QuicClient.connect(
472
+ self._context((NextProtocol.H3,)),
473
+ url.host,
474
+ url.address,
475
+ self._http3.transport,
476
+ version=self._http3.quic_version,
477
+ proxy=proxy,
478
+ memory=memory,
479
+ )
480
+ connection = Http3Connection(client, self._http3, memory)
481
+ await connection.start()
482
+ return connection
483
+ if url.secure:
484
+ stream = await TLSStream.connect(
485
+ self._context((NextProtocol.HTTP1,) if http1_only else None),
486
+ url.address,
487
+ proxy=proxy,
488
+ )
489
+ if stream.negotiated_protocol() is NextProtocol.H2:
490
+ connection = Http2Connection(stream, self._http2)
491
+ await connection.start()
492
+ return connection
493
+ return Http1Connection(stream)
494
+ if proxy is not None and proxy.scheme in (
495
+ ProxyScheme.HTTP,
496
+ ProxyScheme.HTTPS,
497
+ ):
498
+ transport: AsyncTransport
499
+ if proxy.scheme is ProxyScheme.HTTPS:
500
+ transport = await TLSStream.connect(
501
+ self._context(), (proxy.host, proxy.port)
502
+ )
503
+ else:
504
+ transport = StreamTransport(
505
+ *await asyncio.open_connection(proxy.host, proxy.port)
506
+ )
507
+ authorization = None
508
+ if proxy.credentials is not None:
509
+ authorization = "Basic " + base64.b64encode(
510
+ f"{proxy.credentials.username}:{proxy.credentials.password}".encode()
511
+ ).decode("ascii")
512
+ return Http1Connection(
513
+ transport, absolute_form=True, proxy_authorization=authorization
514
+ )
515
+ transport, leftover = await TLSStream.tunnel(
516
+ self._context(), url.address, proxy
517
+ )
518
+ return Http1Connection(transport, leftover=leftover)
519
+ except ProxyError, ConfigurationError:
520
+ raise
521
+ except TLSError as error:
522
+ raise ConnectError(
523
+ f"TLS handshake with {url.authority} failed: {error}"
524
+ ) from error
525
+ except OSError as error:
526
+ raise ConnectError(f"cannot connect to {url.authority}: {error}") from error
527
+
528
+ async def _once(
529
+ self, request: Request, deadline: Deadline, proxy: Proxy | None
530
+ ) -> Response:
531
+ quic = self._prefers_quic(request.url, proxy)
532
+ for attempt in (1, 2):
533
+ try:
534
+ connection = await self._pool.acquire(
535
+ request.url, proxy, deadline, quic=quic
536
+ )
537
+ except ConnectError, TimeoutError:
538
+ if quic and self._versions != (HTTPVersion.HTTP_3,):
539
+ # the learned HTTP/3 route is unreachable, so this origin goes back to TCP
540
+ _ = self._alt_svc.pop(request.url.address, None)
541
+ quic = False
542
+ continue
543
+ raise
544
+ started = monotonic()
545
+ replayable = request.body is None or request.body.replayable
546
+ try:
547
+ response = await connection.send(request, deadline, decode=self._decode)
548
+ except StreamRefused:
549
+ if attempt == 1 and replayable:
550
+ continue
551
+ raise
552
+ except RemoteDisconnected:
553
+ if attempt == 1 and connection.reused and replayable:
554
+ continue
555
+ raise
556
+ response.started = started
557
+ response.elapsed = monotonic() - started
558
+ return response
559
+ raise HTTPError("unreachable")
560
+
561
+ def _prefers_quic(self, url: URL, proxy: Proxy | None) -> bool:
562
+ if (
563
+ HTTPVersion.HTTP_3 not in self._versions
564
+ or not url.secure
565
+ or (
566
+ proxy is not None
567
+ and proxy.scheme not in (ProxyScheme.SOCKS5, ProxyScheme.SOCKS5H)
568
+ )
569
+ ):
570
+ return False
571
+ if self._versions == (HTTPVersion.HTTP_3,):
572
+ return True
573
+ expiry = self._alt_svc.get(url.address)
574
+ return expiry is not None and expiry > monotonic()
575
+
576
+ def _learn_alt_svc(self, response: Response) -> None:
577
+ if "alt-svc" not in response.headers:
578
+ return
579
+ for alternative in response.headers.get_all("alt-svc"):
580
+ for entry in alternative.split(","):
581
+ protocol, _, rest = entry.strip().partition("=")
582
+ if protocol != "h3":
583
+ continue
584
+ authority, *parameters = (part.strip() for part in rest.split(";"))
585
+ if (
586
+ authority.strip('"')
587
+ != f":{response.url.port or response.url.default_port}"
588
+ ):
589
+ continue
590
+ age = next(
591
+ (
592
+ int(parameter[3:])
593
+ for parameter in parameters
594
+ if parameter.startswith("ma=")
595
+ and parameter[3:].isascii()
596
+ and parameter[3:].isdigit()
597
+ ),
598
+ 86400,
599
+ )
600
+ self._alt_svc[response.url.address] = monotonic() + age
601
+
602
+ def _with_cookies(
603
+ self, request: Request, extra: Mapping[str, str] | None
604
+ ) -> Request:
605
+ parts = [
606
+ value
607
+ for value in (
608
+ request.headers.get("cookie"),
609
+ self.cookies.header(
610
+ request.url.host, request.url.path or "/", request.url.secure
611
+ ),
612
+ )
613
+ if value
614
+ ]
615
+ if extra:
616
+ parts.append("; ".join(f"{name}={value}" for name, value in extra.items()))
617
+ if not parts:
618
+ return request
619
+ headers = request.headers.copy()
620
+ headers.set("Cookie", "; ".join(parts))
621
+ return Request(request.method, request.url, headers, request.body)
622
+
623
+ def _redirect(self, request: Request, response: Response) -> Request:
624
+ location = response.location
625
+ if location is None:
626
+ raise ProtocolError("redirect without a Location header")
627
+ if not location.fragment and request.url.fragment:
628
+ location = URL(
629
+ location.scheme,
630
+ location.host,
631
+ location.port,
632
+ location.path,
633
+ location.query,
634
+ request.url.fragment,
635
+ location.username,
636
+ location.password,
637
+ )
638
+ method, body = request.method, request.body
639
+ headers = request.headers.copy()
640
+ if response.status == 303 or (
641
+ response.status in (301, 302) and method == "POST"
642
+ ):
643
+ method, body = ("HEAD" if method == "HEAD" else "GET"), None
644
+ for name in ("Content-Type", "Content-Length", "Transfer-Encoding"):
645
+ headers.remove(name)
646
+ elif body is not None and not body.replayable:
647
+ raise HTTPError(
648
+ f"cannot repeat a streamed body for the {response.status} redirect to {location}"
649
+ )
650
+ if location.origin != request.url.origin:
651
+ headers.remove("Authorization")
652
+ headers.remove("Cookie")
653
+ return Request(method, location, headers, body)
654
+
655
+ @staticmethod
656
+ def _body(
657
+ json: object,
658
+ data: FormFields | bytes | AsyncIterable[bytes] | None,
659
+ content: bytes | str | AsyncIterable[bytes] | None,
660
+ files: Mapping[str, FileField] | None,
661
+ ) -> Body | None:
662
+ given = [
663
+ name
664
+ for name, value in (
665
+ ("json", json),
666
+ ("data", data),
667
+ ("content", content),
668
+ ("files", files),
669
+ )
670
+ if value is not None
671
+ ]
672
+ if files is not None:
673
+ if json is not None or content is not None:
674
+ raise ValueError("files= cannot be combined with json= or content=")
675
+ if isinstance(data, (bytes, AsyncIterable)):
676
+ raise ValueError("files= takes form fields in data=, not a raw body")
677
+ return Body.multipart(data, files)
678
+ if len(given) > 1:
679
+ raise ValueError(f"give one body, not {', '.join(given)}")
680
+ if json is not None:
681
+ return Body.json(json)
682
+ if content is not None:
683
+ return Body.content(content)
684
+ match data:
685
+ case None:
686
+ return None
687
+ case bytes():
688
+ return Body.content(data)
689
+ case Mapping() | Sequence():
690
+ return Body.form(data)
691
+ case _:
692
+ return Body.content(data)