cloudru-qa-http-client 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.
- cloudru_qa_http_client-1.0.0.dist-info/METADATA +615 -0
- cloudru_qa_http_client-1.0.0.dist-info/RECORD +18 -0
- cloudru_qa_http_client-1.0.0.dist-info/WHEEL +4 -0
- cloudru_qa_http_client-1.0.0.dist-info/licenses/LICENSE +202 -0
- http_client/.agents/skills/http-client-usage/SKILL.md +227 -0
- http_client/.agents/skills/http-client-usage/references/options.md +219 -0
- http_client/.agents/skills/http-client-usage/references/pitfalls.md +53 -0
- http_client/__init__.py +16 -0
- http_client/client.py +603 -0
- http_client/config.py +82 -0
- http_client/event_hooks.py +159 -0
- http_client/options.py +226 -0
- http_client/py.typed +1 -0
- http_client/types.py +141 -0
- http_client/utils/__init__.py +0 -0
- http_client/utils/logutils.py +275 -0
- http_client/utils/sanitizer.py +59 -0
- http_client/utils/traceparent.py +12 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Pitfalls
|
|
2
|
+
|
|
3
|
+
Non-obvious behaviour that costs time when you hit it unprepared.
|
|
4
|
+
|
|
5
|
+
## Options run before the httpx client exists
|
|
6
|
+
|
|
7
|
+
Order inside `__init__`: `BaseHttpClient.__init__` creates typed internal configuration and runs
|
|
8
|
+
the options; then `HttpClient.__init__` creates the httpx transport from that configuration.
|
|
9
|
+
|
|
10
|
+
Two consequences:
|
|
11
|
+
|
|
12
|
+
1. An option that touches `client.client` raises `AttributeError` — there is no transport yet.
|
|
13
|
+
2. Setting `client.timeout`, `auth`, `verify` or `url_suffix` after construction raises `RuntimeError`.
|
|
14
|
+
Set them through constructor options. For transport-level changes supported by httpx, use its
|
|
15
|
+
public API directly, for example `client.client.timeout`, or rebuild the client.
|
|
16
|
+
|
|
17
|
+
## Decorators are frozen once
|
|
18
|
+
|
|
19
|
+
Decorators are composed in `__init__`. Register them through constructor options with
|
|
20
|
+
`add_send_decorator` / `add_stream_decorator` or the built-in option factories. Late registration
|
|
21
|
+
raises `RuntimeError`.
|
|
22
|
+
|
|
23
|
+
Composition order is a plain loop:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
for decorator in decorators:
|
|
27
|
+
func = decorator(func)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
so the last decorator added ends up outermost and runs first.
|
|
31
|
+
|
|
32
|
+
## `endpoint()` only accepts relative paths
|
|
33
|
+
|
|
34
|
+
An absolute URL or a `..` segment raises `ValueError` instead of silently escaping the client's base URL. Build cross-host calls with a separate client, not by passing a full URL to `endpoint()`.
|
|
35
|
+
|
|
36
|
+
## A decorator that sends twice runs the hooks twice
|
|
37
|
+
|
|
38
|
+
Event hooks fire inside `httpx.Client.send`, so a decorator that calls `func()` twice runs every
|
|
39
|
+
request hook twice. The built-in ID hooks preserve existing `X-Request-ID` and `Traceparent` values;
|
|
40
|
+
reusing one request keeps its identifiers stable across retries. A newly built request gets new
|
|
41
|
+
identifiers unless the caller provided them. Other hooks, including logging, still run per attempt.
|
|
42
|
+
|
|
43
|
+
## Streaming: the logged response body is always a placeholder
|
|
44
|
+
|
|
45
|
+
`stream_request` marks the request with `request.extensions["is_streaming"] = True`. The logging response hook sees the marker and, while the body is unread, writes `Body: <streamed content>`. This is by design — reading the body inside the hook would defeat the purpose of streaming.
|
|
46
|
+
|
|
47
|
+
## `HttpClient` and `AsyncHttpClient` are declared abstract yet instantiable
|
|
48
|
+
|
|
49
|
+
Both carry `metaclass=ABCMeta`, but the only abstract member, `BaseHttpClient.client`, is implemented in each. So `HttpClient("https://...")` works — the library's own tests rely on it. Subclassing is still the preferred approach, but direct construction is not a mistake.
|
|
50
|
+
|
|
51
|
+
## False alarms when reading the code
|
|
52
|
+
|
|
53
|
+
- `logutils.create_request_log` writes `r.extensions["trace_id"]`, meaning the logging hook mutates the request. Keep that in mind when comparing `extensions` in tests.
|
http_client/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from http_client import options, utils
|
|
2
|
+
|
|
3
|
+
from .client import AsyncHttpClient, BaseHttpClient, ClientOption, HttpClient
|
|
4
|
+
from .config import configure_logging
|
|
5
|
+
from .types import EventHook
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AsyncHttpClient",
|
|
9
|
+
"BaseHttpClient",
|
|
10
|
+
"ClientOption",
|
|
11
|
+
"EventHook",
|
|
12
|
+
"HttpClient",
|
|
13
|
+
"configure_logging",
|
|
14
|
+
"options",
|
|
15
|
+
"utils",
|
|
16
|
+
]
|
http_client/client.py
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABCMeta, abstractmethod
|
|
4
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping
|
|
5
|
+
from contextlib import AbstractAsyncContextManager, AbstractContextManager, asynccontextmanager, contextmanager
|
|
6
|
+
from typing import TYPE_CHECKING, Self, Unpack, cast
|
|
7
|
+
from urllib.parse import unquote, urljoin, urlsplit
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
import ssl
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
|
|
13
|
+
from httpx._types import (
|
|
14
|
+
AuthTypes,
|
|
15
|
+
TimeoutTypes,
|
|
16
|
+
URLTypes,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from .types import (
|
|
20
|
+
AsyncSendDecorator,
|
|
21
|
+
AsyncSendMethod,
|
|
22
|
+
AsyncStreamRequestDecorator,
|
|
23
|
+
AsyncStreamRequestMethod,
|
|
24
|
+
RequestHook,
|
|
25
|
+
ResponseHook,
|
|
26
|
+
SendDecorator,
|
|
27
|
+
StreamRequestDecorator,
|
|
28
|
+
SyncSendDecorator,
|
|
29
|
+
SyncSendMethod,
|
|
30
|
+
SyncStreamRequestDecorator,
|
|
31
|
+
SyncStreamRequestMethod,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
import httpx
|
|
35
|
+
from httpx._client import USE_CLIENT_DEFAULT
|
|
36
|
+
from httpx._config import DEFAULT_MAX_REDIRECTS
|
|
37
|
+
|
|
38
|
+
from .config import HttpClientConfig
|
|
39
|
+
from .types import QueryOptions, RequestOptions, SendOptions, StreamOptions
|
|
40
|
+
|
|
41
|
+
ClientOption = Callable[["BaseHttpClient"], None]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _reject_unknown_options(options: Mapping[str, object], allowed: frozenset[str]) -> None:
|
|
45
|
+
unknown = set(options).difference(allowed)
|
|
46
|
+
if unknown:
|
|
47
|
+
message = f"Unexpected request options: {', '.join(sorted(unknown))}"
|
|
48
|
+
raise TypeError(message)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _mark_streaming(request: httpx.Request) -> None:
|
|
52
|
+
extensions = cast("dict[str, object]", request.extensions)
|
|
53
|
+
extensions["is_streaming"] = True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _ensure_async_hook[T](hook: Callable[[T], Awaitable[None] | None]) -> Callable[[T], Awaitable[None]]:
|
|
57
|
+
"""Wrap a synchronous event hook in an asynchronous wrapper if needed."""
|
|
58
|
+
|
|
59
|
+
async def async_hook(message: T) -> None:
|
|
60
|
+
result = hook(message)
|
|
61
|
+
if result is not None:
|
|
62
|
+
await result
|
|
63
|
+
|
|
64
|
+
return async_hook
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _resolve_auth(auth: AuthTypes | None, client_auth: AuthTypes | None) -> AuthTypes | None:
|
|
68
|
+
"""Select request authentication.
|
|
69
|
+
|
|
70
|
+
Compare with None rather than checking truthiness: a valid Auth may have __bool__ == False.
|
|
71
|
+
"""
|
|
72
|
+
return auth if auth is not None else client_auth
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class BaseHttpClient(metaclass=ABCMeta):
|
|
76
|
+
"""Base HTTP client class."""
|
|
77
|
+
|
|
78
|
+
DEFAULT_URL_SUFFIX = ""
|
|
79
|
+
DEFAULT_TIMEOUT = httpx.Timeout(60)
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
base_url: URLTypes,
|
|
84
|
+
*options: ClientOption,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Apply options to the settings before creating the transport.
|
|
87
|
+
|
|
88
|
+
:param base_url: HTTP service address
|
|
89
|
+
:param options: Built-in and custom client options
|
|
90
|
+
"""
|
|
91
|
+
self._config = HttpClientConfig(
|
|
92
|
+
base_url=base_url, timeout=self.DEFAULT_TIMEOUT, url_suffix=self.DEFAULT_URL_SUFFIX
|
|
93
|
+
)
|
|
94
|
+
self._configuration_open = True
|
|
95
|
+
|
|
96
|
+
for opt in options:
|
|
97
|
+
opt(self)
|
|
98
|
+
self._configuration_open = False
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def verify(self) -> ssl.SSLContext | str | bool:
|
|
102
|
+
"""Return the transport certificate verification setting."""
|
|
103
|
+
return self._config.verify
|
|
104
|
+
|
|
105
|
+
@verify.setter
|
|
106
|
+
def verify(self, value: ssl.SSLContext | str | bool) -> None:
|
|
107
|
+
self._assert_configuring()
|
|
108
|
+
self._config.verify = value
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def auth(self) -> AuthTypes | None:
|
|
112
|
+
"""Return the transport authentication setting."""
|
|
113
|
+
return self._config.auth
|
|
114
|
+
|
|
115
|
+
@auth.setter
|
|
116
|
+
def auth(self, value: AuthTypes | None) -> None:
|
|
117
|
+
self._assert_configuring()
|
|
118
|
+
self._config.auth = value
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def timeout(self) -> TimeoutTypes:
|
|
122
|
+
"""Return the transport timeout."""
|
|
123
|
+
return self._config.timeout
|
|
124
|
+
|
|
125
|
+
@timeout.setter
|
|
126
|
+
def timeout(self, value: TimeoutTypes) -> None:
|
|
127
|
+
self._assert_configuring()
|
|
128
|
+
self._config.timeout = value
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def url_suffix(self) -> str:
|
|
132
|
+
"""Return the prefix for relative request paths."""
|
|
133
|
+
return self._config.url_suffix
|
|
134
|
+
|
|
135
|
+
@url_suffix.setter
|
|
136
|
+
def url_suffix(self, value: str) -> None:
|
|
137
|
+
self._assert_configuring()
|
|
138
|
+
self._config.url_suffix = value
|
|
139
|
+
|
|
140
|
+
def add_request_hook(self, hook: RequestHook, *, prepend: bool = False) -> None:
|
|
141
|
+
"""Register a request hook while applying options.
|
|
142
|
+
|
|
143
|
+
:param hook: HTTP request handler
|
|
144
|
+
:param prepend: Run the hook before previously registered handlers
|
|
145
|
+
:raises RuntimeError: Client initialization has already finished
|
|
146
|
+
"""
|
|
147
|
+
self._assert_configuring()
|
|
148
|
+
if prepend:
|
|
149
|
+
self._config.request_hooks.insert(0, hook)
|
|
150
|
+
else:
|
|
151
|
+
self._config.request_hooks.append(hook)
|
|
152
|
+
|
|
153
|
+
def add_response_hook(self, hook: ResponseHook) -> None:
|
|
154
|
+
"""Register a response hook while applying options.
|
|
155
|
+
|
|
156
|
+
:param hook: HTTP response handler
|
|
157
|
+
:raises RuntimeError: Client initialization has already finished
|
|
158
|
+
"""
|
|
159
|
+
self._assert_configuring()
|
|
160
|
+
self._config.response_hooks.append(hook)
|
|
161
|
+
|
|
162
|
+
def add_send_decorator(self, decorator: SendDecorator) -> None:
|
|
163
|
+
"""Register a request send decorator while applying options.
|
|
164
|
+
|
|
165
|
+
:param decorator: Synchronous or asynchronous send decorator
|
|
166
|
+
:raises RuntimeError: Client initialization has already finished
|
|
167
|
+
"""
|
|
168
|
+
self._assert_configuring()
|
|
169
|
+
self._config.send_decorators.append(decorator)
|
|
170
|
+
|
|
171
|
+
def add_stream_decorator(self, decorator: StreamRequestDecorator) -> None:
|
|
172
|
+
"""Register a streaming request decorator while applying options.
|
|
173
|
+
|
|
174
|
+
:param decorator: Synchronous or asynchronous context manager decorator
|
|
175
|
+
:raises RuntimeError: Client initialization has already finished
|
|
176
|
+
"""
|
|
177
|
+
self._assert_configuring()
|
|
178
|
+
self._config.stream_decorators.append(decorator)
|
|
179
|
+
|
|
180
|
+
def enable_redirects(self, max_redirects: int = DEFAULT_MAX_REDIRECTS) -> None:
|
|
181
|
+
"""Enable redirects while applying options.
|
|
182
|
+
|
|
183
|
+
:param max_redirects: Maximum number of redirects
|
|
184
|
+
:raises RuntimeError: Client initialization has already finished
|
|
185
|
+
"""
|
|
186
|
+
self._assert_configuring()
|
|
187
|
+
self._config.follow_redirects = True
|
|
188
|
+
self._config.max_redirects = max_redirects
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def base_url(self) -> httpx.URL:
|
|
192
|
+
"""Return the transport base URL."""
|
|
193
|
+
return self.client.base_url
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
@abstractmethod
|
|
197
|
+
def client(self) -> httpx._client.BaseClient:
|
|
198
|
+
"""Return the synchronous or asynchronous HTTP transport."""
|
|
199
|
+
...
|
|
200
|
+
|
|
201
|
+
def endpoint(self, path: str) -> str:
|
|
202
|
+
"""Build a URL using the suffix.
|
|
203
|
+
|
|
204
|
+
:param path: Relative endpoint path
|
|
205
|
+
:raises ValueError: If the path is absolute or traverses above the suffix
|
|
206
|
+
:returns: Path with the suffix applied
|
|
207
|
+
|
|
208
|
+
>>> assert "api/v1/endpoint" == self.endpoint("api/v1/endpoint")
|
|
209
|
+
>>> self.url_suffix = "/u-api/container-registry/v1"
|
|
210
|
+
>>> assert "/u-api/container-registry/v1/api/v1/endpoint" == self.endpoint("api/v1/endpoint")
|
|
211
|
+
"""
|
|
212
|
+
parsed = urlsplit(path)
|
|
213
|
+
if parsed.scheme or parsed.netloc:
|
|
214
|
+
message = f"endpoint() принимает только относительный путь, получен абсолютный URL: {path!r}"
|
|
215
|
+
raise ValueError(message)
|
|
216
|
+
if ".." in unquote(parsed.path).split("/"):
|
|
217
|
+
message = f"endpoint() не допускает выход вверх по пути: {path!r}"
|
|
218
|
+
raise ValueError(message)
|
|
219
|
+
|
|
220
|
+
suffix = self.url_suffix
|
|
221
|
+
if suffix and not suffix.endswith("/"):
|
|
222
|
+
suffix += "/"
|
|
223
|
+
|
|
224
|
+
return urljoin(suffix, path.removeprefix("/"))
|
|
225
|
+
|
|
226
|
+
def _prepare_request(
|
|
227
|
+
self, method: str, url: URLTypes, options: RequestOptions
|
|
228
|
+
) -> tuple[httpx.Request, StreamOptions]:
|
|
229
|
+
_reject_unknown_options(options, RequestOptions.__optional_keys__)
|
|
230
|
+
request = self.client.build_request(
|
|
231
|
+
method,
|
|
232
|
+
url,
|
|
233
|
+
content=options.get("content"),
|
|
234
|
+
data=options.get("data"),
|
|
235
|
+
files=options.get("files"),
|
|
236
|
+
json=options.get("json"),
|
|
237
|
+
params=options.get("params"),
|
|
238
|
+
headers=options.get("headers"),
|
|
239
|
+
cookies=options.get("cookies"),
|
|
240
|
+
timeout=options.get("timeout", USE_CLIENT_DEFAULT),
|
|
241
|
+
extensions=options.get("extensions"),
|
|
242
|
+
)
|
|
243
|
+
send_options: StreamOptions = {
|
|
244
|
+
"auth": options.get("auth"),
|
|
245
|
+
"follow_redirects": options.get("follow_redirects"),
|
|
246
|
+
"anonymous": options.get("anonymous", False),
|
|
247
|
+
}
|
|
248
|
+
return request, send_options
|
|
249
|
+
|
|
250
|
+
def _assert_configuring(self) -> None:
|
|
251
|
+
if not self._configuration_open:
|
|
252
|
+
message = "Register client configuration through constructor options"
|
|
253
|
+
raise RuntimeError(message)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class HttpClient(BaseHttpClient, metaclass=ABCMeta):
|
|
257
|
+
"""HTTP client class."""
|
|
258
|
+
|
|
259
|
+
def __init__(
|
|
260
|
+
self,
|
|
261
|
+
base_url: URLTypes,
|
|
262
|
+
*options: ClientOption,
|
|
263
|
+
) -> None:
|
|
264
|
+
"""Create a synchronous HTTP client.
|
|
265
|
+
|
|
266
|
+
:param base_url: HTTP service address
|
|
267
|
+
:param options: Built-in and custom options
|
|
268
|
+
"""
|
|
269
|
+
super().__init__(base_url, *options)
|
|
270
|
+
self._client = httpx.Client(
|
|
271
|
+
base_url=self._config.base_url,
|
|
272
|
+
auth=self.auth,
|
|
273
|
+
timeout=self.timeout,
|
|
274
|
+
verify=self.verify,
|
|
275
|
+
follow_redirects=self._config.follow_redirects,
|
|
276
|
+
max_redirects=self._config.max_redirects,
|
|
277
|
+
event_hooks={
|
|
278
|
+
"request": self._config.request_hooks,
|
|
279
|
+
"response": self._config.response_hooks,
|
|
280
|
+
},
|
|
281
|
+
)
|
|
282
|
+
self._decorated_send = self._apply_decorators(self._send)
|
|
283
|
+
self._decorated_stream_request = self._apply_stream_request_decorators(self._stream_request)
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def client(self) -> httpx.Client:
|
|
287
|
+
"""Return the synchronous HTTP transport."""
|
|
288
|
+
return self._client
|
|
289
|
+
|
|
290
|
+
def __enter__(self) -> Self:
|
|
291
|
+
"""Open the connection pool and return the client."""
|
|
292
|
+
self.client.__enter__()
|
|
293
|
+
return self
|
|
294
|
+
|
|
295
|
+
def __exit__(
|
|
296
|
+
self,
|
|
297
|
+
exc_type: type[BaseException] | None,
|
|
298
|
+
exc_val: BaseException | None,
|
|
299
|
+
exc_tb: TracebackType | None,
|
|
300
|
+
) -> None:
|
|
301
|
+
"""Close the connection pool when leaving the context.
|
|
302
|
+
|
|
303
|
+
:param exc_type: Type of the exception in the context
|
|
304
|
+
:param exc_val: Exception in the context
|
|
305
|
+
:param exc_tb: Exception traceback
|
|
306
|
+
"""
|
|
307
|
+
self.client.__exit__(exc_type, exc_val, exc_tb)
|
|
308
|
+
|
|
309
|
+
def close(self) -> None:
|
|
310
|
+
"""Close the connection pool."""
|
|
311
|
+
self.client.close()
|
|
312
|
+
|
|
313
|
+
def get(self, url: URLTypes, **options: Unpack[QueryOptions]) -> httpx.Response:
|
|
314
|
+
"""Send a GET request."""
|
|
315
|
+
return self.request("GET", url, **options)
|
|
316
|
+
|
|
317
|
+
def post(self, url: URLTypes, **options: Unpack[RequestOptions]) -> httpx.Response:
|
|
318
|
+
"""Send a POST request."""
|
|
319
|
+
return self.request("POST", url, **options)
|
|
320
|
+
|
|
321
|
+
def put(self, url: URLTypes, **options: Unpack[RequestOptions]) -> httpx.Response:
|
|
322
|
+
"""Send a PUT request."""
|
|
323
|
+
return self.request("PUT", url, **options)
|
|
324
|
+
|
|
325
|
+
def patch(self, url: URLTypes, **options: Unpack[RequestOptions]) -> httpx.Response:
|
|
326
|
+
"""Send a PATCH request."""
|
|
327
|
+
return self.request("PATCH", url, **options)
|
|
328
|
+
|
|
329
|
+
def delete(self, url: URLTypes, **options: Unpack[QueryOptions]) -> httpx.Response:
|
|
330
|
+
"""Send a DELETE request."""
|
|
331
|
+
return self.request("DELETE", url, **options)
|
|
332
|
+
|
|
333
|
+
def head(self, url: URLTypes, **options: Unpack[QueryOptions]) -> httpx.Response:
|
|
334
|
+
"""Send a HEAD request."""
|
|
335
|
+
return self.request("HEAD", url, **options)
|
|
336
|
+
|
|
337
|
+
def options(self, url: URLTypes, **options: Unpack[QueryOptions]) -> httpx.Response:
|
|
338
|
+
"""Send an OPTIONS request."""
|
|
339
|
+
return self.request("OPTIONS", url, **options)
|
|
340
|
+
|
|
341
|
+
def request(self, method: str, url: URLTypes, **options: Unpack[RequestOptions]) -> httpx.Response:
|
|
342
|
+
"""Send an HTTP request with an arbitrary method."""
|
|
343
|
+
request, send_options = self._prepare_request(method, url, options)
|
|
344
|
+
return self.send(request, **send_options)
|
|
345
|
+
|
|
346
|
+
def send(self, request: httpx.Request, **options: Unpack[SendOptions]) -> httpx.Response:
|
|
347
|
+
"""Send an HTTP request."""
|
|
348
|
+
_reject_unknown_options(options, SendOptions.__optional_keys__)
|
|
349
|
+
if options.get("stream", False):
|
|
350
|
+
_mark_streaming(request)
|
|
351
|
+
return self._decorated_send(request, **options)
|
|
352
|
+
|
|
353
|
+
def _send(
|
|
354
|
+
self,
|
|
355
|
+
request: httpx.Request,
|
|
356
|
+
*,
|
|
357
|
+
stream: bool = False,
|
|
358
|
+
auth: AuthTypes | None = None,
|
|
359
|
+
follow_redirects: bool | None = None,
|
|
360
|
+
anonymous: bool = False,
|
|
361
|
+
) -> httpx.Response:
|
|
362
|
+
follow = follow_redirects if follow_redirects is not None else self.client.follow_redirects
|
|
363
|
+
|
|
364
|
+
if anonymous:
|
|
365
|
+
return self.client.send(request, stream=stream, auth=None, follow_redirects=follow)
|
|
366
|
+
return self.client.send(
|
|
367
|
+
request,
|
|
368
|
+
stream=stream,
|
|
369
|
+
auth=_resolve_auth(auth, self.client.auth),
|
|
370
|
+
follow_redirects=follow,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
def stream(
|
|
374
|
+
self, method: str, url: URLTypes, **options: Unpack[RequestOptions]
|
|
375
|
+
) -> AbstractContextManager[httpx.Response]:
|
|
376
|
+
"""Send a streaming HTTP request using the httpx contract."""
|
|
377
|
+
request, send_options = self._prepare_request(method, url, options)
|
|
378
|
+
return self.stream_request(request, **send_options)
|
|
379
|
+
|
|
380
|
+
def stream_request(
|
|
381
|
+
self, request: httpx.Request, **options: Unpack[StreamOptions]
|
|
382
|
+
) -> AbstractContextManager[httpx.Response]:
|
|
383
|
+
"""Send a streaming HTTP request.
|
|
384
|
+
|
|
385
|
+
Return a context manager for accessing the streaming response.
|
|
386
|
+
|
|
387
|
+
Usage example:
|
|
388
|
+
with client.stream_request(request) as response:
|
|
389
|
+
for chunk in response.iter_bytes():
|
|
390
|
+
process(chunk)
|
|
391
|
+
"""
|
|
392
|
+
_reject_unknown_options(options, StreamOptions.__optional_keys__)
|
|
393
|
+
return self._decorated_stream_request(request, **options)
|
|
394
|
+
|
|
395
|
+
@contextmanager
|
|
396
|
+
def _stream_request(
|
|
397
|
+
self,
|
|
398
|
+
request: httpx.Request,
|
|
399
|
+
*,
|
|
400
|
+
anonymous: bool = False,
|
|
401
|
+
auth: AuthTypes | None = None,
|
|
402
|
+
follow_redirects: bool | None = None,
|
|
403
|
+
) -> Iterator[httpx.Response]:
|
|
404
|
+
auth_to_use = None if anonymous else _resolve_auth(auth, self.client.auth)
|
|
405
|
+
follow = follow_redirects if follow_redirects is not None else self.client.follow_redirects
|
|
406
|
+
|
|
407
|
+
_mark_streaming(request)
|
|
408
|
+
|
|
409
|
+
# Send the supplied Request: rebuilding it with client.stream()
|
|
410
|
+
# broke streaming request bodies and passed a different object to hooks.
|
|
411
|
+
response = self.client.send(request, stream=True, auth=auth_to_use, follow_redirects=follow)
|
|
412
|
+
try:
|
|
413
|
+
yield response
|
|
414
|
+
finally:
|
|
415
|
+
response.close()
|
|
416
|
+
|
|
417
|
+
def _apply_decorators(self, func: SyncSendMethod) -> SyncSendMethod:
|
|
418
|
+
"""Apply all decorators to the function."""
|
|
419
|
+
for decorator in self._config.send_decorators:
|
|
420
|
+
func = cast("SyncSendDecorator", decorator)(func)
|
|
421
|
+
return func
|
|
422
|
+
|
|
423
|
+
def _apply_stream_request_decorators(self, func: SyncStreamRequestMethod) -> SyncStreamRequestMethod:
|
|
424
|
+
"""Apply all stream_request decorators to the function."""
|
|
425
|
+
for decorator in self._config.stream_decorators:
|
|
426
|
+
func = cast("SyncStreamRequestDecorator", decorator)(func)
|
|
427
|
+
return func
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class AsyncHttpClient(BaseHttpClient, metaclass=ABCMeta):
|
|
431
|
+
"""Asynchronous HTTP client."""
|
|
432
|
+
|
|
433
|
+
def __init__(
|
|
434
|
+
self,
|
|
435
|
+
base_url: URLTypes,
|
|
436
|
+
*options: ClientOption,
|
|
437
|
+
) -> None:
|
|
438
|
+
"""Create an asynchronous HTTP client.
|
|
439
|
+
|
|
440
|
+
:param base_url: HTTP service address
|
|
441
|
+
:param options: Built-in and custom options
|
|
442
|
+
"""
|
|
443
|
+
super().__init__(base_url, *options)
|
|
444
|
+
self._config.request_hooks = [_ensure_async_hook(h) for h in self._config.request_hooks]
|
|
445
|
+
self._config.response_hooks = [_ensure_async_hook(h) for h in self._config.response_hooks]
|
|
446
|
+
self._client = httpx.AsyncClient(
|
|
447
|
+
base_url=self._config.base_url,
|
|
448
|
+
auth=self.auth,
|
|
449
|
+
timeout=self.timeout,
|
|
450
|
+
verify=self.verify,
|
|
451
|
+
follow_redirects=self._config.follow_redirects,
|
|
452
|
+
max_redirects=self._config.max_redirects,
|
|
453
|
+
event_hooks={
|
|
454
|
+
"request": self._config.request_hooks,
|
|
455
|
+
"response": self._config.response_hooks,
|
|
456
|
+
},
|
|
457
|
+
)
|
|
458
|
+
self._decorated_send = self._apply_decorators(self._send)
|
|
459
|
+
self._decorated_stream_request = self._apply_stream_request_decorators(self._stream_request)
|
|
460
|
+
|
|
461
|
+
@property
|
|
462
|
+
def client(self) -> httpx.AsyncClient:
|
|
463
|
+
"""Return the asynchronous HTTP transport."""
|
|
464
|
+
return self._client
|
|
465
|
+
|
|
466
|
+
async def __aenter__(self) -> Self:
|
|
467
|
+
"""Open the connection pool and return the client."""
|
|
468
|
+
await self.client.__aenter__()
|
|
469
|
+
return self
|
|
470
|
+
|
|
471
|
+
async def __aexit__(
|
|
472
|
+
self,
|
|
473
|
+
exc_type: type[BaseException] | None,
|
|
474
|
+
exc_val: BaseException | None,
|
|
475
|
+
exc_tb: TracebackType | None,
|
|
476
|
+
) -> None:
|
|
477
|
+
"""Close the connection pool when leaving the context.
|
|
478
|
+
|
|
479
|
+
:param exc_type: Type of the exception in the context
|
|
480
|
+
:param exc_val: Exception in the context
|
|
481
|
+
:param exc_tb: Exception traceback
|
|
482
|
+
"""
|
|
483
|
+
await self.client.__aexit__(exc_type, exc_val, exc_tb)
|
|
484
|
+
|
|
485
|
+
async def aclose(self) -> None:
|
|
486
|
+
"""Close the connection pool."""
|
|
487
|
+
await self.client.aclose()
|
|
488
|
+
|
|
489
|
+
async def get(self, url: URLTypes, **options: Unpack[QueryOptions]) -> httpx.Response:
|
|
490
|
+
"""Send a GET request asynchronously."""
|
|
491
|
+
return await self.request("GET", url, **options)
|
|
492
|
+
|
|
493
|
+
async def post(self, url: URLTypes, **options: Unpack[RequestOptions]) -> httpx.Response:
|
|
494
|
+
"""Send a POST request asynchronously."""
|
|
495
|
+
return await self.request("POST", url, **options)
|
|
496
|
+
|
|
497
|
+
async def put(self, url: URLTypes, **options: Unpack[RequestOptions]) -> httpx.Response:
|
|
498
|
+
"""Send a PUT request asynchronously."""
|
|
499
|
+
return await self.request("PUT", url, **options)
|
|
500
|
+
|
|
501
|
+
async def patch(self, url: URLTypes, **options: Unpack[RequestOptions]) -> httpx.Response:
|
|
502
|
+
"""Send a PATCH request asynchronously."""
|
|
503
|
+
return await self.request("PATCH", url, **options)
|
|
504
|
+
|
|
505
|
+
async def delete(self, url: URLTypes, **options: Unpack[QueryOptions]) -> httpx.Response:
|
|
506
|
+
"""Send a DELETE request asynchronously."""
|
|
507
|
+
return await self.request("DELETE", url, **options)
|
|
508
|
+
|
|
509
|
+
async def head(self, url: URLTypes, **options: Unpack[QueryOptions]) -> httpx.Response:
|
|
510
|
+
"""Send a HEAD request asynchronously."""
|
|
511
|
+
return await self.request("HEAD", url, **options)
|
|
512
|
+
|
|
513
|
+
async def options(self, url: URLTypes, **options: Unpack[QueryOptions]) -> httpx.Response:
|
|
514
|
+
"""Send an OPTIONS request asynchronously."""
|
|
515
|
+
return await self.request("OPTIONS", url, **options)
|
|
516
|
+
|
|
517
|
+
async def request(self, method: str, url: URLTypes, **options: Unpack[RequestOptions]) -> httpx.Response:
|
|
518
|
+
"""Send an HTTP request with an arbitrary method asynchronously."""
|
|
519
|
+
request, send_options = self._prepare_request(method, url, options)
|
|
520
|
+
return await self.send(request, **send_options)
|
|
521
|
+
|
|
522
|
+
async def send(self, request: httpx.Request, **options: Unpack[SendOptions]) -> httpx.Response:
|
|
523
|
+
"""Send an HTTP request asynchronously."""
|
|
524
|
+
_reject_unknown_options(options, SendOptions.__optional_keys__)
|
|
525
|
+
if options.get("stream", False):
|
|
526
|
+
_mark_streaming(request)
|
|
527
|
+
return await self._decorated_send(request, **options)
|
|
528
|
+
|
|
529
|
+
async def _send(
|
|
530
|
+
self,
|
|
531
|
+
request: httpx.Request,
|
|
532
|
+
*,
|
|
533
|
+
stream: bool = False,
|
|
534
|
+
auth: AuthTypes | None = None,
|
|
535
|
+
follow_redirects: bool | None = None,
|
|
536
|
+
anonymous: bool = False,
|
|
537
|
+
) -> httpx.Response:
|
|
538
|
+
follow = follow_redirects if follow_redirects is not None else self.client.follow_redirects
|
|
539
|
+
|
|
540
|
+
if anonymous:
|
|
541
|
+
return await self.client.send(request, stream=stream, auth=None, follow_redirects=follow)
|
|
542
|
+
return await self.client.send(
|
|
543
|
+
request,
|
|
544
|
+
stream=stream,
|
|
545
|
+
auth=_resolve_auth(auth, self.client.auth),
|
|
546
|
+
follow_redirects=follow,
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
def stream(
|
|
550
|
+
self, method: str, url: URLTypes, **options: Unpack[RequestOptions]
|
|
551
|
+
) -> AbstractAsyncContextManager[httpx.Response]:
|
|
552
|
+
"""Send a streaming HTTP request asynchronously using the httpx contract."""
|
|
553
|
+
request, send_options = self._prepare_request(method, url, options)
|
|
554
|
+
return self.stream_request(request, **send_options)
|
|
555
|
+
|
|
556
|
+
def stream_request(
|
|
557
|
+
self, request: httpx.Request, **options: Unpack[StreamOptions]
|
|
558
|
+
) -> AbstractAsyncContextManager[httpx.Response]:
|
|
559
|
+
"""Send a streaming HTTP request asynchronously.
|
|
560
|
+
|
|
561
|
+
Return an asynchronous context manager for accessing the streaming response.
|
|
562
|
+
|
|
563
|
+
Usage example:
|
|
564
|
+
async with client.stream_request(request) as response:
|
|
565
|
+
async for chunk in response.aiter_bytes():
|
|
566
|
+
await process(chunk)
|
|
567
|
+
"""
|
|
568
|
+
_reject_unknown_options(options, StreamOptions.__optional_keys__)
|
|
569
|
+
return self._decorated_stream_request(request, **options)
|
|
570
|
+
|
|
571
|
+
@asynccontextmanager
|
|
572
|
+
async def _stream_request(
|
|
573
|
+
self,
|
|
574
|
+
request: httpx.Request,
|
|
575
|
+
*,
|
|
576
|
+
anonymous: bool = False,
|
|
577
|
+
auth: AuthTypes | None = None,
|
|
578
|
+
follow_redirects: bool | None = None,
|
|
579
|
+
) -> AsyncIterator[httpx.Response]:
|
|
580
|
+
auth_to_use = None if anonymous else _resolve_auth(auth, self.client.auth)
|
|
581
|
+
follow = follow_redirects if follow_redirects is not None else self.client.follow_redirects
|
|
582
|
+
|
|
583
|
+
_mark_streaming(request)
|
|
584
|
+
|
|
585
|
+
# Send the supplied Request: rebuilding it with client.stream()
|
|
586
|
+
# broke streaming request bodies and passed a different object to hooks.
|
|
587
|
+
response = await self.client.send(request, stream=True, auth=auth_to_use, follow_redirects=follow)
|
|
588
|
+
try:
|
|
589
|
+
yield response
|
|
590
|
+
finally:
|
|
591
|
+
await response.aclose()
|
|
592
|
+
|
|
593
|
+
def _apply_decorators(self, func: AsyncSendMethod) -> AsyncSendMethod:
|
|
594
|
+
"""Apply all decorators to the function."""
|
|
595
|
+
for decorator in self._config.send_decorators:
|
|
596
|
+
func = cast("AsyncSendDecorator", decorator)(func)
|
|
597
|
+
return func
|
|
598
|
+
|
|
599
|
+
def _apply_stream_request_decorators(self, func: AsyncStreamRequestMethod) -> AsyncStreamRequestMethod:
|
|
600
|
+
"""Apply all stream_request decorators to the function."""
|
|
601
|
+
for decorator in self._config.stream_decorators:
|
|
602
|
+
func = cast("AsyncStreamRequestDecorator", decorator)(func)
|
|
603
|
+
return func
|