httpware 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.
- httpware/__init__.py +67 -0
- httpware/_internal/__init__.py +1 -0
- httpware/_internal/chain.py +39 -0
- httpware/_internal/import_checker.py +6 -0
- httpware/client.py +620 -0
- httpware/config.py +40 -0
- httpware/decoders/__init__.py +18 -0
- httpware/decoders/msgspec.py +32 -0
- httpware/decoders/pydantic.py +29 -0
- httpware/errors.py +194 -0
- httpware/middleware/__init__.py +89 -0
- httpware/py.typed +0 -0
- httpware/request.py +55 -0
- httpware/response.py +69 -0
- httpware/transports/__init__.py +27 -0
- httpware/transports/httpx2.py +180 -0
- httpware/transports/recorded.py +84 -0
- httpware-0.1.0.dist-info/METADATA +94 -0
- httpware-0.1.0.dist-info/RECORD +20 -0
- httpware-0.1.0.dist-info/WHEEL +4 -0
httpware/client.py
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
"""AsyncClient — the v0.1.0 public surface of httpware."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import json as _json
|
|
5
|
+
import typing
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
|
|
8
|
+
from httpware._internal.chain import compose
|
|
9
|
+
from httpware.config import ClientConfig, Limits, Timeout
|
|
10
|
+
from httpware.decoders import ResponseDecoder
|
|
11
|
+
from httpware.decoders.pydantic import PydanticDecoder
|
|
12
|
+
from httpware.middleware import Middleware, Next
|
|
13
|
+
from httpware.request import Request
|
|
14
|
+
from httpware.response import Response
|
|
15
|
+
from httpware.transports import Transport
|
|
16
|
+
from httpware.transports.httpx2 import Httpx2Transport
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_UNSET: typing.Any = object()
|
|
20
|
+
|
|
21
|
+
T = typing.TypeVar("T")
|
|
22
|
+
|
|
23
|
+
# Recursive type alias for any JSON-serializable Python value. Used for the `json=` body parameter
|
|
24
|
+
# on HTTP methods so we avoid `Any` while still accepting arbitrary nested structures.
|
|
25
|
+
JsonValue: typing.TypeAlias = Mapping[str, "JsonValue"] | Sequence["JsonValue"] | str | int | float | bool | None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _normalize_timeout(value: Timeout | float | None) -> Timeout:
|
|
29
|
+
if value is None:
|
|
30
|
+
return Timeout()
|
|
31
|
+
if isinstance(value, Timeout):
|
|
32
|
+
return value
|
|
33
|
+
return Timeout(connect=value, read=value, write=value, pool=value)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _build_body(
|
|
37
|
+
json_value: JsonValue,
|
|
38
|
+
content: bytes | None,
|
|
39
|
+
) -> tuple[bytes | None, str | None]:
|
|
40
|
+
if json_value is not None and content is not None:
|
|
41
|
+
msg = "pass either `json` or `content`, not both"
|
|
42
|
+
raise TypeError(msg)
|
|
43
|
+
if json_value is not None:
|
|
44
|
+
return _json.dumps(json_value).encode("utf-8"), "application/json"
|
|
45
|
+
return content, None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AsyncClient:
|
|
49
|
+
"""Async HTTP client with typed response decoding and middleware composition."""
|
|
50
|
+
|
|
51
|
+
_config: ClientConfig
|
|
52
|
+
_transport: Transport
|
|
53
|
+
_dispatch: Next
|
|
54
|
+
_owns_transport: bool
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
base_url: str | None = None,
|
|
60
|
+
default_headers: Mapping[str, str] | None = None,
|
|
61
|
+
default_query: Mapping[str, str] | None = None,
|
|
62
|
+
timeout: Timeout | float | None = None,
|
|
63
|
+
limits: Limits | None = None,
|
|
64
|
+
transport: Transport | None = None,
|
|
65
|
+
decoder: ResponseDecoder | None = None,
|
|
66
|
+
middleware: Sequence[Middleware] | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
normalized_timeout = _normalize_timeout(timeout)
|
|
69
|
+
resolved_limits = limits or Limits()
|
|
70
|
+
resolved_transport: Transport = transport or Httpx2Transport(limits=resolved_limits, timeout=normalized_timeout)
|
|
71
|
+
resolved_decoder = decoder or PydanticDecoder()
|
|
72
|
+
resolved_middleware = tuple(middleware) if middleware is not None else ()
|
|
73
|
+
|
|
74
|
+
self._config = ClientConfig(
|
|
75
|
+
base_url=base_url,
|
|
76
|
+
default_headers=dict(default_headers or {}),
|
|
77
|
+
default_query=dict(default_query or {}),
|
|
78
|
+
timeout=normalized_timeout,
|
|
79
|
+
limits=resolved_limits,
|
|
80
|
+
decoder=resolved_decoder,
|
|
81
|
+
middleware=resolved_middleware,
|
|
82
|
+
)
|
|
83
|
+
self._transport = resolved_transport
|
|
84
|
+
self._dispatch = compose(resolved_middleware, resolved_transport)
|
|
85
|
+
self._owns_transport = True
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def from_url(cls, base_url: str, **kwargs: object) -> "AsyncClient":
|
|
89
|
+
"""Construct an AsyncClient with a base URL prefix."""
|
|
90
|
+
return cls(base_url=base_url, **kwargs) # ty: ignore[invalid-argument-type]
|
|
91
|
+
|
|
92
|
+
async def __aenter__(self) -> typing.Self:
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
async def __aexit__(
|
|
96
|
+
self,
|
|
97
|
+
exc_type: type[BaseException] | None,
|
|
98
|
+
exc: BaseException | None,
|
|
99
|
+
tb: object,
|
|
100
|
+
) -> None:
|
|
101
|
+
if self._owns_transport:
|
|
102
|
+
await self._transport.aclose()
|
|
103
|
+
|
|
104
|
+
def _resolve_url(self, path: str) -> str:
|
|
105
|
+
if path.startswith(("http://", "https://")):
|
|
106
|
+
return path
|
|
107
|
+
base = self._config.base_url
|
|
108
|
+
if base is None:
|
|
109
|
+
return path
|
|
110
|
+
return f"{base.rstrip('/')}/{path.lstrip('/')}"
|
|
111
|
+
|
|
112
|
+
def _build_request(
|
|
113
|
+
self,
|
|
114
|
+
method: str,
|
|
115
|
+
path: str,
|
|
116
|
+
*,
|
|
117
|
+
headers: Mapping[str, str] | None,
|
|
118
|
+
params: Mapping[str, str] | None,
|
|
119
|
+
cookies: Mapping[str, str] | None,
|
|
120
|
+
timeout: Timeout | float | None,
|
|
121
|
+
body: bytes | None,
|
|
122
|
+
content_type: str | None,
|
|
123
|
+
) -> Request:
|
|
124
|
+
merged_headers: dict[str, str] = {**self._config.default_headers, **(headers or {})}
|
|
125
|
+
if content_type is not None and "content-type" not in {k.lower() for k in merged_headers}:
|
|
126
|
+
merged_headers["content-type"] = content_type
|
|
127
|
+
merged_params: dict[str, str] = {**self._config.default_query, **(params or {})}
|
|
128
|
+
extensions: dict[str, typing.Any] = {}
|
|
129
|
+
if timeout is not None:
|
|
130
|
+
extensions["timeout"] = _normalize_timeout(timeout)
|
|
131
|
+
return Request(
|
|
132
|
+
method=method,
|
|
133
|
+
url=self._resolve_url(path),
|
|
134
|
+
headers=merged_headers,
|
|
135
|
+
params=merged_params,
|
|
136
|
+
cookies=dict(cookies or {}),
|
|
137
|
+
body=body,
|
|
138
|
+
extensions=extensions,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
async def _send(
|
|
142
|
+
self,
|
|
143
|
+
method: str,
|
|
144
|
+
path: str,
|
|
145
|
+
*,
|
|
146
|
+
headers: Mapping[str, str] | None,
|
|
147
|
+
params: Mapping[str, str] | None,
|
|
148
|
+
cookies: Mapping[str, str] | None,
|
|
149
|
+
timeout: Timeout | float | None,
|
|
150
|
+
body: bytes | None,
|
|
151
|
+
content_type: str | None,
|
|
152
|
+
response_model: type[T] | None,
|
|
153
|
+
) -> Response | T:
|
|
154
|
+
request = self._build_request(
|
|
155
|
+
method,
|
|
156
|
+
path,
|
|
157
|
+
headers=headers,
|
|
158
|
+
params=params,
|
|
159
|
+
cookies=cookies,
|
|
160
|
+
timeout=timeout,
|
|
161
|
+
body=body,
|
|
162
|
+
content_type=content_type,
|
|
163
|
+
)
|
|
164
|
+
response = await self._dispatch(request)
|
|
165
|
+
if response_model is None:
|
|
166
|
+
return response
|
|
167
|
+
return self._config.decoder.decode(response.content, response_model)
|
|
168
|
+
|
|
169
|
+
@typing.overload
|
|
170
|
+
async def get(
|
|
171
|
+
self,
|
|
172
|
+
path: str,
|
|
173
|
+
*,
|
|
174
|
+
headers: Mapping[str, str] | None = None,
|
|
175
|
+
params: Mapping[str, str] | None = None,
|
|
176
|
+
cookies: Mapping[str, str] | None = None,
|
|
177
|
+
timeout: Timeout | float | None = None,
|
|
178
|
+
response_model: None = None,
|
|
179
|
+
) -> Response: ...
|
|
180
|
+
|
|
181
|
+
@typing.overload
|
|
182
|
+
async def get(
|
|
183
|
+
self,
|
|
184
|
+
path: str,
|
|
185
|
+
*,
|
|
186
|
+
headers: Mapping[str, str] | None = None,
|
|
187
|
+
params: Mapping[str, str] | None = None,
|
|
188
|
+
cookies: Mapping[str, str] | None = None,
|
|
189
|
+
timeout: Timeout | float | None = None,
|
|
190
|
+
response_model: type[T],
|
|
191
|
+
) -> T: ...
|
|
192
|
+
|
|
193
|
+
async def get(
|
|
194
|
+
self,
|
|
195
|
+
path: str,
|
|
196
|
+
*,
|
|
197
|
+
headers: Mapping[str, str] | None = None,
|
|
198
|
+
params: Mapping[str, str] | None = None,
|
|
199
|
+
cookies: Mapping[str, str] | None = None,
|
|
200
|
+
timeout: Timeout | float | None = None,
|
|
201
|
+
response_model: type[T] | None = None,
|
|
202
|
+
) -> Response | T:
|
|
203
|
+
"""Send a GET request."""
|
|
204
|
+
return await self._send(
|
|
205
|
+
"GET",
|
|
206
|
+
path,
|
|
207
|
+
headers=headers,
|
|
208
|
+
params=params,
|
|
209
|
+
cookies=cookies,
|
|
210
|
+
timeout=timeout,
|
|
211
|
+
body=None,
|
|
212
|
+
content_type=None,
|
|
213
|
+
response_model=response_model,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
@typing.overload
|
|
217
|
+
async def post(
|
|
218
|
+
self,
|
|
219
|
+
path: str,
|
|
220
|
+
*,
|
|
221
|
+
headers: Mapping[str, str] | None = None,
|
|
222
|
+
params: Mapping[str, str] | None = None,
|
|
223
|
+
cookies: Mapping[str, str] | None = None,
|
|
224
|
+
timeout: Timeout | float | None = None,
|
|
225
|
+
json: JsonValue = None,
|
|
226
|
+
content: bytes | None = None,
|
|
227
|
+
response_model: None = None,
|
|
228
|
+
) -> Response: ...
|
|
229
|
+
|
|
230
|
+
@typing.overload
|
|
231
|
+
async def post(
|
|
232
|
+
self,
|
|
233
|
+
path: str,
|
|
234
|
+
*,
|
|
235
|
+
headers: Mapping[str, str] | None = None,
|
|
236
|
+
params: Mapping[str, str] | None = None,
|
|
237
|
+
cookies: Mapping[str, str] | None = None,
|
|
238
|
+
timeout: Timeout | float | None = None,
|
|
239
|
+
json: JsonValue = None,
|
|
240
|
+
content: bytes | None = None,
|
|
241
|
+
response_model: type[T],
|
|
242
|
+
) -> T: ...
|
|
243
|
+
|
|
244
|
+
async def post(
|
|
245
|
+
self,
|
|
246
|
+
path: str,
|
|
247
|
+
*,
|
|
248
|
+
headers: Mapping[str, str] | None = None,
|
|
249
|
+
params: Mapping[str, str] | None = None,
|
|
250
|
+
cookies: Mapping[str, str] | None = None,
|
|
251
|
+
timeout: Timeout | float | None = None,
|
|
252
|
+
json: JsonValue = None,
|
|
253
|
+
content: bytes | None = None,
|
|
254
|
+
response_model: type[T] | None = None,
|
|
255
|
+
) -> Response | T:
|
|
256
|
+
"""Send a POST request."""
|
|
257
|
+
body, content_type = _build_body(json, content)
|
|
258
|
+
return await self._send(
|
|
259
|
+
"POST",
|
|
260
|
+
path,
|
|
261
|
+
headers=headers,
|
|
262
|
+
params=params,
|
|
263
|
+
cookies=cookies,
|
|
264
|
+
timeout=timeout,
|
|
265
|
+
body=body,
|
|
266
|
+
content_type=content_type,
|
|
267
|
+
response_model=response_model,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
@typing.overload
|
|
271
|
+
async def put(
|
|
272
|
+
self,
|
|
273
|
+
path: str,
|
|
274
|
+
*,
|
|
275
|
+
headers: Mapping[str, str] | None = None,
|
|
276
|
+
params: Mapping[str, str] | None = None,
|
|
277
|
+
cookies: Mapping[str, str] | None = None,
|
|
278
|
+
timeout: Timeout | float | None = None,
|
|
279
|
+
json: JsonValue = None,
|
|
280
|
+
content: bytes | None = None,
|
|
281
|
+
response_model: None = None,
|
|
282
|
+
) -> Response: ...
|
|
283
|
+
|
|
284
|
+
@typing.overload
|
|
285
|
+
async def put(
|
|
286
|
+
self,
|
|
287
|
+
path: str,
|
|
288
|
+
*,
|
|
289
|
+
headers: Mapping[str, str] | None = None,
|
|
290
|
+
params: Mapping[str, str] | None = None,
|
|
291
|
+
cookies: Mapping[str, str] | None = None,
|
|
292
|
+
timeout: Timeout | float | None = None,
|
|
293
|
+
json: JsonValue = None,
|
|
294
|
+
content: bytes | None = None,
|
|
295
|
+
response_model: type[T],
|
|
296
|
+
) -> T: ...
|
|
297
|
+
|
|
298
|
+
async def put(
|
|
299
|
+
self,
|
|
300
|
+
path: str,
|
|
301
|
+
*,
|
|
302
|
+
headers: Mapping[str, str] | None = None,
|
|
303
|
+
params: Mapping[str, str] | None = None,
|
|
304
|
+
cookies: Mapping[str, str] | None = None,
|
|
305
|
+
timeout: Timeout | float | None = None,
|
|
306
|
+
json: JsonValue = None,
|
|
307
|
+
content: bytes | None = None,
|
|
308
|
+
response_model: type[T] | None = None,
|
|
309
|
+
) -> Response | T:
|
|
310
|
+
"""Send a PUT request."""
|
|
311
|
+
body, content_type = _build_body(json, content)
|
|
312
|
+
return await self._send(
|
|
313
|
+
"PUT",
|
|
314
|
+
path,
|
|
315
|
+
headers=headers,
|
|
316
|
+
params=params,
|
|
317
|
+
cookies=cookies,
|
|
318
|
+
timeout=timeout,
|
|
319
|
+
body=body,
|
|
320
|
+
content_type=content_type,
|
|
321
|
+
response_model=response_model,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
@typing.overload
|
|
325
|
+
async def patch(
|
|
326
|
+
self,
|
|
327
|
+
path: str,
|
|
328
|
+
*,
|
|
329
|
+
headers: Mapping[str, str] | None = None,
|
|
330
|
+
params: Mapping[str, str] | None = None,
|
|
331
|
+
cookies: Mapping[str, str] | None = None,
|
|
332
|
+
timeout: Timeout | float | None = None,
|
|
333
|
+
json: JsonValue = None,
|
|
334
|
+
content: bytes | None = None,
|
|
335
|
+
response_model: None = None,
|
|
336
|
+
) -> Response: ...
|
|
337
|
+
|
|
338
|
+
@typing.overload
|
|
339
|
+
async def patch(
|
|
340
|
+
self,
|
|
341
|
+
path: str,
|
|
342
|
+
*,
|
|
343
|
+
headers: Mapping[str, str] | None = None,
|
|
344
|
+
params: Mapping[str, str] | None = None,
|
|
345
|
+
cookies: Mapping[str, str] | None = None,
|
|
346
|
+
timeout: Timeout | float | None = None,
|
|
347
|
+
json: JsonValue = None,
|
|
348
|
+
content: bytes | None = None,
|
|
349
|
+
response_model: type[T],
|
|
350
|
+
) -> T: ...
|
|
351
|
+
|
|
352
|
+
async def patch(
|
|
353
|
+
self,
|
|
354
|
+
path: str,
|
|
355
|
+
*,
|
|
356
|
+
headers: Mapping[str, str] | None = None,
|
|
357
|
+
params: Mapping[str, str] | None = None,
|
|
358
|
+
cookies: Mapping[str, str] | None = None,
|
|
359
|
+
timeout: Timeout | float | None = None,
|
|
360
|
+
json: JsonValue = None,
|
|
361
|
+
content: bytes | None = None,
|
|
362
|
+
response_model: type[T] | None = None,
|
|
363
|
+
) -> Response | T:
|
|
364
|
+
"""Send a PATCH request."""
|
|
365
|
+
body, content_type = _build_body(json, content)
|
|
366
|
+
return await self._send(
|
|
367
|
+
"PATCH",
|
|
368
|
+
path,
|
|
369
|
+
headers=headers,
|
|
370
|
+
params=params,
|
|
371
|
+
cookies=cookies,
|
|
372
|
+
timeout=timeout,
|
|
373
|
+
body=body,
|
|
374
|
+
content_type=content_type,
|
|
375
|
+
response_model=response_model,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
@typing.overload
|
|
379
|
+
async def delete(
|
|
380
|
+
self,
|
|
381
|
+
path: str,
|
|
382
|
+
*,
|
|
383
|
+
headers: Mapping[str, str] | None = None,
|
|
384
|
+
params: Mapping[str, str] | None = None,
|
|
385
|
+
cookies: Mapping[str, str] | None = None,
|
|
386
|
+
timeout: Timeout | float | None = None,
|
|
387
|
+
response_model: None = None,
|
|
388
|
+
) -> Response: ...
|
|
389
|
+
|
|
390
|
+
@typing.overload
|
|
391
|
+
async def delete(
|
|
392
|
+
self,
|
|
393
|
+
path: str,
|
|
394
|
+
*,
|
|
395
|
+
headers: Mapping[str, str] | None = None,
|
|
396
|
+
params: Mapping[str, str] | None = None,
|
|
397
|
+
cookies: Mapping[str, str] | None = None,
|
|
398
|
+
timeout: Timeout | float | None = None,
|
|
399
|
+
response_model: type[T],
|
|
400
|
+
) -> T: ...
|
|
401
|
+
|
|
402
|
+
async def delete(
|
|
403
|
+
self,
|
|
404
|
+
path: str,
|
|
405
|
+
*,
|
|
406
|
+
headers: Mapping[str, str] | None = None,
|
|
407
|
+
params: Mapping[str, str] | None = None,
|
|
408
|
+
cookies: Mapping[str, str] | None = None,
|
|
409
|
+
timeout: Timeout | float | None = None,
|
|
410
|
+
response_model: type[T] | None = None,
|
|
411
|
+
) -> Response | T:
|
|
412
|
+
"""Send a DELETE request."""
|
|
413
|
+
return await self._send(
|
|
414
|
+
"DELETE",
|
|
415
|
+
path,
|
|
416
|
+
headers=headers,
|
|
417
|
+
params=params,
|
|
418
|
+
cookies=cookies,
|
|
419
|
+
timeout=timeout,
|
|
420
|
+
body=None,
|
|
421
|
+
content_type=None,
|
|
422
|
+
response_model=response_model,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
@typing.overload
|
|
426
|
+
async def head(
|
|
427
|
+
self,
|
|
428
|
+
path: str,
|
|
429
|
+
*,
|
|
430
|
+
headers: Mapping[str, str] | None = None,
|
|
431
|
+
params: Mapping[str, str] | None = None,
|
|
432
|
+
cookies: Mapping[str, str] | None = None,
|
|
433
|
+
timeout: Timeout | float | None = None,
|
|
434
|
+
response_model: None = None,
|
|
435
|
+
) -> Response: ...
|
|
436
|
+
|
|
437
|
+
@typing.overload
|
|
438
|
+
async def head(
|
|
439
|
+
self,
|
|
440
|
+
path: str,
|
|
441
|
+
*,
|
|
442
|
+
headers: Mapping[str, str] | None = None,
|
|
443
|
+
params: Mapping[str, str] | None = None,
|
|
444
|
+
cookies: Mapping[str, str] | None = None,
|
|
445
|
+
timeout: Timeout | float | None = None,
|
|
446
|
+
response_model: type[T],
|
|
447
|
+
) -> T: ...
|
|
448
|
+
|
|
449
|
+
async def head(
|
|
450
|
+
self,
|
|
451
|
+
path: str,
|
|
452
|
+
*,
|
|
453
|
+
headers: Mapping[str, str] | None = None,
|
|
454
|
+
params: Mapping[str, str] | None = None,
|
|
455
|
+
cookies: Mapping[str, str] | None = None,
|
|
456
|
+
timeout: Timeout | float | None = None,
|
|
457
|
+
response_model: type[T] | None = None,
|
|
458
|
+
) -> Response | T:
|
|
459
|
+
"""Send a HEAD request."""
|
|
460
|
+
return await self._send(
|
|
461
|
+
"HEAD",
|
|
462
|
+
path,
|
|
463
|
+
headers=headers,
|
|
464
|
+
params=params,
|
|
465
|
+
cookies=cookies,
|
|
466
|
+
timeout=timeout,
|
|
467
|
+
body=None,
|
|
468
|
+
content_type=None,
|
|
469
|
+
response_model=response_model,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
@typing.overload
|
|
473
|
+
async def options(
|
|
474
|
+
self,
|
|
475
|
+
path: str,
|
|
476
|
+
*,
|
|
477
|
+
headers: Mapping[str, str] | None = None,
|
|
478
|
+
params: Mapping[str, str] | None = None,
|
|
479
|
+
cookies: Mapping[str, str] | None = None,
|
|
480
|
+
timeout: Timeout | float | None = None,
|
|
481
|
+
response_model: None = None,
|
|
482
|
+
) -> Response: ...
|
|
483
|
+
|
|
484
|
+
@typing.overload
|
|
485
|
+
async def options(
|
|
486
|
+
self,
|
|
487
|
+
path: str,
|
|
488
|
+
*,
|
|
489
|
+
headers: Mapping[str, str] | None = None,
|
|
490
|
+
params: Mapping[str, str] | None = None,
|
|
491
|
+
cookies: Mapping[str, str] | None = None,
|
|
492
|
+
timeout: Timeout | float | None = None,
|
|
493
|
+
response_model: type[T],
|
|
494
|
+
) -> T: ...
|
|
495
|
+
|
|
496
|
+
async def options(
|
|
497
|
+
self,
|
|
498
|
+
path: str,
|
|
499
|
+
*,
|
|
500
|
+
headers: Mapping[str, str] | None = None,
|
|
501
|
+
params: Mapping[str, str] | None = None,
|
|
502
|
+
cookies: Mapping[str, str] | None = None,
|
|
503
|
+
timeout: Timeout | float | None = None,
|
|
504
|
+
response_model: type[T] | None = None,
|
|
505
|
+
) -> Response | T:
|
|
506
|
+
"""Send an OPTIONS request."""
|
|
507
|
+
return await self._send(
|
|
508
|
+
"OPTIONS",
|
|
509
|
+
path,
|
|
510
|
+
headers=headers,
|
|
511
|
+
params=params,
|
|
512
|
+
cookies=cookies,
|
|
513
|
+
timeout=timeout,
|
|
514
|
+
body=None,
|
|
515
|
+
content_type=None,
|
|
516
|
+
response_model=response_model,
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
@typing.overload
|
|
520
|
+
async def request(
|
|
521
|
+
self,
|
|
522
|
+
method: str,
|
|
523
|
+
path: str,
|
|
524
|
+
*,
|
|
525
|
+
headers: Mapping[str, str] | None = None,
|
|
526
|
+
params: Mapping[str, str] | None = None,
|
|
527
|
+
cookies: Mapping[str, str] | None = None,
|
|
528
|
+
timeout: Timeout | float | None = None,
|
|
529
|
+
json: JsonValue = None,
|
|
530
|
+
content: bytes | None = None,
|
|
531
|
+
response_model: None = None,
|
|
532
|
+
) -> Response: ...
|
|
533
|
+
|
|
534
|
+
@typing.overload
|
|
535
|
+
async def request(
|
|
536
|
+
self,
|
|
537
|
+
method: str,
|
|
538
|
+
path: str,
|
|
539
|
+
*,
|
|
540
|
+
headers: Mapping[str, str] | None = None,
|
|
541
|
+
params: Mapping[str, str] | None = None,
|
|
542
|
+
cookies: Mapping[str, str] | None = None,
|
|
543
|
+
timeout: Timeout | float | None = None,
|
|
544
|
+
json: JsonValue = None,
|
|
545
|
+
content: bytes | None = None,
|
|
546
|
+
response_model: type[T],
|
|
547
|
+
) -> T: ...
|
|
548
|
+
|
|
549
|
+
async def request(
|
|
550
|
+
self,
|
|
551
|
+
method: str,
|
|
552
|
+
path: str,
|
|
553
|
+
*,
|
|
554
|
+
headers: Mapping[str, str] | None = None,
|
|
555
|
+
params: Mapping[str, str] | None = None,
|
|
556
|
+
cookies: Mapping[str, str] | None = None,
|
|
557
|
+
timeout: Timeout | float | None = None,
|
|
558
|
+
json: JsonValue = None,
|
|
559
|
+
content: bytes | None = None,
|
|
560
|
+
response_model: type[T] | None = None,
|
|
561
|
+
) -> Response | T:
|
|
562
|
+
"""Send a request with an arbitrary HTTP method."""
|
|
563
|
+
body, content_type = _build_body(json, content)
|
|
564
|
+
return await self._send(
|
|
565
|
+
method,
|
|
566
|
+
path,
|
|
567
|
+
headers=headers,
|
|
568
|
+
params=params,
|
|
569
|
+
cookies=cookies,
|
|
570
|
+
timeout=timeout,
|
|
571
|
+
body=body,
|
|
572
|
+
content_type=content_type,
|
|
573
|
+
response_model=response_model,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
def with_options(
|
|
577
|
+
self,
|
|
578
|
+
*,
|
|
579
|
+
base_url: str | None = _UNSET,
|
|
580
|
+
default_headers: Mapping[str, str] | None = _UNSET,
|
|
581
|
+
default_query: Mapping[str, str] | None = _UNSET,
|
|
582
|
+
timeout: Timeout | float | None = _UNSET,
|
|
583
|
+
decoder: ResponseDecoder | None = _UNSET,
|
|
584
|
+
middleware: Sequence[Middleware] | None = _UNSET,
|
|
585
|
+
) -> "AsyncClient":
|
|
586
|
+
"""Return a new AsyncClient sharing the same transport with overridden config.
|
|
587
|
+
|
|
588
|
+
The returned client is a "view": it does NOT own the transport lifecycle.
|
|
589
|
+
Closing it via `async with` is a no-op. The original client should be the
|
|
590
|
+
one inside the outermost `async with` block.
|
|
591
|
+
|
|
592
|
+
`limits` and `transport` are NOT overridable here — both bind to the
|
|
593
|
+
transport, which is shared. Construct a fresh AsyncClient for those.
|
|
594
|
+
"""
|
|
595
|
+
changes: dict[str, typing.Any] = {}
|
|
596
|
+
if base_url is not _UNSET:
|
|
597
|
+
changes["base_url"] = base_url
|
|
598
|
+
if default_headers is not _UNSET:
|
|
599
|
+
changes["default_headers"] = dict(default_headers or {})
|
|
600
|
+
if default_query is not _UNSET:
|
|
601
|
+
changes["default_query"] = dict(default_query or {})
|
|
602
|
+
if timeout is not _UNSET:
|
|
603
|
+
changes["timeout"] = _normalize_timeout(timeout)
|
|
604
|
+
if decoder is not _UNSET:
|
|
605
|
+
changes["decoder"] = decoder or PydanticDecoder()
|
|
606
|
+
if middleware is not _UNSET:
|
|
607
|
+
changes["middleware"] = tuple(middleware) if middleware is not None else ()
|
|
608
|
+
|
|
609
|
+
new_config = dataclasses.replace(self._config, **changes)
|
|
610
|
+
return AsyncClient._from_view(new_config, self._transport)
|
|
611
|
+
|
|
612
|
+
@classmethod
|
|
613
|
+
def _from_view(cls, config: ClientConfig, transport: Transport) -> "AsyncClient":
|
|
614
|
+
"""Construct a view sharing an existing transport. Bypasses __init__."""
|
|
615
|
+
client = cls.__new__(cls)
|
|
616
|
+
client._config = config # noqa: SLF001
|
|
617
|
+
client._transport = transport # noqa: SLF001
|
|
618
|
+
client._dispatch = compose(config.middleware, transport) # noqa: SLF001
|
|
619
|
+
client._owns_transport = False # noqa: SLF001
|
|
620
|
+
return client
|
httpware/config.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Immutable configuration value types: Limits, Timeout, ClientConfig."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from httpware.decoders import ResponseDecoder
|
|
7
|
+
from httpware.decoders.pydantic import PydanticDecoder
|
|
8
|
+
from httpware.middleware import Middleware
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class Timeout:
|
|
13
|
+
"""Per-phase request timeout configuration (seconds)."""
|
|
14
|
+
|
|
15
|
+
connect: float = 5.0
|
|
16
|
+
read: float = 30.0
|
|
17
|
+
write: float = 30.0
|
|
18
|
+
pool: float = 5.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class Limits:
|
|
23
|
+
"""Connection-pool limits."""
|
|
24
|
+
|
|
25
|
+
max_connections: int = 100
|
|
26
|
+
max_keepalive_connections: int = 20
|
|
27
|
+
keepalive_expiry: float = 5.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class ClientConfig:
|
|
32
|
+
"""Immutable client configuration bag."""
|
|
33
|
+
|
|
34
|
+
base_url: str | None = None
|
|
35
|
+
default_headers: Mapping[str, str] = field(default_factory=dict)
|
|
36
|
+
default_query: Mapping[str, str] = field(default_factory=dict)
|
|
37
|
+
timeout: Timeout = field(default_factory=Timeout)
|
|
38
|
+
limits: Limits = field(default_factory=Limits)
|
|
39
|
+
decoder: ResponseDecoder = field(default_factory=PydanticDecoder)
|
|
40
|
+
middleware: tuple[Middleware, ...] = ()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""ResponseDecoder protocol — the AsyncClient ↔ ResponseDecoder seam (Seam 3)."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol, TypeVar, runtime_checkable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
T = TypeVar("T")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@runtime_checkable
|
|
10
|
+
class ResponseDecoder(Protocol):
|
|
11
|
+
"""Structural protocol every response-body decoder satisfies."""
|
|
12
|
+
|
|
13
|
+
def decode(self, content: bytes, model: type[T]) -> T:
|
|
14
|
+
"""Decode `content` (raw response bytes) into an instance of `model`."""
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__ = ["ResponseDecoder"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""MsgspecDecoder — opt-in ResponseDecoder backed by msgspec.json.decode."""
|
|
2
|
+
|
|
3
|
+
from typing import TypeVar
|
|
4
|
+
|
|
5
|
+
from httpware._internal import import_checker
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
if import_checker.is_msgspec_installed:
|
|
9
|
+
import msgspec
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
MISSING_DEPENDENCY_MESSAGE = "MsgspecDecoder requires the 'msgspec' extra. Install with: pip install httpware[msgspec]"
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MsgspecDecoder:
|
|
18
|
+
"""Decode raw response bytes via `msgspec.json.decode(content, type=model)`.
|
|
19
|
+
|
|
20
|
+
Requires the `msgspec` extra: `pip install httpware[msgspec]`. Importing
|
|
21
|
+
this module without the extra works (the `msgspec` import is guarded by a
|
|
22
|
+
`find_spec` check), but instantiating the decoder raises `ImportError` with
|
|
23
|
+
the install hint.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
if not import_checker.is_msgspec_installed:
|
|
28
|
+
raise ImportError(MISSING_DEPENDENCY_MESSAGE)
|
|
29
|
+
|
|
30
|
+
def decode(self, content: bytes, model: type[T]) -> T:
|
|
31
|
+
"""Validate `content` as JSON against `model` in a single parse pass."""
|
|
32
|
+
return msgspec.json.decode(content, type=model)
|