xtr-http-message 4.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,296 @@
1
+ ---
2
+ name: xtr-http-message
3
+ description: How to build and change requests, responses, streams, URIs and uploaded files with xtr-http-message. Use when code needs a concrete HTTP message rather than the interface - building a request or response in a test or a client, reading or writing a message body, taking a URI apart or putting one together, handling an uploaded file, or taking the factory that builds any of these. Also use when a message body reads as empty, a with_ method seems not to have changed anything, a header is refused, or a URI will not parse - a host, a port or a missing host that RFC 3986 and RFC 9110 refuse.
4
+ ---
5
+
6
+ # xtr-http-message
7
+
8
+ The HTTP message contract, implemented. Six classes and one factory, no web framework. Code that
9
+ only *handles* messages should depend on `xtr-http-message-contracts` instead; this is what an
10
+ application, a test or a client installs to have something that actually is a message.
11
+
12
+ ## Quick reference
13
+
14
+ - `Request(method, uri, headers=None, body=None, version="1.1")` — the `Host` header comes from
15
+ the URI unless you pass one.
16
+ - `ServerRequest(method, uri, headers=None, body=None, version="1.1", server_params=None)` — the
17
+ query parameters are read off the URI as it is built.
18
+ - `Response(status=200, headers=None, body=None, version="1.1", reason=None)` — `reason=None`
19
+ takes the recommended phrase, `reason=""` leaves the response with none.
20
+ - `Stream.create(bytes | BinaryIO | StreamInterface)` — no `str`: a body is bytes.
21
+ - `Uri(text)` — read as RFC 3986 defines a URI reference;
22
+ `UploadedFile(stream_or_file, size, error, client_filename=None, client_media_type=None)`.
23
+ - `HttpFactory()` is all six factory interfaces at once; it holds no state.
24
+ - **`await stream.get_contents()` is the body. `str(stream)` is not** — it is the object's repr,
25
+ because a body may be binary and there is no encoding to guess at.
26
+ - **A body is awaited.** `read`, `write`, `get_contents`, `seek`, `rewind` and `close` are
27
+ `async`, as is `UploadedFile.move_to`; `get_size`, `tell`, `eof`, `is_seekable`,
28
+ `is_readable`, `is_writable`, `get_metadata` and `detach` only report what the stream already
29
+ knows, and are not.
30
+ - Every `with_*` returns a new message and hands *the same one* back when nothing would change.
31
+ - `headers` maps a name to one value or to a sequence of them: `{"Accept": "text/html"}`,
32
+ `{"X-Tag": ["a", "b"]}`.
33
+
34
+ ## Build a request or a response
35
+
36
+ ```python
37
+ from xtr_http_message import Request, Response
38
+
39
+ request = Request("POST", "https://example.com/orders", {"content-type": "text/plain"}, b"two")
40
+ assert request.get_header_line("host") == "example.com"
41
+ assert request.get_request_target() == "/orders"
42
+
43
+ response = Response(201, {"location": "/orders/7"})
44
+ assert response.get_reason_phrase() == "Created"
45
+ assert response.with_status(404).get_reason_phrase() == "Not Found"
46
+ ```
47
+
48
+ Headers are matched without regard to case and reported in the case they were first given in.
49
+ A header the message does not carry reads as `[]` from `get_header` and `""` from
50
+ `get_header_line` — never an error.
51
+
52
+ ## Read and write a body
53
+
54
+ ```python
55
+ import asyncio
56
+
57
+ from xtr_http_message import Response
58
+
59
+
60
+ async def main() -> None:
61
+ response = Response(200, None, b'{"id": 7}')
62
+
63
+ assert await response.get_body().get_contents() == b'{"id": 7}' # rewinds, then reads all
64
+
65
+ body = response.get_body()
66
+ await body.seek(0)
67
+ assert await body.read(4) == b'{"id'
68
+
69
+
70
+ asyncio.run(main())
71
+ ```
72
+
73
+ A message built with no body opens nothing until `get_body()` is asked for. A body is the one
74
+ thing here that is *not* immutable: writing to `get_body()` changes the message's body in place,
75
+ which is how an injected response is filled in.
76
+
77
+ ```python
78
+ import asyncio
79
+
80
+ from xtr_http_message import Response
81
+
82
+
83
+ async def main() -> None:
84
+ response = Response()
85
+ _ = await response.get_body().write(b"hello")
86
+ assert await response.get_body().get_contents() == b"hello"
87
+
88
+
89
+ asyncio.run(main())
90
+ ```
91
+
92
+ ## Take a URI apart and put one together
93
+
94
+ ```python
95
+ from xtr_http_message import Uri
96
+
97
+ uri = Uri("https://user:pass@example.com:8080/path?q=abc#top")
98
+ assert (uri.get_host(), uri.get_port(), uri.get_query()) == ("example.com", 8080, "q=abc")
99
+
100
+ assert Uri("https://example.com:443").get_port() is None # the scheme already says 443
101
+ assert str(Uri().with_path("/pa th")) == "/pa%20th" # encoded once, never twice
102
+ assert Uri("/p%61th").get_path() == "/p%61th" # already encoded, left alone
103
+ ```
104
+
105
+ The splitting is RFC 3986's, done by `uritools`. Nothing is trimmed and no character is dropped:
106
+ what a path, a query string or a fragment may not carry as it is comes back per cent-encoded.
107
+ An authority that says something which is not an authority raises `InvalidArgumentError` naming
108
+ the whole string: a host that is neither an IPv6 address in two brackets nor a registered name of
109
+ unreserved characters, per cent escapes and sub-delimiters — a zone identifier between those
110
+ brackets, or a name in another script rather than its `xn--` form, is neither (§3.2.2); a port
111
+ outside sixteen bits (§3.2.3); a port with no host to hang off (§3.2); or an `http` or `https` URI
112
+ naming an authority with no host in it (RFC 9110 §4.2.1 and §4.2.2). `with_host` reads a host by
113
+ those same rules, since a request's `Host` header is written straight off it.
114
+
115
+ Anything else is read rather than refused, which is not the same as being a URI reference: a
116
+ string whose front is no scheme is read as a path, and §4.2 leaves a colon out of the first
117
+ segment of a relative-path reference, so `0:0` comes back as a path that would read as a scheme to
118
+ whoever resolved it.
119
+
120
+ ```python
121
+ from xtr_http_message import Uri
122
+ from xtr_http_message.exception import InvalidArgumentError
123
+
124
+ assert Uri("http://h/a\nb").get_path() == "/a%0Ab" # kept, as its escape
125
+ assert Uri("0:foo").get_scheme() == "" # a scheme opens with a letter
126
+ assert Uri("http://u:p@ss@h/").get_user_info() == "u:p%40ss" # encoded, so it reads back
127
+
128
+ local = Uri("file:///etc/hosts") # a scheme other than http(s) may name no host
129
+ assert (local.get_host(), local.get_path()) == ("", "/etc/hosts")
130
+
131
+ refusal = ""
132
+ try:
133
+ _ = Uri("http://exa mple.com")
134
+ except InvalidArgumentError as error:
135
+ refusal = str(error)
136
+
137
+ assert refusal == 'Unable to parse URI: "http://exa mple.com"'
138
+ ```
139
+
140
+ ## Handle an uploaded file
141
+
142
+ ```python
143
+ import asyncio
144
+
145
+ from xtr_http_message import Stream, UploadedFile
146
+ from xtr_http_message_contracts import UploadError
147
+
148
+
149
+ async def main() -> None:
150
+ upload = UploadedFile(Stream.create(b"hello"), 5, UploadError.OK, "greeting.txt", "text/plain")
151
+ assert await upload.get_stream().get_contents() == b"hello"
152
+
153
+ await upload.move_to("greeting.txt") # consumes it; a second move is refused
154
+
155
+
156
+ asyncio.run(main())
157
+ ```
158
+
159
+ Given a path instead of a stream, the file is opened only when it is read, and `move_to` moves it
160
+ rather than copying it. Never trust `get_client_filename()`: a client chose it.
161
+
162
+ ## Take the factory instead of the classes
163
+
164
+ ```python
165
+ from xtr_http_message import HttpFactory
166
+ from xtr_http_message_contracts.factory import ResponseFactoryInterface
167
+
168
+
169
+ def deny(responses: ResponseFactoryInterface) -> None:
170
+ response = responses.create_response(403)
171
+ assert response.get_reason_phrase() == "Forbidden"
172
+
173
+
174
+ deny(HttpFactory())
175
+ ```
176
+
177
+ `create_response(403)` fills in the recommended phrase; `create_response(403, "")` leaves the
178
+ response with no phrase at all. That is the difference between asking for the usual phrase and
179
+ asking for none.
180
+
181
+ ## Where these classes do not belong: serving a request
182
+
183
+ Code that is **serving a request** never builds a response or a factory from this package. In
184
+ an application a response is either the injected `ResponseInterface` or one the injected
185
+ `ResponseFactoryInterface` made, and every response a `with_*` on one of those returns is the
186
+ integration's own — returned from the route as it is, with nothing to configure. `Response`
187
+ here knows no web framework, so one returned from a route is not something a framework can
188
+ send.
189
+
190
+ So: build these classes in a test, a worker or a console command, where no request is being
191
+ served. A message that arrived from such a place is sent by having the integration convert it —
192
+ for a FastAPI application, `xtr-http-kernel`'s `StarletteHttpFactory.create_response()`.
193
+
194
+ ## Testing
195
+
196
+ Build real messages — there is nothing here worth faking. The classes do no I/O until a stream is
197
+ read, and a stream over bytes is held in memory.
198
+
199
+ ```python
200
+ from xtr_http_message import ServerRequest
201
+
202
+
203
+ def test_the_handler_reads_the_page_it_was_asked_for() -> None:
204
+ request = ServerRequest("GET", "/orders?page=2", {"accept": "application/json"})
205
+
206
+ assert request.get_query_params() == {"page": "2"}
207
+ ```
208
+
209
+ Use `tmp_path` for anything that touches disk, and close a stream your test opened from a path:
210
+ `Stream` closes the file object it was given when you close the stream, but not when the stream is
211
+ merely dropped.
212
+
213
+ A test that reads or writes a body is an async test — `pytestmark = pytest.mark.anyio` with an
214
+ `anyio_backend` fixture, the way this package's own suite runs.
215
+
216
+ ```python
217
+ from pathlib import Path
218
+
219
+ import pytest
220
+
221
+ from xtr_http_message import HttpFactory
222
+
223
+ pytestmark = pytest.mark.anyio
224
+
225
+
226
+ async def test_a_body_can_be_read_from_a_file(tmp_path: Path) -> None:
227
+ path = tmp_path / "body.txt"
228
+ _ = path.write_bytes(b"hello")
229
+
230
+ stream = HttpFactory().create_stream_from_file(str(path))
231
+ try:
232
+ assert await stream.get_contents() == b"hello"
233
+ finally:
234
+ await stream.close()
235
+ ```
236
+
237
+ ## Use in an application
238
+
239
+ 1. **Install** — `uv add xtr-http-message`.
240
+ 2. **Recipe** — there is none, and nothing for one to do: no bundle to list, no configuration
241
+ file, no environment variable, no path to ignore.
242
+ 3. **Activate** — nothing to activate. Build a class where you need one, or take `HttpFactory`
243
+ as a dependency.
244
+ 4. **Brings along** — `xtr-http-message-contracts`, `typing-extensions` and `uritools`. No web
245
+ framework, no container.
246
+ 5. **Configure** — nothing to configure.
247
+ 6. **Environment** — nothing to set.
248
+ 7. **Ignore** — nothing to ignore.
249
+ 8. **Remove** — `uv remove xtr-http-message`.
250
+ 9. **Check** — `python -c "from xtr_http_message import HttpFactory; print(HttpFactory().create_response(404).get_reason_phrase())"`
251
+ prints `Not Found`.
252
+
253
+ Registering these classes on a container, and bridging a message to the web framework the
254
+ application runs on, belongs with that framework's integration — for a FastAPI application,
255
+ `xtr-http-kernel`. Read that package's *Use in an application* section for what it registers —
256
+ and the response rule above for what it asks you not to build.
257
+
258
+ ## Errors
259
+
260
+ | Error | Is also | Raised when |
261
+ | --- | --- | --- |
262
+ | `HttpMessageError` | — | Never raised itself; the base the other three share |
263
+ | `InvalidArgumentError` | `ValueError` | A malformed header name or value, an out-of-range port, a status code outside 100-599, a URI that cannot be read, a parsed body that is a scalar |
264
+ | `StreamError` | `RuntimeError` | A detached, unseekable or unreadable stream; a file that will not open |
265
+ | `UploadedFileError` | `RuntimeError` | An upload that did not go through, or a file already moved |
266
+
267
+ Code written against the interfaces catches `ValueError` or `RuntimeError`; code that chose this
268
+ implementation can catch `HttpMessageError` and get only its failures.
269
+
270
+ ## Do not
271
+
272
+ - Do not call `str(stream)` for a body — it is the object's repr. Use
273
+ `await stream.get_contents()`.
274
+ - Do not pass a `str` to `Stream.create`; encode it first. A body is bytes, and guessing an
275
+ encoding is how a binary body gets corrupted.
276
+ - Do not expect a `with_*` method to change the message it was called on: it returns a new one,
277
+ and returns the same one when nothing would change. `request.with_header(...)` on its own does
278
+ nothing — keep the result.
279
+ - Do not change a mapping or list one of the getters handed you and expect the message to follow:
280
+ every one of them hands back a fresh copy.
281
+ - Do not import these classes in a library that only handles messages — depend on
282
+ `xtr-http-message-contracts` and take the interface, so the application picks the
283
+ implementation.
284
+ - Do not build a `Response` or an `HttpFactory` in code that is serving a request — a route, a
285
+ listener, a per-request service. Take the registered `ResponseFactoryInterface`, or the
286
+ injected `ResponseInterface`, and return what it gives you.
287
+ - Do not leave a body call unawaited: `response.get_body().write(b"...")` on its own writes
288
+ nothing, it builds a coroutine. Await it.
289
+ - Do not read a body with `await stream.read(n)` and wonder why it starts where it does: `read`
290
+ reads from the cursor onwards, while `get_contents()` rewinds a seekable stream first and
291
+ raises when the stream cannot be read at all.
292
+ - Do not call `move_to` twice on an uploaded file, or read it afterwards: moving consumes it,
293
+ and it is awaited — `await upload.move_to(path)`.
294
+ - Do not expect a URI to be cleaned up for you: a blank, a tab or a control character is encoded
295
+ rather than stripped, and a host carrying one is refused rather than patched — by `Uri(...)` and
296
+ by `with_host` alike. Hand `Uri` a string a sender wrote, and catch `InvalidArgumentError`.
@@ -0,0 +1,85 @@
1
+ """The HTTP message contract, implemented: six classes and the factory that builds them.
2
+
3
+ A library that handles requests should depend on the contract alone. An
4
+ application has to pick something that *is* a request, and this is that
5
+ something: small, strict, and tied to no web framework.
6
+
7
+ What is here is one class per interface, and nothing besides:
8
+
9
+ - :class:`~xtr_http_message.request.Request` — a request going out: a method,
10
+ a URI, headers and a body, with the ``Host`` header taken from the URI.
11
+ - :class:`~xtr_http_message.server_request.ServerRequest` — a request that
12
+ arrived, with the environment parameters, the cookies, the query string
13
+ parsed, the files that came with it, the parsed body and the attributes a
14
+ handler annotates it with.
15
+ - :class:`~xtr_http_message.response.Response` — a status code, the reason
16
+ phrase recommended for it, headers and a body.
17
+ - :class:`~xtr_http_message.stream.Stream` — a body, as a cursor over bytes
18
+ behind an open binary file object.
19
+ - :class:`~xtr_http_message.uri.Uri` — a URI taken apart into components and
20
+ put back together as text.
21
+ - :class:`~xtr_http_message.uploaded_file.UploadedFile` — one file that came
22
+ in with a request, movable once.
23
+ - :class:`~xtr_http_message.factory.http_factory.HttpFactory` — all six
24
+ factory interfaces behind one object.
25
+
26
+ ```python
27
+ from xtr_http_message import Response, ServerRequest
28
+
29
+ request = ServerRequest("GET", "https://example.com/orders?page=2")
30
+ assert request.get_query_params() == {"page": "2"}
31
+
32
+ response = Response(201, {"content-type": "application/json"}, b'{"id": 7}')
33
+ assert response.get_reason_phrase() == "Created"
34
+ ```
35
+
36
+ Every message is immutable: a ``with_*`` method leaves the instance it was
37
+ called on alone and returns one carrying the change — and hands the same
38
+ instance back when the change would change nothing. A stream is the one thing
39
+ that is not: reading, writing and seeking move its cursor in place. Its
40
+ payload is bytes throughout, so ``await stream.get_contents()`` is how the
41
+ whole of a body is read; there is no encoding to guess at.
42
+
43
+ Bridging a message to the web framework an application runs on belongs with
44
+ that framework's integration, not here.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ from importlib.metadata import PackageNotFoundError, version
50
+
51
+ from .exception import (
52
+ HttpMessageError,
53
+ InvalidArgumentError,
54
+ StreamError,
55
+ UploadedFileError,
56
+ )
57
+ from .factory import HttpFactory
58
+ from .request import Request
59
+ from .response import Response
60
+ from .server_request import ServerRequest
61
+ from .stream import Stream
62
+ from .uploaded_file import UploadedFile
63
+ from .uri import Uri
64
+
65
+ try:
66
+ __version__ = version("xtr-http-message")
67
+ except PackageNotFoundError: # pragma: no cover
68
+ # Running from a source tree or a vendored copy, with no installed
69
+ # metadata to read. Having no version is better than refusing to import.
70
+ __version__ = "0+unknown"
71
+
72
+ __all__ = [
73
+ "HttpFactory",
74
+ "HttpMessageError",
75
+ "InvalidArgumentError",
76
+ "Request",
77
+ "Response",
78
+ "ServerRequest",
79
+ "Stream",
80
+ "StreamError",
81
+ "UploadedFile",
82
+ "UploadedFileError",
83
+ "Uri",
84
+ "__version__",
85
+ ]
@@ -0,0 +1,26 @@
1
+ """Lowercasing that stops where the grammars this package reads stop: at ASCII."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Final
6
+
7
+ __all__ = ["lower"]
8
+
9
+ # `str.lower` is the wrong tool twice over. It recases letters outside ASCII,
10
+ # and a non-ASCII host is not ours to touch; and it folds characters such as
11
+ # the Kelvin sign onto an ASCII letter, which would make a header name match
12
+ # one it does not equal.
13
+ _LOWERCASE: Final = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")
14
+
15
+
16
+ def lower(text: str) -> str:
17
+ """Return ``text`` with its ASCII letters lowercased and every other character as it was.
18
+
19
+ Args:
20
+ text: The text to lowercase.
21
+
22
+ Returns:
23
+ The text, with ``A``-``Z`` mapped onto ``a``-``z`` and nothing else
24
+ touched.
25
+ """
26
+ return text.translate(_LOWERCASE)