xtr-http-message 4.0.2__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 (24) hide show
  1. xtr_http_message-4.0.2/LICENSE +23 -0
  2. xtr_http_message-4.0.2/PKG-INFO +280 -0
  3. xtr_http_message-4.0.2/README.md +257 -0
  4. xtr_http_message-4.0.2/pyproject.toml +154 -0
  5. xtr_http_message-4.0.2/pyproject.toml.orig +151 -0
  6. xtr_http_message-4.0.2/src/xtr_http_message/.agents/skills/xtr-http-message/SKILL.md +296 -0
  7. xtr_http_message-4.0.2/src/xtr_http_message/__init__.py +85 -0
  8. xtr_http_message-4.0.2/src/xtr_http_message/_ascii.py +26 -0
  9. xtr_http_message-4.0.2/src/xtr_http_message/_message_trait.py +354 -0
  10. xtr_http_message-4.0.2/src/xtr_http_message/_request_trait.py +164 -0
  11. xtr_http_message-4.0.2/src/xtr_http_message/exception/__init__.py +24 -0
  12. xtr_http_message-4.0.2/src/xtr_http_message/exception/http_message_error.py +14 -0
  13. xtr_http_message-4.0.2/src/xtr_http_message/exception/invalid_argument_error.py +17 -0
  14. xtr_http_message-4.0.2/src/xtr_http_message/exception/stream_error.py +17 -0
  15. xtr_http_message-4.0.2/src/xtr_http_message/exception/uploaded_file_error.py +17 -0
  16. xtr_http_message-4.0.2/src/xtr_http_message/factory/__init__.py +20 -0
  17. xtr_http_message-4.0.2/src/xtr_http_message/factory/http_factory.py +235 -0
  18. xtr_http_message-4.0.2/src/xtr_http_message/py.typed +0 -0
  19. xtr_http_message-4.0.2/src/xtr_http_message/request.py +74 -0
  20. xtr_http_message-4.0.2/src/xtr_http_message/response.py +196 -0
  21. xtr_http_message-4.0.2/src/xtr_http_message/server_request.py +350 -0
  22. xtr_http_message-4.0.2/src/xtr_http_message/stream.py +404 -0
  23. xtr_http_message-4.0.2/src/xtr_http_message/uploaded_file.py +270 -0
  24. xtr_http_message-4.0.2/src/xtr_http_message/uri.py +660 -0
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xterr
4
+ Copyright (c) 2016 Tobias Nyholm
5
+ Copyright (c) 2015-2016 PHP HTTP Team <team@php-http.org>
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
@@ -0,0 +1,280 @@
1
+ Metadata-Version: 2.4
2
+ Name: xtr-http-message
3
+ Version: 4.0.2
4
+ Summary: A strict, framework-free implementation of the HTTP message contract: requests, responses, streams, URIs, uploaded files and one factory for all six.
5
+ Keywords: http,http-message,request,response,stream,uri,uploaded-file
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: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Internet :: WWW/HTTP
17
+ Classifier: Typing :: Typed
18
+ Requires-Dist: xtr-http-message-contracts>=4.0,<5
19
+ Requires-Dist: typing-extensions>=4.4
20
+ Requires-Dist: uritools>=6.1,<7
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+
24
+ <div align="center">
25
+
26
+ # xtr-http-message
27
+
28
+ **The HTTP message contract, implemented โ€” six small classes, one factory, and no web framework anywhere in sight.**
29
+
30
+ <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
31
+ <img alt="core dependencies: 3" src="https://img.shields.io/badge/core%20deps-3-3FB950">
32
+ <img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
33
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
34
+
35
+ </div>
36
+
37
+ ---
38
+
39
+ ## Why?
40
+
41
+ A library that handles requests should depend on the contract alone. An application has to pick
42
+ something that actually *is* a request โ€” and that is what this package is: the thirteen
43
+ interfaces of [xtr-http-message-contracts](../xtr-http-message-contracts) made real, and nothing
44
+ besides.
45
+
46
+ - ๐Ÿ“จ **`Request`** โ€” a request going out: a method, a URI, headers and a body, with the `Host`
47
+ header taken from the URI and sent in front of every other header.
48
+ - ๐Ÿ“ฅ **`ServerRequest`** โ€” a request that arrived: the environment parameters, the cookies, the
49
+ query string parsed, the files that came with it, the parsed body, and the attributes a
50
+ handler annotates it with.
51
+ - ๐Ÿ“ค **`Response`** โ€” a status code, the reason phrase recommended for it, headers and a body.
52
+ - ๐ŸŒŠ **`Stream`** โ€” a body, as a cursor over bytes behind an open binary file object.
53
+ - ๐Ÿ”— **`Uri`** โ€” a URI taken apart into components and put back together as text, read as
54
+ RFC 3986 defines one.
55
+ - ๐Ÿ“Ž **`UploadedFile`** โ€” one file that came in, movable once.
56
+ - ๐Ÿญ **`HttpFactory`** โ€” all six factory interfaces behind one object.
57
+
58
+ ```python
59
+ from xtr_http_message import Response, ServerRequest
60
+
61
+ request = ServerRequest("GET", "https://example.com/orders?page=2")
62
+ assert request.get_query_params() == {"page": "2"}
63
+ assert request.get_header_line("host") == "example.com"
64
+
65
+ response = Response(201, {"content-type": "application/json"}, b'{"id": 7}')
66
+ assert response.get_reason_phrase() == "Created"
67
+ ```
68
+
69
+ ## Install
70
+
71
+ ```sh
72
+ uv add xtr-http-message
73
+ ```
74
+
75
+ Requires Python 3.11+. Three runtime dependencies: the contract it implements,
76
+ `typing-extensions` for `@override` on 3.11, and `uritools` to split a URI the way RFC 3986
77
+ defines it.
78
+
79
+ ## Who installs what
80
+
81
+ | | Depends on |
82
+ | --- | --- |
83
+ | **A library that handles requests** | `xtr-http-message-contracts` at runtime, this package as a dev dependency โ€” its tests need real messages. |
84
+ | **An application** | this package, plus the integration that bridges these types to the web framework it runs on. |
85
+
86
+ An application installs it and then mostly does not name it. Code that is **serving a request**
87
+ takes the registered factory interfaces and is handed whatever the integration put behind them;
88
+ the classes here are what code *outside* a served request builds โ€” a test, a worker, a console
89
+ command. The [response rule](#responses-in-a-served-application) below says why that matters
90
+ most for responses.
91
+
92
+ ### Responses in a served application
93
+
94
+ In an application, a response is either the injected `ResponseInterface` or one the injected
95
+ `ResponseFactoryInterface` made, and every response a `with_*` on one of those returns is of
96
+ that same kind: the integration's own, returned from the route as it is, with nothing to
97
+ configure. `Response` here is *not* that โ€” it knows no web framework, so one returned from a
98
+ route is not a response a framework can send. Build it where there is no request being served,
99
+ and have the integration convert a message that arrived from such a place: for a FastAPI
100
+ application that is [xtr-http-kernel](../xtr-http-kernel)'s
101
+ `StarletteHttpFactory.create_response()`.
102
+
103
+ ## The classes
104
+
105
+ | Class | Implements |
106
+ | --- | --- |
107
+ | `Request` | `RequestInterface` |
108
+ | `ServerRequest` | `ServerRequestInterface` |
109
+ | `Response` | `ResponseInterface` |
110
+ | `Stream` | `StreamInterface` |
111
+ | `Uri` | `UriInterface` |
112
+ | `UploadedFile` | `UploadedFileInterface` |
113
+ | `HttpFactory` | all six factory interfaces |
114
+
115
+ Each one names its interface as its last base class, so `isinstance` and a container
116
+ registration both work without a plugin registry in between.
117
+
118
+ ### The rules that hold across all of them
119
+
120
+ - **Messages are immutable.** Every `with_*` method leaves the instance it was called on as it
121
+ was and returns one carrying the change โ€” and hands *the same instance* back when the change
122
+ would change nothing, so a chain of `with_*` calls costs nothing where it does nothing.
123
+ - **A stream is the one thing that is not.** Reading, writing and seeking move its cursor in
124
+ place.
125
+ - **The payload is bytes throughout.** `await stream.get_contents()` rewinds and reads the whole
126
+ of a body, and raises when the body cannot be read; `str(stream)` is *not* the body โ€” there is
127
+ no encoding to guess at.
128
+ - **Reading, writing, seeking and closing a body are awaited.** A body may come off the network,
129
+ so the methods that move bytes โ€” `read`, `write`, `get_contents`, `seek`, `rewind`, `close` โ€”
130
+ are `async`. The ones that only report what the stream already knows โ€” `get_size`, `tell`,
131
+ `eof`, `is_seekable`, `is_readable`, `is_writable`, `get_metadata`, `detach` โ€” are not.
132
+ - **Header names are matched without regard to case** and reported in the case they were first
133
+ given in. A header the message does not carry reads as an empty list and an empty line, never
134
+ as an error. Names and values are checked against the grammar, so no value can inject a
135
+ header of its own.
136
+ - **Nothing you are handed can change the message.** `get_headers()`, `get_header()`,
137
+ `get_query_params()` and the rest hand back a fresh mapping or list every time.
138
+ - **A body is opened only when it is asked for.** A message built with no body opens nothing
139
+ until `get_body()` is called.
140
+
141
+ ### Streams
142
+
143
+ `Stream.create()` takes the payload in any of the three shapes a body arrives in:
144
+
145
+ ```python
146
+ from io import BytesIO
147
+
148
+ from xtr_http_message import Stream
149
+
150
+ Stream.create(b"hello") # bytes, held in memory
151
+ Stream.create(BytesIO(b"hello")) # an open binary file object, taken over
152
+ Stream.create(Stream.create(b"hello")) # already a stream โ€” handed straight back
153
+ ```
154
+
155
+ A stream takes the file object over: closing the stream closes it. It does *not* close it when
156
+ the stream itself is collected, so a file object you still hold is yours to close.
157
+
158
+ `get_metadata()` reports three entries โ€” `mode`, `seekable` and `uri`, the last being the path
159
+ the file was opened on when it has one.
160
+
161
+ ### URIs
162
+
163
+ A URI is a value, read as RFC 3986 defines a URI reference โ€” the splitting is `uritools`' work.
164
+ Components arrive encoded or not, as you have them, and come back encoded exactly once:
165
+
166
+ ```python
167
+ from xtr_http_message import Uri
168
+
169
+ assert str(Uri().with_path("/pa th")) == "/pa%20th"
170
+ assert Uri("/p%61th").get_path() == "/p%61th"
171
+ ```
172
+
173
+ The standard port of the scheme is dropped, since it says nothing the scheme does not:
174
+ `Uri("https://example.com:443").get_port()` is `None`. Change the scheme and the port is weighed
175
+ again.
176
+
177
+ Nothing is trimmed and no character is dropped: a blank, a tab or a control character in a path,
178
+ a query string or a fragment comes back as its per cent escape, so the URI you asked about is the
179
+ URI you get an answer about. What *is* refused โ€” with `InvalidArgumentError` naming the whole
180
+ string, rather than any of it corrected โ€” is a URI whose authority says something that is not an
181
+ authority:
182
+
183
+ - a host that is neither an IPv6 address in two brackets nor a registered name of unreserved
184
+ characters, per cent escapes and sub-delimiters โ€” `http://exa mple.com`, `http://[::1`,
185
+ `http://h%2`, or a name written in another script, which reaches a host in its `xn--` form โ€”
186
+ including a scoped-address zone identifier between the brackets, which that grammar has no room
187
+ for: `http://[fe80::1%eth0]` (RFC 3986 ยง3.2.2);
188
+ - a port outside the sixteen bits a TCP or UDP port is assigned from โ€” `http://h:65536`
189
+ (RFC 3986 ยง3.2.3);
190
+ - a port with no host to hang off, which no authority could be written from โ€” `//:80`,
191
+ `foo://:8080` (RFC 3986 ยง3.2);
192
+ - an `http` or `https` URI naming an authority with no host in it โ€” `http://`, `http://:80/`
193
+ (RFC 9110 ยง4.2.1 and ยง4.2.2).
194
+
195
+ `with_host` reads a host by those same rules โ€” the `Host` header of a request is written straight
196
+ off that component, so nothing a parse would refuse may reach it by the back door either.
197
+
198
+ Everything else is read rather than refused, which is not the same as being a URI reference. A
199
+ scheme opens with a letter, so `0:foo` is a relative reference whose path is `0:foo`; a scheme
200
+ other than `http` or `https` may name an empty authority, so `file:///etc/hosts` has no host and
201
+ the path `/etc/hosts`. A string whose front is no scheme at all is read as a path and comes back
202
+ per cent-encoded, and the result may still be no URI reference: RFC 3986 ยง4.2 leaves a colon out
203
+ of the first segment of a relative-path reference, so `0:0` and `ht tp://h/` come back as paths
204
+ that would read as a scheme to whoever resolved them. Write `./` in front of such a path, or give
205
+ the reference a scheme.
206
+
207
+ ```python
208
+ from xtr_http_message import Uri
209
+
210
+ assert Uri("http://h/a\nb").get_path() == "/a%0Ab"
211
+ assert (Uri("0:foo").get_scheme(), Uri("0:foo").get_path()) == ("", "0:foo")
212
+
213
+ local = Uri("file:///etc/hosts")
214
+ assert (local.get_scheme(), local.get_host(), local.get_path()) == ("file", "", "/etc/hosts")
215
+ ```
216
+
217
+ ### Uploaded files
218
+
219
+ Two kinds arrive. One is already a stream and comes back as that same stream. The other is a
220
+ path on disk, opened only when the file is read โ€” so a request carrying twenty files nobody
221
+ looks at opens none of them. `await move_to()` is what consumes a file: a path is moved, a stream
222
+ is copied a mebibyte at a time, and a second move is refused.
223
+
224
+ ## Use in an application
225
+
226
+ - **Install** โ€” `uv add xtr-http-message`.
227
+ - **Recipe** โ€” none. There is nothing to write into an application: no bundle to list, no
228
+ configuration file, no environment variable, no path to ignore.
229
+ - **Activate** โ€” nothing to activate. These are classes, not a service: build one where you need
230
+ one, or take `HttpFactory` as a dependency and let it build them.
231
+ - **Brings along** โ€” `xtr-http-message-contracts`, `typing-extensions` and `uritools`. No web
232
+ framework, no container, no I/O until a stream is read.
233
+ - **Configure** โ€” nothing to configure.
234
+ - **Environment** โ€” nothing to set.
235
+ - **Ignore** โ€” nothing to ignore.
236
+ - **Remove** โ€” `uv remove xtr-http-message`.
237
+ - **Check** โ€” `python -c "from xtr_http_message import HttpFactory; print(HttpFactory().create_response(404).get_reason_phrase())"`
238
+ prints `Not Found`.
239
+
240
+ Registering these classes on a container, and bridging a message to the web framework the
241
+ application runs on โ€” building one from the call in flight, returning one from a route โ€” belongs
242
+ with that framework's integration. For a FastAPI application that is
243
+ [xtr-http-kernel](../xtr-http-kernel); read its *Use in an application* section for what it
244
+ registers and what it asks of a route.
245
+
246
+ One thing it asks is worth repeating here: **do not build a `Response` or an `HttpFactory` from
247
+ this package in code that is serving a request.** A served response comes from the injected
248
+ `ResponseFactoryInterface`, or is the injected `ResponseInterface`, and goes out as it is โ€” see
249
+ [Responses in a served application](#responses-in-a-served-application).
250
+
251
+ ## Errors
252
+
253
+ | Error | Is also | Raised when |
254
+ | --- | --- | --- |
255
+ | `HttpMessageError` | โ€” | Never raised itself; the one base class the other three share |
256
+ | `InvalidArgumentError` | `ValueError` | An argument cannot be what it is being used as โ€” a malformed header name, an out-of-range port, a status code that is not one, a URI that cannot be read |
257
+ | `StreamError` | `RuntimeError` | A stream cannot do what is being asked of it โ€” detached, not seekable, not readable, a file that will not open |
258
+ | `UploadedFileError` | `RuntimeError` | An uploaded file is unusable โ€” the upload failed, or the file has already been moved |
259
+
260
+ The contract names `ValueError` and `RuntimeError`, so code written against the interfaces
261
+ catches the builtin and never names this package. Code that chose this implementation can catch
262
+ `HttpMessageError` instead and get only its failures.
263
+
264
+ ## Development
265
+
266
+ Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
267
+ `packages/xtr-http-message`; run the commands below from there. The `python-xtr-http-message`
268
+ repository is a read-only copy, so send issues and pull requests to the monorepo.
269
+
270
+ ```sh
271
+ uv sync
272
+ uv run ruff check . && uv run ruff format --check .
273
+ uv run basedpyright
274
+ uv run ty check
275
+ uv run pytest
276
+ ```
277
+
278
+ ## License
279
+
280
+ MIT โ€” see [LICENSE](LICENSE).
@@ -0,0 +1,257 @@
1
+ <div align="center">
2
+
3
+ # xtr-http-message
4
+
5
+ **The HTTP message contract, implemented โ€” six small classes, one factory, and no web framework anywhere in sight.**
6
+
7
+ <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
8
+ <img alt="core dependencies: 3" src="https://img.shields.io/badge/core%20deps-3-3FB950">
9
+ <img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
10
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
11
+
12
+ </div>
13
+
14
+ ---
15
+
16
+ ## Why?
17
+
18
+ A library that handles requests should depend on the contract alone. An application has to pick
19
+ something that actually *is* a request โ€” and that is what this package is: the thirteen
20
+ interfaces of [xtr-http-message-contracts](../xtr-http-message-contracts) made real, and nothing
21
+ besides.
22
+
23
+ - ๐Ÿ“จ **`Request`** โ€” a request going out: a method, a URI, headers and a body, with the `Host`
24
+ header taken from the URI and sent in front of every other header.
25
+ - ๐Ÿ“ฅ **`ServerRequest`** โ€” a request that arrived: the environment parameters, the cookies, the
26
+ query string parsed, the files that came with it, the parsed body, and the attributes a
27
+ handler annotates it with.
28
+ - ๐Ÿ“ค **`Response`** โ€” a status code, the reason phrase recommended for it, headers and a body.
29
+ - ๐ŸŒŠ **`Stream`** โ€” a body, as a cursor over bytes behind an open binary file object.
30
+ - ๐Ÿ”— **`Uri`** โ€” a URI taken apart into components and put back together as text, read as
31
+ RFC 3986 defines one.
32
+ - ๐Ÿ“Ž **`UploadedFile`** โ€” one file that came in, movable once.
33
+ - ๐Ÿญ **`HttpFactory`** โ€” all six factory interfaces behind one object.
34
+
35
+ ```python
36
+ from xtr_http_message import Response, ServerRequest
37
+
38
+ request = ServerRequest("GET", "https://example.com/orders?page=2")
39
+ assert request.get_query_params() == {"page": "2"}
40
+ assert request.get_header_line("host") == "example.com"
41
+
42
+ response = Response(201, {"content-type": "application/json"}, b'{"id": 7}')
43
+ assert response.get_reason_phrase() == "Created"
44
+ ```
45
+
46
+ ## Install
47
+
48
+ ```sh
49
+ uv add xtr-http-message
50
+ ```
51
+
52
+ Requires Python 3.11+. Three runtime dependencies: the contract it implements,
53
+ `typing-extensions` for `@override` on 3.11, and `uritools` to split a URI the way RFC 3986
54
+ defines it.
55
+
56
+ ## Who installs what
57
+
58
+ | | Depends on |
59
+ | --- | --- |
60
+ | **A library that handles requests** | `xtr-http-message-contracts` at runtime, this package as a dev dependency โ€” its tests need real messages. |
61
+ | **An application** | this package, plus the integration that bridges these types to the web framework it runs on. |
62
+
63
+ An application installs it and then mostly does not name it. Code that is **serving a request**
64
+ takes the registered factory interfaces and is handed whatever the integration put behind them;
65
+ the classes here are what code *outside* a served request builds โ€” a test, a worker, a console
66
+ command. The [response rule](#responses-in-a-served-application) below says why that matters
67
+ most for responses.
68
+
69
+ ### Responses in a served application
70
+
71
+ In an application, a response is either the injected `ResponseInterface` or one the injected
72
+ `ResponseFactoryInterface` made, and every response a `with_*` on one of those returns is of
73
+ that same kind: the integration's own, returned from the route as it is, with nothing to
74
+ configure. `Response` here is *not* that โ€” it knows no web framework, so one returned from a
75
+ route is not a response a framework can send. Build it where there is no request being served,
76
+ and have the integration convert a message that arrived from such a place: for a FastAPI
77
+ application that is [xtr-http-kernel](../xtr-http-kernel)'s
78
+ `StarletteHttpFactory.create_response()`.
79
+
80
+ ## The classes
81
+
82
+ | Class | Implements |
83
+ | --- | --- |
84
+ | `Request` | `RequestInterface` |
85
+ | `ServerRequest` | `ServerRequestInterface` |
86
+ | `Response` | `ResponseInterface` |
87
+ | `Stream` | `StreamInterface` |
88
+ | `Uri` | `UriInterface` |
89
+ | `UploadedFile` | `UploadedFileInterface` |
90
+ | `HttpFactory` | all six factory interfaces |
91
+
92
+ Each one names its interface as its last base class, so `isinstance` and a container
93
+ registration both work without a plugin registry in between.
94
+
95
+ ### The rules that hold across all of them
96
+
97
+ - **Messages are immutable.** Every `with_*` method leaves the instance it was called on as it
98
+ was and returns one carrying the change โ€” and hands *the same instance* back when the change
99
+ would change nothing, so a chain of `with_*` calls costs nothing where it does nothing.
100
+ - **A stream is the one thing that is not.** Reading, writing and seeking move its cursor in
101
+ place.
102
+ - **The payload is bytes throughout.** `await stream.get_contents()` rewinds and reads the whole
103
+ of a body, and raises when the body cannot be read; `str(stream)` is *not* the body โ€” there is
104
+ no encoding to guess at.
105
+ - **Reading, writing, seeking and closing a body are awaited.** A body may come off the network,
106
+ so the methods that move bytes โ€” `read`, `write`, `get_contents`, `seek`, `rewind`, `close` โ€”
107
+ are `async`. The ones that only report what the stream already knows โ€” `get_size`, `tell`,
108
+ `eof`, `is_seekable`, `is_readable`, `is_writable`, `get_metadata`, `detach` โ€” are not.
109
+ - **Header names are matched without regard to case** and reported in the case they were first
110
+ given in. A header the message does not carry reads as an empty list and an empty line, never
111
+ as an error. Names and values are checked against the grammar, so no value can inject a
112
+ header of its own.
113
+ - **Nothing you are handed can change the message.** `get_headers()`, `get_header()`,
114
+ `get_query_params()` and the rest hand back a fresh mapping or list every time.
115
+ - **A body is opened only when it is asked for.** A message built with no body opens nothing
116
+ until `get_body()` is called.
117
+
118
+ ### Streams
119
+
120
+ `Stream.create()` takes the payload in any of the three shapes a body arrives in:
121
+
122
+ ```python
123
+ from io import BytesIO
124
+
125
+ from xtr_http_message import Stream
126
+
127
+ Stream.create(b"hello") # bytes, held in memory
128
+ Stream.create(BytesIO(b"hello")) # an open binary file object, taken over
129
+ Stream.create(Stream.create(b"hello")) # already a stream โ€” handed straight back
130
+ ```
131
+
132
+ A stream takes the file object over: closing the stream closes it. It does *not* close it when
133
+ the stream itself is collected, so a file object you still hold is yours to close.
134
+
135
+ `get_metadata()` reports three entries โ€” `mode`, `seekable` and `uri`, the last being the path
136
+ the file was opened on when it has one.
137
+
138
+ ### URIs
139
+
140
+ A URI is a value, read as RFC 3986 defines a URI reference โ€” the splitting is `uritools`' work.
141
+ Components arrive encoded or not, as you have them, and come back encoded exactly once:
142
+
143
+ ```python
144
+ from xtr_http_message import Uri
145
+
146
+ assert str(Uri().with_path("/pa th")) == "/pa%20th"
147
+ assert Uri("/p%61th").get_path() == "/p%61th"
148
+ ```
149
+
150
+ The standard port of the scheme is dropped, since it says nothing the scheme does not:
151
+ `Uri("https://example.com:443").get_port()` is `None`. Change the scheme and the port is weighed
152
+ again.
153
+
154
+ Nothing is trimmed and no character is dropped: a blank, a tab or a control character in a path,
155
+ a query string or a fragment comes back as its per cent escape, so the URI you asked about is the
156
+ URI you get an answer about. What *is* refused โ€” with `InvalidArgumentError` naming the whole
157
+ string, rather than any of it corrected โ€” is a URI whose authority says something that is not an
158
+ authority:
159
+
160
+ - a host that is neither an IPv6 address in two brackets nor a registered name of unreserved
161
+ characters, per cent escapes and sub-delimiters โ€” `http://exa mple.com`, `http://[::1`,
162
+ `http://h%2`, or a name written in another script, which reaches a host in its `xn--` form โ€”
163
+ including a scoped-address zone identifier between the brackets, which that grammar has no room
164
+ for: `http://[fe80::1%eth0]` (RFC 3986 ยง3.2.2);
165
+ - a port outside the sixteen bits a TCP or UDP port is assigned from โ€” `http://h:65536`
166
+ (RFC 3986 ยง3.2.3);
167
+ - a port with no host to hang off, which no authority could be written from โ€” `//:80`,
168
+ `foo://:8080` (RFC 3986 ยง3.2);
169
+ - an `http` or `https` URI naming an authority with no host in it โ€” `http://`, `http://:80/`
170
+ (RFC 9110 ยง4.2.1 and ยง4.2.2).
171
+
172
+ `with_host` reads a host by those same rules โ€” the `Host` header of a request is written straight
173
+ off that component, so nothing a parse would refuse may reach it by the back door either.
174
+
175
+ Everything else is read rather than refused, which is not the same as being a URI reference. A
176
+ scheme opens with a letter, so `0:foo` is a relative reference whose path is `0:foo`; a scheme
177
+ other than `http` or `https` may name an empty authority, so `file:///etc/hosts` has no host and
178
+ the path `/etc/hosts`. A string whose front is no scheme at all is read as a path and comes back
179
+ per cent-encoded, and the result may still be no URI reference: RFC 3986 ยง4.2 leaves a colon out
180
+ of the first segment of a relative-path reference, so `0:0` and `ht tp://h/` come back as paths
181
+ that would read as a scheme to whoever resolved them. Write `./` in front of such a path, or give
182
+ the reference a scheme.
183
+
184
+ ```python
185
+ from xtr_http_message import Uri
186
+
187
+ assert Uri("http://h/a\nb").get_path() == "/a%0Ab"
188
+ assert (Uri("0:foo").get_scheme(), Uri("0:foo").get_path()) == ("", "0:foo")
189
+
190
+ local = Uri("file:///etc/hosts")
191
+ assert (local.get_scheme(), local.get_host(), local.get_path()) == ("file", "", "/etc/hosts")
192
+ ```
193
+
194
+ ### Uploaded files
195
+
196
+ Two kinds arrive. One is already a stream and comes back as that same stream. The other is a
197
+ path on disk, opened only when the file is read โ€” so a request carrying twenty files nobody
198
+ looks at opens none of them. `await move_to()` is what consumes a file: a path is moved, a stream
199
+ is copied a mebibyte at a time, and a second move is refused.
200
+
201
+ ## Use in an application
202
+
203
+ - **Install** โ€” `uv add xtr-http-message`.
204
+ - **Recipe** โ€” none. There is nothing to write into an application: no bundle to list, no
205
+ configuration file, no environment variable, no path to ignore.
206
+ - **Activate** โ€” nothing to activate. These are classes, not a service: build one where you need
207
+ one, or take `HttpFactory` as a dependency and let it build them.
208
+ - **Brings along** โ€” `xtr-http-message-contracts`, `typing-extensions` and `uritools`. No web
209
+ framework, no container, no I/O until a stream is read.
210
+ - **Configure** โ€” nothing to configure.
211
+ - **Environment** โ€” nothing to set.
212
+ - **Ignore** โ€” nothing to ignore.
213
+ - **Remove** โ€” `uv remove xtr-http-message`.
214
+ - **Check** โ€” `python -c "from xtr_http_message import HttpFactory; print(HttpFactory().create_response(404).get_reason_phrase())"`
215
+ prints `Not Found`.
216
+
217
+ Registering these classes on a container, and bridging a message to the web framework the
218
+ application runs on โ€” building one from the call in flight, returning one from a route โ€” belongs
219
+ with that framework's integration. For a FastAPI application that is
220
+ [xtr-http-kernel](../xtr-http-kernel); read its *Use in an application* section for what it
221
+ registers and what it asks of a route.
222
+
223
+ One thing it asks is worth repeating here: **do not build a `Response` or an `HttpFactory` from
224
+ this package in code that is serving a request.** A served response comes from the injected
225
+ `ResponseFactoryInterface`, or is the injected `ResponseInterface`, and goes out as it is โ€” see
226
+ [Responses in a served application](#responses-in-a-served-application).
227
+
228
+ ## Errors
229
+
230
+ | Error | Is also | Raised when |
231
+ | --- | --- | --- |
232
+ | `HttpMessageError` | โ€” | Never raised itself; the one base class the other three share |
233
+ | `InvalidArgumentError` | `ValueError` | An argument cannot be what it is being used as โ€” a malformed header name, an out-of-range port, a status code that is not one, a URI that cannot be read |
234
+ | `StreamError` | `RuntimeError` | A stream cannot do what is being asked of it โ€” detached, not seekable, not readable, a file that will not open |
235
+ | `UploadedFileError` | `RuntimeError` | An uploaded file is unusable โ€” the upload failed, or the file has already been moved |
236
+
237
+ The contract names `ValueError` and `RuntimeError`, so code written against the interfaces
238
+ catches the builtin and never names this package. Code that chose this implementation can catch
239
+ `HttpMessageError` instead and get only its failures.
240
+
241
+ ## Development
242
+
243
+ Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
244
+ `packages/xtr-http-message`; run the commands below from there. The `python-xtr-http-message`
245
+ repository is a read-only copy, so send issues and pull requests to the monorepo.
246
+
247
+ ```sh
248
+ uv sync
249
+ uv run ruff check . && uv run ruff format --check .
250
+ uv run basedpyright
251
+ uv run ty check
252
+ uv run pytest
253
+ ```
254
+
255
+ ## License
256
+
257
+ MIT โ€” see [LICENSE](LICENSE).