xtr-http-client 4.0.4__tar.gz → 5.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xtr_http_client-4.0.4/README.md → xtr_http_client-5.0.0/PKG-INFO +70 -9
- xtr_http_client-4.0.4/PKG-INFO → xtr_http_client-5.0.0/README.md +39 -45
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/pyproject.toml +10 -12
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/pyproject.toml.orig +11 -18
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/.agents/skills/xtr-http-client/SKILL.md +28 -3
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/bundle/_clients.py +1 -1
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/bundle/client_options_config.py +1 -1
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/bundle/http_client_bundle.py +1 -1
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/exception/__init__.py +5 -1
- xtr_http_client-5.0.0/src/xtr_http_client/exception/logic_error.py +16 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/httpx_http_client.py +1 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/httpx_request.py +93 -6
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/no_private_network_http_client.py +33 -6
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/LICENSE +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/__init__.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/bundle/__init__.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/bundle/http_client_config.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/bundle/retry_failed_config.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/bundle/scoped_client_config.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/exception/_http_error_message.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/http_client.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/http_client_mixin.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/__init__.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/debug_type.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/httpx_body.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/httpx_clients.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/httpx_errors.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/httpx_redirect.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/mock_head.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/mock_info.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/option_merge.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/response_body.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/retry_count_response.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/retry_delay.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/retry_options.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/mock_http_client.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/py.typed +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/recipe/__init__.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/recipe/manifest.toml +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/response/__init__.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/response/http_response.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/response/httpx_response.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/response/json_mock_response.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/response/mock_response.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/retry/__init__.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/retry/generic_retry_strategy.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/retry/retry_context.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/retry/retry_strategy_interface.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/retryable_http_client.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/scoping_http_client.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/throttling_http_client.py +0 -0
- {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/traceable_http_client.py +0 -0
|
@@ -1,3 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xtr-http-client
|
|
3
|
+
Version: 5.0.0
|
|
4
|
+
Summary: An async HTTP client behind one interface: typed request options, bodies read on demand, scoped clients, a private-network guard and a mock client for tests.
|
|
5
|
+
Keywords: http,http-client,request,response,mock,asyncio
|
|
6
|
+
Author: Xterr
|
|
7
|
+
Author-email: Xterr <me@xterr.dev>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: typing-extensions>=4.4
|
|
20
|
+
Requires-Dist: anyio>=4.0
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Requires-Dist: xtr-http-client-contracts>=5.0,<6
|
|
23
|
+
Requires-Dist: xtr-http-message>=5.0,<6
|
|
24
|
+
Requires-Dist: xtr-http-message-contracts>=5.0,<6
|
|
25
|
+
Requires-Dist: xtr-logging-contracts>=5.0,<6
|
|
26
|
+
Requires-Dist: xtr-service-contracts>=5.0,<6
|
|
27
|
+
Requires-Dist: httpx[http2]>=0.27 ; extra == 'http2'
|
|
28
|
+
Requires-Python: >=3.11
|
|
29
|
+
Provides-Extra: http2
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
1
32
|
<div align="center">
|
|
2
33
|
|
|
3
34
|
# xtr-http-client
|
|
@@ -28,12 +59,18 @@ calls out sees one type however much is layered under it.
|
|
|
28
59
|
|
|
29
60
|
```sh
|
|
30
61
|
uv add xtr-http-client
|
|
31
|
-
uv add "xtr-http-client[
|
|
32
|
-
uv add
|
|
33
|
-
uv add
|
|
62
|
+
uv add "xtr-http-client[http2]" # + the framing a request asking for version "2" needs
|
|
63
|
+
uv add xtr-http-client xtr-dependency-injection # + the container, for the HttpClientBundle
|
|
64
|
+
uv add xtr-rate-limiter # beside it: the limiter the throttling client paces by
|
|
65
|
+
uv add xtr-http-foundation # beside it: the networks the private-network guard refuses
|
|
34
66
|
```
|
|
35
67
|
|
|
36
|
-
Requires Python 3.11+.
|
|
68
|
+
Requires Python 3.11+. `http2` is the only extra, and the container is not a dependency: the
|
|
69
|
+
bundle is used when [xtr-dependency-injection](../xtr-dependency-injection) is installed beside the
|
|
70
|
+
package, never as an extra, and every client works without it, built by hand as below. Installed
|
|
71
|
+
beside it too, [xtr-rate-limiter](../xtr-rate-limiter) supplies the limiter `ThrottlingHttpClient`
|
|
72
|
+
paces by, and [xtr-http-foundation](../xtr-http-foundation) the networks
|
|
73
|
+
`NoPrivateNetworkHttpClient` refuses.
|
|
37
74
|
|
|
38
75
|
## Quick start
|
|
39
76
|
|
|
@@ -105,6 +142,21 @@ the rest. An option this client does not know is refused with `InvalidArgumentEr
|
|
|
105
142
|
the closest one it does, because an option spelled wrong that was quietly dropped is a setting
|
|
106
143
|
that silently never applied.
|
|
107
144
|
|
|
145
|
+
A `body` mapping holding a file opened `"rb"` is uploaded as `multipart/form-data` — plain
|
|
146
|
+
values as `data`, each file as a part named after its file and typed from that name — and a
|
|
147
|
+
mapping without one stays url-encoded:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
with open("photo.png", "rb") as photo:
|
|
151
|
+
response = await client.request("POST", "upload", body={"caption": "hi", "photo": photo})
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
A `Content-Type: multipart/form-data; boundary=...` set by the caller keeps its boundary. The
|
|
155
|
+
file is read synchronously while the body is sent, which suits uploads of a modest size. A part
|
|
156
|
+
holds one scalar — a string, bytes, a number, a boolean (`true`/`false`) or `None` — or a list of
|
|
157
|
+
them, one part each; a nested value is refused with `InvalidArgumentError` naming the field, so
|
|
158
|
+
encode it (as JSON, say) first.
|
|
159
|
+
|
|
108
160
|
`with_options()` turns options into the defaults of another client of the same type, leaving
|
|
109
161
|
the one everyone else holds as it was:
|
|
110
162
|
|
|
@@ -214,9 +266,9 @@ from xtr_http_client import ThrottlingHttpClient
|
|
|
214
266
|
client = ThrottlingHttpClient(inner, limiter)
|
|
215
267
|
```
|
|
216
268
|
|
|
217
|
-
`limiter` is an [xtr-rate-limiter](../xtr-rate-limiter) `LimiterInterface`, from
|
|
218
|
-
|
|
219
|
-
that booking is due; booking first and waiting after is what makes the pace hold under
|
|
269
|
+
`limiter` is an [xtr-rate-limiter](../xtr-rate-limiter) `LimiterInterface`, from that package
|
|
270
|
+
installed beside this one. A token is booked before each request and the request waits for the
|
|
271
|
+
moment that booking is due; booking first and waiting after is what makes the pace hold under
|
|
220
272
|
concurrency, since requests then leave in the order they were asked for rather than all at
|
|
221
273
|
once. Which requests share a pace is decided by which of them are given the same limiter — one
|
|
222
274
|
per host, one per account, one for the whole application — and a copy made by `with_options()`
|
|
@@ -272,6 +324,10 @@ alike. `subnets` replaces that set and `allow_list` opens one receiver without o
|
|
|
272
324
|
network it sits in; both take addresses or CIDR networks, never host names. What a host
|
|
273
325
|
resolved to is remembered for the client's life, and `reset()` forgets it.
|
|
274
326
|
|
|
327
|
+
xtr-http-foundation is not a dependency: install it beside this package (`uv add
|
|
328
|
+
xtr-http-foundation`) to use the guard. Without it, building `NoPrivateNetworkHttpClient`
|
|
329
|
+
raises `LogicError`, so nothing goes out unchecked.
|
|
330
|
+
|
|
275
331
|
### `MockHttpClient` — answer from a script
|
|
276
332
|
|
|
277
333
|
The same client as any other here — the options it refuses, the defaults it starts from and the
|
|
@@ -334,8 +390,9 @@ Everything adding this package to an application on
|
|
|
334
390
|
[xtr-dependency-injection](../xtr-dependency-injection) takes — and, read backwards, what
|
|
335
391
|
removing it undoes.
|
|
336
392
|
|
|
337
|
-
- **Install** — `uv add
|
|
338
|
-
|
|
393
|
+
- **Install** — `uv add xtr-http-client xtr-dependency-injection`: the bundle needs the container
|
|
394
|
+
installed beside it, never as an extra. Add `http2` to ask for HTTP/2, and install
|
|
395
|
+
[xtr-rate-limiter](../xtr-rate-limiter) beside it to pace a client with one of its limiters.
|
|
339
396
|
- **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate* step below: it lists
|
|
340
397
|
`HttpClientBundle`. There is no config file, environment or ignore line to write; it prints the
|
|
341
398
|
step to name the services the application calls in `config/http_client.py`, which a recipe
|
|
@@ -356,6 +413,10 @@ removing it undoes.
|
|
|
356
413
|
|
|
357
414
|
## Kernel / bundle
|
|
358
415
|
|
|
416
|
+
With [xtr-dependency-injection](../xtr-dependency-injection) installed beside the package — it is
|
|
417
|
+
not a dependency, and nothing outside `xtr_http_client.bundle` needs it — list the bundle, then
|
|
418
|
+
configure the clients:
|
|
419
|
+
|
|
359
420
|
```python
|
|
360
421
|
# app/bundles.py
|
|
361
422
|
from xtr_http_client.bundle import HttpClientBundle
|
|
@@ -1,39 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: xtr-http-client
|
|
3
|
-
Version: 4.0.4
|
|
4
|
-
Summary: An async HTTP client behind one interface: typed request options, bodies read on demand, scoped clients, a private-network guard and a mock client for tests.
|
|
5
|
-
Keywords: http,http-client,request,response,mock,asyncio
|
|
6
|
-
Author: Xterr
|
|
7
|
-
Author-email: Xterr <me@xterr.dev>
|
|
8
|
-
License-Expression: MIT
|
|
9
|
-
License-File: LICENSE
|
|
10
|
-
Classifier: Development Status :: 3 - Alpha
|
|
11
|
-
Classifier: Framework :: AsyncIO
|
|
12
|
-
Classifier: Intended Audience :: Developers
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
-
Classifier: Topic :: Internet :: WWW/HTTP
|
|
18
|
-
Classifier: Typing :: Typed
|
|
19
|
-
Requires-Dist: typing-extensions>=4.4
|
|
20
|
-
Requires-Dist: anyio>=4.0
|
|
21
|
-
Requires-Dist: httpx>=0.27
|
|
22
|
-
Requires-Dist: xtr-http-client-contracts>=4.0,<5
|
|
23
|
-
Requires-Dist: xtr-http-foundation>=4.0,<5
|
|
24
|
-
Requires-Dist: xtr-http-message>=4.0,<5
|
|
25
|
-
Requires-Dist: xtr-http-message-contracts>=4.0,<5
|
|
26
|
-
Requires-Dist: xtr-logging-contracts>=4.0,<5
|
|
27
|
-
Requires-Dist: xtr-service-contracts>=4.0,<5
|
|
28
|
-
Requires-Dist: xtr-dependency-injection>=4.0,<5 ; extra == 'di'
|
|
29
|
-
Requires-Dist: httpx[http2]>=0.27 ; extra == 'http2'
|
|
30
|
-
Requires-Dist: xtr-rate-limiter>=4.0,<5 ; extra == 'throttling'
|
|
31
|
-
Requires-Python: >=3.11
|
|
32
|
-
Provides-Extra: di
|
|
33
|
-
Provides-Extra: http2
|
|
34
|
-
Provides-Extra: throttling
|
|
35
|
-
Description-Content-Type: text/markdown
|
|
36
|
-
|
|
37
1
|
<div align="center">
|
|
38
2
|
|
|
39
3
|
# xtr-http-client
|
|
@@ -64,12 +28,18 @@ calls out sees one type however much is layered under it.
|
|
|
64
28
|
|
|
65
29
|
```sh
|
|
66
30
|
uv add xtr-http-client
|
|
67
|
-
uv add "xtr-http-client[
|
|
68
|
-
uv add
|
|
69
|
-
uv add
|
|
31
|
+
uv add "xtr-http-client[http2]" # + the framing a request asking for version "2" needs
|
|
32
|
+
uv add xtr-http-client xtr-dependency-injection # + the container, for the HttpClientBundle
|
|
33
|
+
uv add xtr-rate-limiter # beside it: the limiter the throttling client paces by
|
|
34
|
+
uv add xtr-http-foundation # beside it: the networks the private-network guard refuses
|
|
70
35
|
```
|
|
71
36
|
|
|
72
|
-
Requires Python 3.11+.
|
|
37
|
+
Requires Python 3.11+. `http2` is the only extra, and the container is not a dependency: the
|
|
38
|
+
bundle is used when [xtr-dependency-injection](../xtr-dependency-injection) is installed beside the
|
|
39
|
+
package, never as an extra, and every client works without it, built by hand as below. Installed
|
|
40
|
+
beside it too, [xtr-rate-limiter](../xtr-rate-limiter) supplies the limiter `ThrottlingHttpClient`
|
|
41
|
+
paces by, and [xtr-http-foundation](../xtr-http-foundation) the networks
|
|
42
|
+
`NoPrivateNetworkHttpClient` refuses.
|
|
73
43
|
|
|
74
44
|
## Quick start
|
|
75
45
|
|
|
@@ -141,6 +111,21 @@ the rest. An option this client does not know is refused with `InvalidArgumentEr
|
|
|
141
111
|
the closest one it does, because an option spelled wrong that was quietly dropped is a setting
|
|
142
112
|
that silently never applied.
|
|
143
113
|
|
|
114
|
+
A `body` mapping holding a file opened `"rb"` is uploaded as `multipart/form-data` — plain
|
|
115
|
+
values as `data`, each file as a part named after its file and typed from that name — and a
|
|
116
|
+
mapping without one stays url-encoded:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
with open("photo.png", "rb") as photo:
|
|
120
|
+
response = await client.request("POST", "upload", body={"caption": "hi", "photo": photo})
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
A `Content-Type: multipart/form-data; boundary=...` set by the caller keeps its boundary. The
|
|
124
|
+
file is read synchronously while the body is sent, which suits uploads of a modest size. A part
|
|
125
|
+
holds one scalar — a string, bytes, a number, a boolean (`true`/`false`) or `None` — or a list of
|
|
126
|
+
them, one part each; a nested value is refused with `InvalidArgumentError` naming the field, so
|
|
127
|
+
encode it (as JSON, say) first.
|
|
128
|
+
|
|
144
129
|
`with_options()` turns options into the defaults of another client of the same type, leaving
|
|
145
130
|
the one everyone else holds as it was:
|
|
146
131
|
|
|
@@ -250,9 +235,9 @@ from xtr_http_client import ThrottlingHttpClient
|
|
|
250
235
|
client = ThrottlingHttpClient(inner, limiter)
|
|
251
236
|
```
|
|
252
237
|
|
|
253
|
-
`limiter` is an [xtr-rate-limiter](../xtr-rate-limiter) `LimiterInterface`, from
|
|
254
|
-
|
|
255
|
-
that booking is due; booking first and waiting after is what makes the pace hold under
|
|
238
|
+
`limiter` is an [xtr-rate-limiter](../xtr-rate-limiter) `LimiterInterface`, from that package
|
|
239
|
+
installed beside this one. A token is booked before each request and the request waits for the
|
|
240
|
+
moment that booking is due; booking first and waiting after is what makes the pace hold under
|
|
256
241
|
concurrency, since requests then leave in the order they were asked for rather than all at
|
|
257
242
|
once. Which requests share a pace is decided by which of them are given the same limiter — one
|
|
258
243
|
per host, one per account, one for the whole application — and a copy made by `with_options()`
|
|
@@ -308,6 +293,10 @@ alike. `subnets` replaces that set and `allow_list` opens one receiver without o
|
|
|
308
293
|
network it sits in; both take addresses or CIDR networks, never host names. What a host
|
|
309
294
|
resolved to is remembered for the client's life, and `reset()` forgets it.
|
|
310
295
|
|
|
296
|
+
xtr-http-foundation is not a dependency: install it beside this package (`uv add
|
|
297
|
+
xtr-http-foundation`) to use the guard. Without it, building `NoPrivateNetworkHttpClient`
|
|
298
|
+
raises `LogicError`, so nothing goes out unchecked.
|
|
299
|
+
|
|
311
300
|
### `MockHttpClient` — answer from a script
|
|
312
301
|
|
|
313
302
|
The same client as any other here — the options it refuses, the defaults it starts from and the
|
|
@@ -370,8 +359,9 @@ Everything adding this package to an application on
|
|
|
370
359
|
[xtr-dependency-injection](../xtr-dependency-injection) takes — and, read backwards, what
|
|
371
360
|
removing it undoes.
|
|
372
361
|
|
|
373
|
-
- **Install** — `uv add
|
|
374
|
-
|
|
362
|
+
- **Install** — `uv add xtr-http-client xtr-dependency-injection`: the bundle needs the container
|
|
363
|
+
installed beside it, never as an extra. Add `http2` to ask for HTTP/2, and install
|
|
364
|
+
[xtr-rate-limiter](../xtr-rate-limiter) beside it to pace a client with one of its limiters.
|
|
375
365
|
- **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate* step below: it lists
|
|
376
366
|
`HttpClientBundle`. There is no config file, environment or ignore line to write; it prints the
|
|
377
367
|
step to name the services the application calls in `config/http_client.py`, which a recipe
|
|
@@ -392,6 +382,10 @@ removing it undoes.
|
|
|
392
382
|
|
|
393
383
|
## Kernel / bundle
|
|
394
384
|
|
|
385
|
+
With [xtr-dependency-injection](../xtr-dependency-injection) installed beside the package — it is
|
|
386
|
+
not a dependency, and nothing outside `xtr_http_client.bundle` needs it — list the bundle, then
|
|
387
|
+
configure the clients:
|
|
388
|
+
|
|
395
389
|
```python
|
|
396
390
|
# app/bundles.py
|
|
397
391
|
from xtr_http_client.bundle import HttpClientBundle
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xtr-http-client"
|
|
3
|
-
version = "
|
|
3
|
+
version = "5.0.0"
|
|
4
4
|
description = "An async HTTP client behind one interface: typed request options, bodies read on demand, scoped clients, a private-network guard and a mock client for tests."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -29,12 +29,11 @@ dependencies = [
|
|
|
29
29
|
"typing-extensions>=4.4",
|
|
30
30
|
"anyio>=4.0",
|
|
31
31
|
"httpx>=0.27",
|
|
32
|
-
"xtr-http-client-contracts>=
|
|
33
|
-
"xtr-http-
|
|
34
|
-
"xtr-http-message>=
|
|
35
|
-
"xtr-
|
|
36
|
-
"xtr-
|
|
37
|
-
"xtr-service-contracts>=4.0,<5",
|
|
32
|
+
"xtr-http-client-contracts>=5.0,<6",
|
|
33
|
+
"xtr-http-message>=5.0,<6",
|
|
34
|
+
"xtr-http-message-contracts>=5.0,<6",
|
|
35
|
+
"xtr-logging-contracts>=5.0,<6",
|
|
36
|
+
"xtr-service-contracts>=5.0,<6",
|
|
38
37
|
]
|
|
39
38
|
|
|
40
39
|
[[project.authors]]
|
|
@@ -42,9 +41,7 @@ name = "Xterr"
|
|
|
42
41
|
email = "me@xterr.dev"
|
|
43
42
|
|
|
44
43
|
[project.optional-dependencies]
|
|
45
|
-
di = ["xtr-dependency-injection>=4.0,<5"]
|
|
46
44
|
http2 = ["httpx[http2]>=0.27"]
|
|
47
|
-
throttling = ["xtr-rate-limiter>=4.0,<5"]
|
|
48
45
|
|
|
49
46
|
[project.entry-points."xtr_dependency_injection.bundles"]
|
|
50
47
|
http_client = "xtr_http_client.bundle:HttpClientBundle"
|
|
@@ -61,9 +58,10 @@ dev = [
|
|
|
61
58
|
"anyio>=4.0",
|
|
62
59
|
"h2>=4.0",
|
|
63
60
|
"ty>=0.0.83",
|
|
64
|
-
"xtr-clock>=
|
|
65
|
-
"xtr-dependency-injection>=
|
|
66
|
-
"xtr-
|
|
61
|
+
"xtr-clock>=5.0,<6",
|
|
62
|
+
"xtr-dependency-injection>=5.0,<6",
|
|
63
|
+
"xtr-http-foundation>=5.0,<6",
|
|
64
|
+
"xtr-rate-limiter>=5.0,<6",
|
|
67
65
|
]
|
|
68
66
|
|
|
69
67
|
[build-system]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xtr-http-client"
|
|
3
|
-
version = "
|
|
3
|
+
version = "5.0.0"
|
|
4
4
|
description = "An async HTTP client behind one interface: typed request options, bodies read on demand, scoped clients, a private-network guard and a mock client for tests."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -27,27 +27,18 @@ dependencies = [
|
|
|
27
27
|
"typing-extensions>=4.4",
|
|
28
28
|
"anyio>=4.0",
|
|
29
29
|
"httpx>=0.27",
|
|
30
|
-
"xtr-http-client-contracts>=
|
|
31
|
-
"xtr-http-
|
|
32
|
-
"xtr-http-message>=
|
|
33
|
-
"xtr-
|
|
34
|
-
"xtr-
|
|
35
|
-
"xtr-service-contracts>=4.0,<5",
|
|
30
|
+
"xtr-http-client-contracts>=5.0,<6",
|
|
31
|
+
"xtr-http-message>=5.0,<6",
|
|
32
|
+
"xtr-http-message-contracts>=5.0,<6",
|
|
33
|
+
"xtr-logging-contracts>=5.0,<6",
|
|
34
|
+
"xtr-service-contracts>=5.0,<6",
|
|
36
35
|
]
|
|
37
36
|
|
|
38
37
|
[project.optional-dependencies]
|
|
39
|
-
di = [
|
|
40
|
-
"xtr-dependency-injection>=4.0,<5",
|
|
41
|
-
]
|
|
42
38
|
# The HTTP/2 framing, which only a request asking for version "2" needs.
|
|
43
39
|
http2 = [
|
|
44
40
|
"httpx[http2]>=0.27",
|
|
45
41
|
]
|
|
46
|
-
# The limiter the throttling client paces requests by; nothing else needs it,
|
|
47
|
-
# so the package imports and every other client works without it.
|
|
48
|
-
throttling = [
|
|
49
|
-
"xtr-rate-limiter>=4.0,<5",
|
|
50
|
-
]
|
|
51
42
|
|
|
52
43
|
# Advertises the bundle so debug:bundles can name it when installed but not listed.
|
|
53
44
|
# Advertising activates nothing: an application lists the bundles it wants.
|
|
@@ -67,9 +58,11 @@ dev = [
|
|
|
67
58
|
"anyio>=4.0",
|
|
68
59
|
"h2>=4.0",
|
|
69
60
|
"ty>=0.0.83",
|
|
70
|
-
"xtr-clock>=
|
|
71
|
-
"xtr-dependency-injection>=
|
|
72
|
-
|
|
61
|
+
"xtr-clock>=5.0,<6",
|
|
62
|
+
"xtr-dependency-injection>=5.0,<6",
|
|
63
|
+
# The networks NoPrivateNetworkHttpClient refuses; optional for an application.
|
|
64
|
+
"xtr-http-foundation>=5.0,<6",
|
|
65
|
+
"xtr-rate-limiter>=5.0,<6",
|
|
73
66
|
]
|
|
74
67
|
|
|
75
68
|
[build-system]
|
|
@@ -28,6 +28,9 @@ client, so the calling code sees one type however deep the stack is.
|
|
|
28
28
|
`MockHttpClient`.
|
|
29
29
|
- Options are the contract's `RequestOptions`. An option it does not know is refused, never
|
|
30
30
|
ignored.
|
|
31
|
+
- In an application, with xtr-dependency-injection installed beside the package (it is not a
|
|
32
|
+
dependency): activate `HttpClientBundle` and inject `HttpClientInterface`. Without the
|
|
33
|
+
container, build the clients yourself; only `xtr_http_client.bundle` needs it.
|
|
31
34
|
|
|
32
35
|
## Make a request
|
|
33
36
|
|
|
@@ -63,6 +66,25 @@ resolved against `base_uri`, `query` is merged into the URI, and `auth_bearer` a
|
|
|
63
66
|
where the request has no `Authorization`. A `body` or `json` option plus a request that already
|
|
64
67
|
carries a body is refused.
|
|
65
68
|
|
|
69
|
+
## Upload a file
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
with open("photo.png", "rb") as photo:
|
|
73
|
+
response = await client.request("POST", "upload", body={"caption": "hi", "photo": photo})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- A `body` mapping holding a binary file (`open(..., "rb")`, `io.BytesIO`) is sent as
|
|
77
|
+
`multipart/form-data`; without one it stays url-encoded.
|
|
78
|
+
- Each file part is named after the file's `name` (`unknown` without one) and typed by
|
|
79
|
+
`mimetypes.guess_type`, else `application/octet-stream`.
|
|
80
|
+
- Beside a file, every value is a scalar or a list of scalars; a nested one raises
|
|
81
|
+
`InvalidArgumentError` naming the field — `json.dumps` it first.
|
|
82
|
+
- A caller-set `Content-Type: multipart/form-data; boundary=...` keeps its boundary; do not set
|
|
83
|
+
a multipart content type without one.
|
|
84
|
+
- The file is read synchronously while the body is sent: keep the file open until `request()`
|
|
85
|
+
returns, and keep uploads modest in size.
|
|
86
|
+
- `MockHttpClient` hands the mapping, file included, to the script as given.
|
|
87
|
+
|
|
66
88
|
## Give each service its own defaults
|
|
67
89
|
|
|
68
90
|
```python
|
|
@@ -109,7 +131,7 @@ client = RetryableHttpClient(inner, GenericRetryStrategy([500, 502, 503]), max_r
|
|
|
109
131
|
```python
|
|
110
132
|
from xtr_http_client import NoPrivateNetworkHttpClient, ThrottlingHttpClient, TraceableHttpClient
|
|
111
133
|
|
|
112
|
-
paced = ThrottlingHttpClient(inner, limiter) # xtr-rate-limiter
|
|
134
|
+
paced = ThrottlingHttpClient(inner, limiter) # a LimiterInterface from xtr-rate-limiter
|
|
113
135
|
traced = TraceableHttpClient(inner)
|
|
114
136
|
guarded = NoPrivateNetworkHttpClient(inner, allow_list=("10.1.2.3",))
|
|
115
137
|
```
|
|
@@ -125,6 +147,8 @@ guarded = NoPrivateNetworkHttpClient(inner, allow_list=("10.1.2.3",))
|
|
|
125
147
|
Refusal is `TransportError`: `Host "<host>" is blocked for "<url>".`. Without `subnets` it
|
|
126
148
|
refuses `IpUtils.PRIVATE_SUBNETS`; `allow_list` opens one receiver. Addresses and CIDR
|
|
127
149
|
networks only — never host names.
|
|
150
|
+
It needs xtr-http-foundation installed beside this package (`uv add xtr-http-foundation`);
|
|
151
|
+
without it, building the guard raises `LogicError`.
|
|
128
152
|
|
|
129
153
|
## Use the transport directly
|
|
130
154
|
|
|
@@ -187,8 +211,9 @@ async def test_it_reads_the_created_order() -> None:
|
|
|
187
211
|
`HttpClientBundle`. There is no config file, environment or ignore line to write; it prints the
|
|
188
212
|
step to name the services the application calls, which a recipe cannot make for you.
|
|
189
213
|
|
|
190
|
-
1. **Install** — `uv add
|
|
191
|
-
|
|
214
|
+
1. **Install** — `uv add xtr-http-client xtr-dependency-injection`: the bundle needs the container
|
|
215
|
+
installed beside it, never as an extra. Add `http2` to ask for HTTP/2, and install
|
|
216
|
+
xtr-rate-limiter beside it to pace a client with one of its limiters.
|
|
192
217
|
2. **Activate** — `HttpClientBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`
|
|
193
218
|
(`from xtr_http_client.bundle import HttpClientBundle`).
|
|
194
219
|
3. **Brings along** — the rate limiter bundle when xtr-rate-limiter is installed.
|
|
@@ -124,7 +124,7 @@ def _mock_transport() -> MockHttpClient:
|
|
|
124
124
|
|
|
125
125
|
def _limiters(rate_limiter: str) -> object:
|
|
126
126
|
"""Return the annotation asking for the limiter factory ``rate_limiter`` names."""
|
|
127
|
-
#
|
|
127
|
+
# xtr-rate-limiter is optional: a client is only paced while the rate
|
|
128
128
|
# limiter bundle is active, which proves it installed.
|
|
129
129
|
from xtr_rate_limiter import RateLimiterFactoryInterface # noqa: PLC0415
|
|
130
130
|
|
{xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/bundle/client_options_config.py
RENAMED
|
@@ -44,7 +44,7 @@ class ClientOptionsConfig:
|
|
|
44
44
|
extra: Additional options a client may ignore.
|
|
45
45
|
retry_failed: Whether and how a failed request is made again.
|
|
46
46
|
rate_limiter: The name of the xtr-rate-limiter limiter every request
|
|
47
|
-
waits its turn from; needs
|
|
47
|
+
waits its turn from; needs xtr-rate-limiter installed.
|
|
48
48
|
"""
|
|
49
49
|
|
|
50
50
|
_NOT_OPTIONS: ClassVar[frozenset[str]] = frozenset({"retry_failed", "rate_limiter"})
|
{xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/bundle/http_client_bundle.py
RENAMED
|
@@ -81,5 +81,5 @@ def _check_pace(owner: str, rate_limiter: str | None, pacing: bool) -> None:
|
|
|
81
81
|
if rate_limiter is not None and not pacing:
|
|
82
82
|
raise InvalidArgumentError(
|
|
83
83
|
f'{owner} is paced by the "{rate_limiter}" rate limiter, but the rate limiter '
|
|
84
|
-
|
|
84
|
+
"bundle is not active: install xtr-rate-limiter."
|
|
85
85
|
)
|
|
@@ -8,7 +8,8 @@ at the root, :class:`TransportError` for "there is no answer" (with
|
|
|
8
8
|
:class:`ClientError`, :class:`ServerError`), :class:`DecodingError` for an
|
|
9
9
|
answer that is not what it was read as (with :class:`JsonError` for a body that
|
|
10
10
|
is not the JSON it was read as), and :class:`InvalidArgumentError` for a request
|
|
11
|
-
this client refuses to make.
|
|
11
|
+
this client refuses to make. :class:`LogicError` is this package's own: a client
|
|
12
|
+
built in a way that cannot work.
|
|
12
13
|
"""
|
|
13
14
|
|
|
14
15
|
from __future__ import annotations
|
|
@@ -26,6 +27,8 @@ from xtr_http_client_contracts import (
|
|
|
26
27
|
TransportTimeoutError,
|
|
27
28
|
)
|
|
28
29
|
|
|
30
|
+
from .logic_error import LogicError
|
|
31
|
+
|
|
29
32
|
__all__ = [
|
|
30
33
|
"ClientError",
|
|
31
34
|
"DecodingError",
|
|
@@ -33,6 +36,7 @@ __all__ = [
|
|
|
33
36
|
"HttpError",
|
|
34
37
|
"InvalidArgumentError",
|
|
35
38
|
"JsonError",
|
|
39
|
+
"LogicError",
|
|
36
40
|
"RedirectionError",
|
|
37
41
|
"ServerError",
|
|
38
42
|
"TransportError",
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""This library was used in a way that cannot work."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from xtr_http_client_contracts import HttpClientError
|
|
6
|
+
|
|
7
|
+
__all__ = ["LogicError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LogicError(HttpClientError, RuntimeError):
|
|
11
|
+
"""A client was built in a way that cannot work, whatever it is asked to send.
|
|
12
|
+
|
|
13
|
+
Building the private-network guard without xtr-http-foundation installed,
|
|
14
|
+
which holds the networks it refuses, is a mistake in the application's
|
|
15
|
+
setup rather than in a request, so retrying it changes nothing.
|
|
16
|
+
"""
|
{xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/httpx_request.py
RENAMED
|
@@ -8,8 +8,13 @@ per part, made here, away from the exchange and the redirect chain that follow.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import io
|
|
12
|
+
import mimetypes
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
11
15
|
from collections.abc import AsyncIterable, Mapping, Sequence
|
|
12
|
-
from
|
|
16
|
+
from pathlib import PurePath
|
|
17
|
+
from typing import TYPE_CHECKING, BinaryIO, Final, NamedTuple, cast
|
|
13
18
|
|
|
14
19
|
import httpx
|
|
15
20
|
|
|
@@ -47,12 +52,30 @@ _FORM: Final = "application/x-www-form-urlencoded"
|
|
|
47
52
|
_NO_BODY: Final = (None, b"", "")
|
|
48
53
|
|
|
49
54
|
|
|
55
|
+
# Read the way the backend reads it: a boundary it cannot find here is one it would not use
|
|
56
|
+
# either, and it would then write a body the caller's header does not describe.
|
|
57
|
+
# What one multipart part holds; a boolean is an int, written "true" or "false".
|
|
58
|
+
_PART_SCALARS: Final = (str, bytes, int, float)
|
|
59
|
+
_NAMED_BOUNDARY: Final = re.compile(r'^multipart/form-data;.*\bboundary=(?:"[^"]+"|[^;\s"]+)')
|
|
60
|
+
|
|
61
|
+
# What a file with no name of its own is sent as: a server reads a part without a file name
|
|
62
|
+
# as a plain value, so a file has to be called something.
|
|
63
|
+
_UNNAMED: Final = "unknown"
|
|
64
|
+
_OCTET_STREAM: Final = "application/octet-stream"
|
|
65
|
+
|
|
66
|
+
|
|
50
67
|
class Payload(NamedTuple):
|
|
51
|
-
"""The one body of a request, in whichever of
|
|
68
|
+
"""The one body of a request, in whichever of four shapes it is to be sent.
|
|
69
|
+
|
|
70
|
+
``files`` sits beside ``form`` rather than replacing it: a form holding a file is sent
|
|
71
|
+
as multipart, its plain values in ``form`` and each file, with the name and content type
|
|
72
|
+
its part is labelled with, in ``files``.
|
|
73
|
+
"""
|
|
52
74
|
|
|
53
75
|
content: bytes | str | AsyncIterator[bytes] | None
|
|
54
76
|
form: Mapping[str, object] | None
|
|
55
77
|
document: object
|
|
78
|
+
files: Mapping[str, tuple[str, BinaryIO, str]] | None = None
|
|
56
79
|
|
|
57
80
|
|
|
58
81
|
def target_url(request: RequestInterface, options: Mapping[str, object]) -> httpx.URL:
|
|
@@ -161,9 +184,9 @@ async def assemble_payload(
|
|
|
161
184
|
def _asked_for(options: Mapping[str, object], headers: list[tuple[str, str]]) -> Payload:
|
|
162
185
|
"""What the options alone say to send, for a request that carried no body.
|
|
163
186
|
|
|
164
|
-
A mapping is a form, which the backend writes out
|
|
165
|
-
iterable are a body nobody labelled, so
|
|
166
|
-
and a JSON document labels itself.
|
|
187
|
+
A mapping is a form, which the backend writes out — as multipart when it
|
|
188
|
+
holds a file; bytes, text and an iterable are a body nobody labelled, so
|
|
189
|
+
they are said to be a form here; and a JSON document labels itself.
|
|
167
190
|
"""
|
|
168
191
|
document = options.get("json")
|
|
169
192
|
if document is not None:
|
|
@@ -171,7 +194,7 @@ def _asked_for(options: Mapping[str, object], headers: list[tuple[str, str]]) ->
|
|
|
171
194
|
|
|
172
195
|
body = options.get("body")
|
|
173
196
|
if isinstance(body, Mapping):
|
|
174
|
-
return
|
|
197
|
+
return _form(cast("Mapping[str, object]", body), headers)
|
|
175
198
|
if isinstance(body, (bytes, str)) and body:
|
|
176
199
|
_imply_form(headers)
|
|
177
200
|
|
|
@@ -184,6 +207,70 @@ def _asked_for(options: Mapping[str, object], headers: list[tuple[str, str]]) ->
|
|
|
184
207
|
return Payload(None, None, None)
|
|
185
208
|
|
|
186
209
|
|
|
210
|
+
def _form(body: Mapping[str, object], headers: list[tuple[str, str]]) -> Payload:
|
|
211
|
+
"""A form, url-encoded unless one of its values is a binary file.
|
|
212
|
+
|
|
213
|
+
The content type is left for the backend to write with the boundary it
|
|
214
|
+
picks, unless the caller already named one: then that boundary is the one
|
|
215
|
+
the body is written with. A content type naming none would describe a
|
|
216
|
+
body it cannot be read by, so it is dropped.
|
|
217
|
+
"""
|
|
218
|
+
files = {
|
|
219
|
+
# The standard library's binary file classes are what `BinaryIO` describes, though
|
|
220
|
+
# the two are not declared related.
|
|
221
|
+
name: _file_part(cast("BinaryIO", cast("object", value)))
|
|
222
|
+
for name, value in body.items()
|
|
223
|
+
if isinstance(value, (io.RawIOBase, io.BufferedIOBase))
|
|
224
|
+
}
|
|
225
|
+
if not files:
|
|
226
|
+
return Payload(None, body, None)
|
|
227
|
+
|
|
228
|
+
content_type = next(
|
|
229
|
+
(value for name, value in headers if name.lower() == "content-type"),
|
|
230
|
+
"",
|
|
231
|
+
)
|
|
232
|
+
if not _NAMED_BOUNDARY.match(content_type):
|
|
233
|
+
headers[:] = [entry for entry in headers if entry[0].lower() != "content-type"]
|
|
234
|
+
|
|
235
|
+
plain = {name: value for name, value in body.items() if name not in files}
|
|
236
|
+
for name, value in plain.items():
|
|
237
|
+
_check_part(name, value)
|
|
238
|
+
|
|
239
|
+
return Payload(None, plain, None, files)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _check_part(name: str, value: object) -> None:
|
|
243
|
+
"""Refuse a plain value of a multipart form that no part can hold.
|
|
244
|
+
|
|
245
|
+
A part holds one scalar, and a list or tuple of them is one part per item;
|
|
246
|
+
anything nested would otherwise fail deep inside the backend, naming no field.
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
InvalidArgumentError: Naming the field and the type it was given.
|
|
250
|
+
"""
|
|
251
|
+
given = debug_type(value)
|
|
252
|
+
items: Sequence[object] = (
|
|
253
|
+
cast("Sequence[object]", value) if isinstance(value, (list, tuple)) else (value,)
|
|
254
|
+
)
|
|
255
|
+
if all(isinstance(item, _PART_SCALARS) or item is None for item in items):
|
|
256
|
+
return
|
|
257
|
+
raise InvalidArgumentError(
|
|
258
|
+
f'Option "body" field "{name}" must be a string, a number, a boolean, None or a list '
|
|
259
|
+
f'of them when the body is multipart, "{given}" given.'
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _file_part(file: BinaryIO) -> tuple[str, BinaryIO, str]:
|
|
264
|
+
"""``file``, with the file name and the content type its part is labelled with."""
|
|
265
|
+
name = cast("object", getattr(file, "name", None))
|
|
266
|
+
if isinstance(name, bytes):
|
|
267
|
+
name = os.fsdecode(name)
|
|
268
|
+
filename = PurePath(name).name if isinstance(name, str) and name else _UNNAMED
|
|
269
|
+
content_type = mimetypes.guess_type(filename)[0] or _OCTET_STREAM
|
|
270
|
+
|
|
271
|
+
return filename, file, content_type
|
|
272
|
+
|
|
273
|
+
|
|
187
274
|
async def checked_body(source: AsyncIterable[object]) -> AsyncIterator[bytes]:
|
|
188
275
|
"""``source``, with a piece that is not bytes refused before it reaches the wire.
|
|
189
276
|
|
|
@@ -12,11 +12,10 @@ from urllib.parse import urljoin
|
|
|
12
12
|
import anyio
|
|
13
13
|
from typing_extensions import Unpack, override
|
|
14
14
|
from xtr_http_client_contracts import HttpClientInterface
|
|
15
|
-
from xtr_http_foundation import IpUtils
|
|
16
15
|
from xtr_http_message import Request, Stream, Uri
|
|
17
16
|
from xtr_service_contracts import ResetInterface
|
|
18
17
|
|
|
19
|
-
from .exception import InvalidArgumentError, TransportError
|
|
18
|
+
from .exception import InvalidArgumentError, LogicError, TransportError
|
|
20
19
|
from .internal.option_merge import as_options
|
|
21
20
|
|
|
22
21
|
if TYPE_CHECKING:
|
|
@@ -24,6 +23,7 @@ if TYPE_CHECKING:
|
|
|
24
23
|
from typing import Self
|
|
25
24
|
|
|
26
25
|
from xtr_http_client_contracts import HttpResponseInterface, RequestOptions
|
|
26
|
+
from xtr_http_foundation import IpUtils
|
|
27
27
|
from xtr_http_message_contracts import RequestInterface
|
|
28
28
|
|
|
29
29
|
__all__ = ["NoPrivateNetworkHttpClient"]
|
|
@@ -64,9 +64,12 @@ class NoPrivateNetworkHttpClient(HttpClientInterface, ResetInterface):
|
|
|
64
64
|
What a host resolves to is remembered for as long as this client lives,
|
|
65
65
|
so a chain of hops back to the same host asks the resolver once;
|
|
66
66
|
:meth:`reset` forgets it.
|
|
67
|
+
|
|
68
|
+
The networks it refuses come from xtr-http-foundation, which is not a
|
|
69
|
+
dependency of this package: install it beside it to use this client.
|
|
67
70
|
"""
|
|
68
71
|
|
|
69
|
-
__slots__ = ("_allow_list", "_client", "_dns_cache", "_resolver", "_subnets")
|
|
72
|
+
__slots__ = ("_allow_list", "_client", "_dns_cache", "_ip_utils", "_resolver", "_subnets")
|
|
70
73
|
|
|
71
74
|
def __init__(
|
|
72
75
|
self,
|
|
@@ -89,9 +92,14 @@ class NoPrivateNetworkHttpClient(HttpClientInterface, ResetInterface):
|
|
|
89
92
|
have to be resolved, and could point anywhere by then.
|
|
90
93
|
resolver: What every address of a host and port is asked of.
|
|
91
94
|
``None`` asks the platform's own resolver.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
LogicError: When xtr-http-foundation is not installed, since there
|
|
98
|
+
is then no way to tell which addresses to refuse.
|
|
92
99
|
"""
|
|
100
|
+
self._ip_utils = _ip_utils()
|
|
93
101
|
self._client = client
|
|
94
|
-
self._subnets = _entries(subnets) if subnets is not None else
|
|
102
|
+
self._subnets = _entries(subnets) if subnets is not None else self._ip_utils.PRIVATE_SUBNETS
|
|
95
103
|
self._allow_list = _entries(allow_list)
|
|
96
104
|
self._resolver = resolver if resolver is not None else _getaddrinfo
|
|
97
105
|
self._dns_cache: dict[str, Sequence[str]] = {}
|
|
@@ -258,10 +266,29 @@ class NoPrivateNetworkHttpClient(HttpClientInterface, ResetInterface):
|
|
|
258
266
|
The allow list is read first, so one internal receiver is opened
|
|
259
267
|
without opening the network it sits in.
|
|
260
268
|
"""
|
|
261
|
-
if self._allow_list and
|
|
269
|
+
if self._allow_list and self._ip_utils.check_ip(address, self._allow_list):
|
|
262
270
|
return False
|
|
263
271
|
|
|
264
|
-
return
|
|
272
|
+
return self._ip_utils.check_ip(address, self._subnets)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _ip_utils() -> type[IpUtils]:
|
|
276
|
+
"""The address checks, from xtr-http-foundation when it is installed.
|
|
277
|
+
|
|
278
|
+
Raises:
|
|
279
|
+
LogicError: When it is not, so the guard fails where it is built
|
|
280
|
+
rather than letting through a request it could not check.
|
|
281
|
+
"""
|
|
282
|
+
try:
|
|
283
|
+
# Optional package, so imported only when a guard is built (hence PLC0415).
|
|
284
|
+
from xtr_http_foundation import IpUtils # noqa: PLC0415
|
|
285
|
+
except ImportError as error:
|
|
286
|
+
raise LogicError(
|
|
287
|
+
'You cannot use "NoPrivateNetworkHttpClient" if xtr-http-foundation is not '
|
|
288
|
+
'installed. Try running "uv add xtr-http-foundation".'
|
|
289
|
+
) from error
|
|
290
|
+
|
|
291
|
+
return IpUtils
|
|
265
292
|
|
|
266
293
|
|
|
267
294
|
async def _getaddrinfo(host: str, port: int) -> Sequence[str]:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/bundle/http_client_config.py
RENAMED
|
File without changes
|
{xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/bundle/retry_failed_config.py
RENAMED
|
File without changes
|
{xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/bundle/scoped_client_config.py
RENAMED
|
File without changes
|
{xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/exception/_http_error_message.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/httpx_clients.py
RENAMED
|
File without changes
|
{xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/httpx_errors.py
RENAMED
|
File without changes
|
{xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/httpx_redirect.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/option_merge.py
RENAMED
|
File without changes
|
{xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/response_body.py
RENAMED
|
File without changes
|
{xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/retry_count_response.py
RENAMED
|
File without changes
|
|
File without changes
|
{xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/retry_options.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/response/http_response.py
RENAMED
|
File without changes
|
{xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/response/httpx_response.py
RENAMED
|
File without changes
|
{xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/response/json_mock_response.py
RENAMED
|
File without changes
|
{xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/response/mock_response.py
RENAMED
|
File without changes
|
|
File without changes
|
{xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/retry/generic_retry_strategy.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/retryable_http_client.py
RENAMED
|
File without changes
|
|
File without changes
|
{xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/throttling_http_client.py
RENAMED
|
File without changes
|
{xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/traceable_http_client.py
RENAMED
|
File without changes
|