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.
Files changed (52) hide show
  1. xtr_http_client-4.0.4/README.md → xtr_http_client-5.0.0/PKG-INFO +70 -9
  2. xtr_http_client-4.0.4/PKG-INFO → xtr_http_client-5.0.0/README.md +39 -45
  3. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/pyproject.toml +10 -12
  4. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/pyproject.toml.orig +11 -18
  5. {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
  6. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/bundle/_clients.py +1 -1
  7. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/bundle/client_options_config.py +1 -1
  8. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/bundle/http_client_bundle.py +1 -1
  9. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/exception/__init__.py +5 -1
  10. xtr_http_client-5.0.0/src/xtr_http_client/exception/logic_error.py +16 -0
  11. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/httpx_http_client.py +1 -0
  12. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/httpx_request.py +93 -6
  13. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/no_private_network_http_client.py +33 -6
  14. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/LICENSE +0 -0
  15. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/__init__.py +0 -0
  16. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/bundle/__init__.py +0 -0
  17. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/bundle/http_client_config.py +0 -0
  18. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/bundle/retry_failed_config.py +0 -0
  19. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/bundle/scoped_client_config.py +0 -0
  20. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/exception/_http_error_message.py +0 -0
  21. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/http_client.py +0 -0
  22. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/http_client_mixin.py +0 -0
  23. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/__init__.py +0 -0
  24. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/debug_type.py +0 -0
  25. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/httpx_body.py +0 -0
  26. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/httpx_clients.py +0 -0
  27. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/httpx_errors.py +0 -0
  28. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/httpx_redirect.py +0 -0
  29. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/mock_head.py +0 -0
  30. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/mock_info.py +0 -0
  31. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/option_merge.py +0 -0
  32. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/response_body.py +0 -0
  33. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/retry_count_response.py +0 -0
  34. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/retry_delay.py +0 -0
  35. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/internal/retry_options.py +0 -0
  36. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/mock_http_client.py +0 -0
  37. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/py.typed +0 -0
  38. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/recipe/__init__.py +0 -0
  39. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/recipe/manifest.toml +0 -0
  40. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/response/__init__.py +0 -0
  41. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/response/http_response.py +0 -0
  42. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/response/httpx_response.py +0 -0
  43. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/response/json_mock_response.py +0 -0
  44. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/response/mock_response.py +0 -0
  45. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/retry/__init__.py +0 -0
  46. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/retry/generic_retry_strategy.py +0 -0
  47. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/retry/retry_context.py +0 -0
  48. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/retry/retry_strategy_interface.py +0 -0
  49. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/retryable_http_client.py +0 -0
  50. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/scoping_http_client.py +0 -0
  51. {xtr_http_client-4.0.4 → xtr_http_client-5.0.0}/src/xtr_http_client/throttling_http_client.py +0 -0
  52. {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[di]" # + the bundle
32
- uv add "xtr-http-client[throttling]" # + the limiter the throttling client paces by
33
- uv add "xtr-http-client[http2]" # + the framing a request asking for version "2" needs
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 the
218
- `throttling` extra. A token is booked before each request and the request waits for the moment
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 "xtr-http-client[di]"`; add `throttling` to pace a client with an
338
- [xtr-rate-limiter](../xtr-rate-limiter) limiter, `http2` to ask for HTTP/2.
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[di]" # + the bundle
68
- uv add "xtr-http-client[throttling]" # + the limiter the throttling client paces by
69
- uv add "xtr-http-client[http2]" # + the framing a request asking for version "2" needs
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 the
254
- `throttling` extra. A token is booked before each request and the request waits for the moment
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 "xtr-http-client[di]"`; add `throttling` to pace a client with an
374
- [xtr-rate-limiter](../xtr-rate-limiter) limiter, `http2` to ask for HTTP/2.
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 = "4.0.4"
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>=4.0,<5",
33
- "xtr-http-foundation>=4.0,<5",
34
- "xtr-http-message>=4.0,<5",
35
- "xtr-http-message-contracts>=4.0,<5",
36
- "xtr-logging-contracts>=4.0,<5",
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>=4.0,<5",
65
- "xtr-dependency-injection>=4.0,<5",
66
- "xtr-rate-limiter>=4.0,<5",
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 = "4.0.4"
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>=4.0,<5",
31
- "xtr-http-foundation>=4.0,<5",
32
- "xtr-http-message>=4.0,<5",
33
- "xtr-http-message-contracts>=4.0,<5",
34
- "xtr-logging-contracts>=4.0,<5",
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>=4.0,<5",
71
- "xtr-dependency-injection>=4.0,<5",
72
- "xtr-rate-limiter>=4.0,<5",
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 LimiterInterface, [throttling]
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 "xtr-http-client[di]"`; add `throttling` to pace a client with an
191
- xtr-rate-limiter limiter, `http2` to ask for HTTP/2.
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
- # The throttling extra is optional: a client is only paced while the rate
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
 
@@ -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 the ``throttling`` extra.
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"})
@@ -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
- f'bundle is not active: install "xtr-http-client[throttling]".'
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
+ """
@@ -156,6 +156,7 @@ class HttpxHttpClient(HttpClientMixin, HttpClientInterface):
156
156
  url,
157
157
  content=payload.content,
158
158
  data=payload.form,
159
+ files=payload.files,
159
160
  json=payload.document,
160
161
  headers=headers,
161
162
  extensions=extensions,
@@ -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 typing import TYPE_CHECKING, Final, NamedTuple, cast
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 three shapes it is to be sent."""
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; bytes, text and an
165
- iterable are a body nobody labelled, so they are said to be a form here;
166
- and a JSON document labels itself.
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 Payload(None, cast("Mapping[str, object]", body), None)
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 IpUtils.PRIVATE_SUBNETS
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 IpUtils.check_ip(address, self._allow_list):
269
+ if self._allow_list and self._ip_utils.check_ip(address, self._allow_list):
262
270
  return False
263
271
 
264
- return IpUtils.check_ip(address, self._subnets)
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