xtr-http-message 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 (27) hide show
  1. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/PKG-INFO +68 -5
  2. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/README.md +66 -3
  3. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/pyproject.toml +2 -2
  4. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/pyproject.toml.orig +2 -2
  5. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/src/xtr_http_message/.agents/skills/xtr-http-message/SKILL.md +53 -4
  6. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/src/xtr_http_message/__init__.py +10 -1
  7. xtr_http_message-5.0.0/src/xtr_http_message/_connected_trait.py +115 -0
  8. xtr_http_message-5.0.0/src/xtr_http_message/lazy_stream.py +157 -0
  9. xtr_http_message-5.0.0/src/xtr_http_message/request_source.py +71 -0
  10. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/src/xtr_http_message/server_request.py +145 -7
  11. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/LICENSE +0 -0
  12. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/src/xtr_http_message/_ascii.py +0 -0
  13. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/src/xtr_http_message/_message_trait.py +0 -0
  14. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/src/xtr_http_message/_request_trait.py +0 -0
  15. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/src/xtr_http_message/exception/__init__.py +0 -0
  16. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/src/xtr_http_message/exception/http_message_error.py +0 -0
  17. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/src/xtr_http_message/exception/invalid_argument_error.py +0 -0
  18. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/src/xtr_http_message/exception/stream_error.py +0 -0
  19. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/src/xtr_http_message/exception/uploaded_file_error.py +0 -0
  20. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/src/xtr_http_message/factory/__init__.py +0 -0
  21. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/src/xtr_http_message/factory/http_factory.py +0 -0
  22. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/src/xtr_http_message/py.typed +0 -0
  23. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/src/xtr_http_message/request.py +0 -0
  24. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/src/xtr_http_message/response.py +0 -0
  25. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/src/xtr_http_message/stream.py +0 -0
  26. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/src/xtr_http_message/uploaded_file.py +0 -0
  27. {xtr_http_message-4.0.4 → xtr_http_message-5.0.0}/src/xtr_http_message/uri.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xtr-http-message
3
- Version: 4.0.4
3
+ Version: 5.0.0
4
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
5
  Keywords: http,http-message,request,response,stream,uri,uploaded-file
6
6
  Author: Xterr
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: Programming Language :: Python :: 3.14
16
16
  Classifier: Topic :: Internet :: WWW/HTTP
17
17
  Classifier: Typing :: Typed
18
- Requires-Dist: xtr-http-message-contracts>=4.0,<5
18
+ Requires-Dist: xtr-http-message-contracts>=5.0,<6
19
19
  Requires-Dist: typing-extensions>=4.4
20
20
  Requires-Dist: uritools>=6.1,<7
21
21
  Requires-Python: >=3.11
@@ -25,7 +25,7 @@ Description-Content-Type: text/markdown
25
25
 
26
26
  # xtr-http-message
27
27
 
28
- **The HTTP message contract, implemented — six small classes, one factory, and no web framework anywhere in sight.**
28
+ **The HTTP message contract, implemented — six small classes, one factory, a request that can be born connected, and no web framework anywhere in sight.**
29
29
 
30
30
  <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
31
31
  <img alt="core dependencies: 3" src="https://img.shields.io/badge/core%20deps-3-3FB950">
@@ -47,9 +47,12 @@ besides.
47
47
  header taken from the URI and sent in front of every other header.
48
48
  - 📥 **`ServerRequest`** — a request that arrived: the environment parameters, the cookies, the
49
49
  query string parsed, the files that came with it, the parsed body, and the attributes a
50
- handler annotates it with.
50
+ handler annotates it with. Born complete — or born connected over a `RequestSource`, its body
51
+ side arriving as it is read.
51
52
  - 📤 **`Response`** — a status code, the reason phrase recommended for it, headers and a body.
52
53
  - 🌊 **`Stream`** — a body, as a cursor over bytes behind an open binary file object.
54
+ - 🌊 **`LazyStream`** — a body that stands in unread: the whole of it is read once, on the first
55
+ read, and buffered after.
53
56
  - 🔗 **`Uri`** — a URI taken apart into components and put back together as text, read as
54
57
  RFC 3986 defines one.
55
58
  - 📎 **`UploadedFile`** — one file that came in, movable once.
@@ -108,12 +111,15 @@ application that is [xtr-http-kernel](../xtr-http-kernel)'s
108
111
  | `ServerRequest` | `ServerRequestInterface` |
109
112
  | `Response` | `ResponseInterface` |
110
113
  | `Stream` | `StreamInterface` |
114
+ | `LazyStream` | `StreamInterface` |
111
115
  | `Uri` | `UriInterface` |
112
116
  | `UploadedFile` | `UploadedFileInterface` |
113
117
  | `HttpFactory` | all six factory interfaces |
114
118
 
115
119
  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.
120
+ registration both work without a plugin registry in between. `RequestSource` is the one
121
+ protocol among them: not a message, but where a connected request reads its late answers
122
+ from.
117
123
 
118
124
  ### The rules that hold across all of them
119
125
 
@@ -158,6 +164,63 @@ the stream itself is collected, so a file object you still hold is yours to clos
158
164
  `get_metadata()` reports three entries — `mode`, `seekable` and `uri`, the last being the path
159
165
  the file was opened on when it has one.
160
166
 
167
+ `LazyStream` is a stream that stands in for a body nobody has read: built with an async
168
+ callable that returns the bytes, it holds nothing until the first `read` or `get_contents`
169
+ calls it, then buffers what came back and serves every later read out of the buffer —
170
+ seekable, rewindable, and closing it before the first read is a no-op rather than a read.
171
+
172
+ ### A request born connected
173
+
174
+ A request that arrived is not always known in full when it becomes a message: a connection
175
+ hands over its head at once, but its body is still on the wire. `ServerRequest.over()` builds
176
+ the one class both ways — born complete through the constructor, born connected through a
177
+ `RequestSource`:
178
+
179
+ ```python
180
+ from collections.abc import Mapping
181
+
182
+ from xtr_http_message import RequestSource, ServerRequest
183
+
184
+
185
+ class Connection(RequestSource):
186
+ """Reads the body off the wire, parses it, answers the late side."""
187
+
188
+ async def materialise(self) -> bytes: ... # runs at most once, memoised
189
+
190
+ def parsed_body(self) -> object | None: ... # a sync peek; None until materialised
191
+ def uploaded_files(self) -> Mapping[str, object]: ... # a sync peek; {} until then
192
+ def attributes(self) -> Mapping[str, object]: ... # live; routing fills it late
193
+
194
+
195
+ served = ServerRequest.over(
196
+ "POST",
197
+ "https://example.com/orders",
198
+ source=Connection(),
199
+ headers={"host": "example.com"},
200
+ cookies={"session": "abc"},
201
+ query={"page": "2"},
202
+ )
203
+ ```
204
+
205
+ The head is passed eagerly — it costs no input and never changes mid-request — and the body
206
+ side comes from the source:
207
+
208
+ - **The body is one lazy stream** (`LazyStream` over `source.materialise`), handed out however
209
+ many times it is asked for; the first read materialises the whole body once, and every later
210
+ read is served out of the buffer.
211
+ - **The parsed body and the uploaded files answer what materialisation has yielded so far** —
212
+ `None` and an empty mapping until the body has been read. `await served.ensure_parsed()`
213
+ materialises it at the one seam that can still await, before the message is handed on.
214
+ - **The attributes are live**, read off the source each time: the named parameters a matched
215
+ route fills in are there as soon as routing has filled them.
216
+ - **The `with_*` members detach.** The copy is a plain eager request — the attributes frozen to
217
+ the current snapshot, the parsed body and files to the current peek, the body the one shared
218
+ lazy stream — so a change a handler makes never reaches the connection, and the copy never
219
+ reads the source again.
220
+
221
+ `body=` is refused alongside `source=` — one owner of the body. A header the message grammar
222
+ refuses is left out rather than refusing the request: a connection let it through.
223
+
161
224
  ### URIs
162
225
 
163
226
  A URI is a value, read as RFC 3986 defines a URI reference — the splitting is `uritools`' work.
@@ -2,7 +2,7 @@
2
2
 
3
3
  # xtr-http-message
4
4
 
5
- **The HTTP message contract, implemented — six small classes, one factory, and no web framework anywhere in sight.**
5
+ **The HTTP message contract, implemented — six small classes, one factory, a request that can be born connected, and no web framework anywhere in sight.**
6
6
 
7
7
  <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
8
8
  <img alt="core dependencies: 3" src="https://img.shields.io/badge/core%20deps-3-3FB950">
@@ -24,9 +24,12 @@ besides.
24
24
  header taken from the URI and sent in front of every other header.
25
25
  - 📥 **`ServerRequest`** — a request that arrived: the environment parameters, the cookies, the
26
26
  query string parsed, the files that came with it, the parsed body, and the attributes a
27
- handler annotates it with.
27
+ handler annotates it with. Born complete — or born connected over a `RequestSource`, its body
28
+ side arriving as it is read.
28
29
  - 📤 **`Response`** — a status code, the reason phrase recommended for it, headers and a body.
29
30
  - 🌊 **`Stream`** — a body, as a cursor over bytes behind an open binary file object.
31
+ - 🌊 **`LazyStream`** — a body that stands in unread: the whole of it is read once, on the first
32
+ read, and buffered after.
30
33
  - 🔗 **`Uri`** — a URI taken apart into components and put back together as text, read as
31
34
  RFC 3986 defines one.
32
35
  - 📎 **`UploadedFile`** — one file that came in, movable once.
@@ -85,12 +88,15 @@ application that is [xtr-http-kernel](../xtr-http-kernel)'s
85
88
  | `ServerRequest` | `ServerRequestInterface` |
86
89
  | `Response` | `ResponseInterface` |
87
90
  | `Stream` | `StreamInterface` |
91
+ | `LazyStream` | `StreamInterface` |
88
92
  | `Uri` | `UriInterface` |
89
93
  | `UploadedFile` | `UploadedFileInterface` |
90
94
  | `HttpFactory` | all six factory interfaces |
91
95
 
92
96
  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.
97
+ registration both work without a plugin registry in between. `RequestSource` is the one
98
+ protocol among them: not a message, but where a connected request reads its late answers
99
+ from.
94
100
 
95
101
  ### The rules that hold across all of them
96
102
 
@@ -135,6 +141,63 @@ the stream itself is collected, so a file object you still hold is yours to clos
135
141
  `get_metadata()` reports three entries — `mode`, `seekable` and `uri`, the last being the path
136
142
  the file was opened on when it has one.
137
143
 
144
+ `LazyStream` is a stream that stands in for a body nobody has read: built with an async
145
+ callable that returns the bytes, it holds nothing until the first `read` or `get_contents`
146
+ calls it, then buffers what came back and serves every later read out of the buffer —
147
+ seekable, rewindable, and closing it before the first read is a no-op rather than a read.
148
+
149
+ ### A request born connected
150
+
151
+ A request that arrived is not always known in full when it becomes a message: a connection
152
+ hands over its head at once, but its body is still on the wire. `ServerRequest.over()` builds
153
+ the one class both ways — born complete through the constructor, born connected through a
154
+ `RequestSource`:
155
+
156
+ ```python
157
+ from collections.abc import Mapping
158
+
159
+ from xtr_http_message import RequestSource, ServerRequest
160
+
161
+
162
+ class Connection(RequestSource):
163
+ """Reads the body off the wire, parses it, answers the late side."""
164
+
165
+ async def materialise(self) -> bytes: ... # runs at most once, memoised
166
+
167
+ def parsed_body(self) -> object | None: ... # a sync peek; None until materialised
168
+ def uploaded_files(self) -> Mapping[str, object]: ... # a sync peek; {} until then
169
+ def attributes(self) -> Mapping[str, object]: ... # live; routing fills it late
170
+
171
+
172
+ served = ServerRequest.over(
173
+ "POST",
174
+ "https://example.com/orders",
175
+ source=Connection(),
176
+ headers={"host": "example.com"},
177
+ cookies={"session": "abc"},
178
+ query={"page": "2"},
179
+ )
180
+ ```
181
+
182
+ The head is passed eagerly — it costs no input and never changes mid-request — and the body
183
+ side comes from the source:
184
+
185
+ - **The body is one lazy stream** (`LazyStream` over `source.materialise`), handed out however
186
+ many times it is asked for; the first read materialises the whole body once, and every later
187
+ read is served out of the buffer.
188
+ - **The parsed body and the uploaded files answer what materialisation has yielded so far** —
189
+ `None` and an empty mapping until the body has been read. `await served.ensure_parsed()`
190
+ materialises it at the one seam that can still await, before the message is handed on.
191
+ - **The attributes are live**, read off the source each time: the named parameters a matched
192
+ route fills in are there as soon as routing has filled them.
193
+ - **The `with_*` members detach.** The copy is a plain eager request — the attributes frozen to
194
+ the current snapshot, the parsed body and files to the current peek, the body the one shared
195
+ lazy stream — so a change a handler makes never reaches the connection, and the copy never
196
+ reads the source again.
197
+
198
+ `body=` is refused alongside `source=` — one owner of the body. A header the message grammar
199
+ refuses is left out rather than refusing the request: a connection let it through.
200
+
138
201
  ### URIs
139
202
 
140
203
  A URI is a value, read as RFC 3986 defines a URI reference — the splitting is `uritools`' work.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xtr-http-message"
3
- version = "4.0.4"
3
+ version = "5.0.0"
4
4
  description = "A strict, framework-free implementation of the HTTP message contract: requests, responses, streams, URIs, uploaded files and one factory for all six."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -26,7 +26,7 @@ classifiers = [
26
26
  "Typing :: Typed",
27
27
  ]
28
28
  dependencies = [
29
- "xtr-http-message-contracts>=4.0,<5",
29
+ "xtr-http-message-contracts>=5.0,<6",
30
30
  "typing-extensions>=4.4",
31
31
  "uritools>=6.1,<7",
32
32
  ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xtr-http-message"
3
- version = "4.0.4"
3
+ version = "5.0.0"
4
4
  description = "A strict, framework-free implementation of the HTTP message contract: requests, responses, streams, URIs, uploaded files and one factory for all six."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -26,7 +26,7 @@ classifiers = [
26
26
  # ours sees the string). Everything else the six classes need is in the
27
27
  # standard library.
28
28
  dependencies = [
29
- "xtr-http-message-contracts>=4.0,<5",
29
+ "xtr-http-message-contracts>=5.0,<6",
30
30
  "typing-extensions>=4.4",
31
31
  "uritools>=6.1,<7",
32
32
  ]
@@ -1,13 +1,14 @@
1
1
  ---
2
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.
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, building a request over a connection whose body arrives late, 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
4
  ---
5
5
 
6
6
  # xtr-http-message
7
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.
8
+ The HTTP message contract, implemented. Six classes, one factory, a request that can be born
9
+ connected, no web framework. Code that only *handles* messages should depend on
10
+ `xtr-http-message-contracts` instead; this is what an application, a test or a client installs
11
+ to have something that actually is a message.
11
12
 
12
13
  ## Quick reference
13
14
 
@@ -15,9 +16,13 @@ application, a test or a client installs to have something that actually is a me
15
16
  the URI unless you pass one.
16
17
  - `ServerRequest(method, uri, headers=None, body=None, version="1.1", server_params=None)` — the
17
18
  query parameters are read off the URI as it is built.
19
+ - `ServerRequest.over(method, uri, *, source, ...)` — the same class, born connected over a
20
+ `RequestSource`: the head passed eagerly, the body side answered late.
18
21
  - `Response(status=200, headers=None, body=None, version="1.1", reason=None)` — `reason=None`
19
22
  takes the recommended phrase, `reason=""` leaves the response with none.
20
23
  - `Stream.create(bytes | BinaryIO | StreamInterface)` — no `str`: a body is bytes.
24
+ - `LazyStream(materialise)` — a body that stands in unread: read once on the first read,
25
+ buffered after.
21
26
  - `Uri(text)` — read as RFC 3986 defines a URI reference;
22
27
  `UploadedFile(stream_or_file, size, error, client_filename=None, client_media_type=None)`.
23
28
  - `HttpFactory()` is all six factory interfaces at once; it holds no state.
@@ -89,6 +94,47 @@ async def main() -> None:
89
94
  asyncio.run(main())
90
95
  ```
91
96
 
97
+ ## Build a request born connected
98
+
99
+ A request that arrived is not always known in full when it becomes a message — a connection
100
+ hands over its head at once, but its body is still on the wire. `ServerRequest.over()` builds
101
+ the one class both ways, over a `RequestSource`:
102
+
103
+ ```python
104
+ from typing import Mapping
105
+
106
+ from xtr_http_message import RequestSource, ServerRequest
107
+
108
+
109
+ class Connection(RequestSource):
110
+ async def materialise(self) -> bytes:
111
+ """Read the whole body once, memoised; fill the parsed side as a side effect."""
112
+
113
+ def parsed_body(self) -> object | None: ... # a sync peek; None until materialised
114
+ def uploaded_files(self) -> Mapping[str, object]: ... # a sync peek; {} until then
115
+ def attributes(self) -> Mapping[str, object]: ... # live; routing fills it late
116
+
117
+
118
+ served = ServerRequest.over(
119
+ "POST",
120
+ "https://example.com/orders",
121
+ source=Connection(),
122
+ headers={"host": "example.com"},
123
+ cookies={"session": "abc"},
124
+ query={"page": "2"},
125
+ )
126
+ ```
127
+
128
+ - The head is passed eagerly; the body is one `LazyStream` over `source.materialise`, and the
129
+ first read materialises the whole body once.
130
+ - `get_parsed_body()` and `get_uploaded_files()` answer what materialisation has yielded so
131
+ far; `await served.ensure_parsed()` materialises it at the one seam that can still await.
132
+ - `get_attributes()` reads the source live, each time.
133
+ - The `with_*` members detach: plain eager copies — attributes frozen to the snapshot, parsed
134
+ body and files to the peek, body the one shared lazy stream — that never read the source
135
+ again.
136
+ - `body=` is refused alongside `source=`: one owner of the body.
137
+
92
138
  ## Take a URI apart and put one together
93
139
 
94
140
  ```python
@@ -276,6 +322,9 @@ implementation can catch `HttpMessageError` and get only its failures.
276
322
  - Do not expect a `with_*` method to change the message it was called on: it returns a new one,
277
323
  and returns the same one when nothing would change. `request.with_header(...)` on its own does
278
324
  nothing — keep the result.
325
+ - Do not expect `get_parsed_body()` or `get_uploaded_files()` on a connected request to answer
326
+ before its body has been read: they answer what materialisation has yielded, and
327
+ `await served.ensure_parsed()` is the seam that materialises it.
279
328
  - Do not change a mapping or list one of the getters handed you and expect the message to follow:
280
329
  every one of them hands back a fresh copy.
281
330
  - Do not import these classes in a library that only handles messages — depend on
@@ -11,11 +11,16 @@ What is here is one class per interface, and nothing besides:
11
11
  - :class:`~xtr_http_message.server_request.ServerRequest` — a request that
12
12
  arrived, with the environment parameters, the cookies, the query string
13
13
  parsed, the files that came with it, the parsed body and the attributes a
14
- handler annotates it with.
14
+ handler annotates it with. Born complete, or born connected over a
15
+ :class:`~xtr_http_message.request_source.RequestSource` — a connection whose
16
+ body side arrives as it is read.
15
17
  - :class:`~xtr_http_message.response.Response` — a status code, the reason
16
18
  phrase recommended for it, headers and a body.
17
19
  - :class:`~xtr_http_message.stream.Stream` — a body, as a cursor over bytes
18
20
  behind an open binary file object.
21
+ - :class:`~xtr_http_message.lazy_stream.LazyStream` — a body that stands in
22
+ unread: the whole of it is read once, on the first read, and buffered
23
+ after.
19
24
  - :class:`~xtr_http_message.uri.Uri` — a URI taken apart into components and
20
25
  put back together as text.
21
26
  - :class:`~xtr_http_message.uploaded_file.UploadedFile` — one file that came
@@ -55,7 +60,9 @@ from .exception import (
55
60
  UploadedFileError,
56
61
  )
57
62
  from .factory import HttpFactory
63
+ from .lazy_stream import LazyStream
58
64
  from .request import Request
65
+ from .request_source import RequestSource
59
66
  from .response import Response
60
67
  from .server_request import ServerRequest
61
68
  from .stream import Stream
@@ -73,7 +80,9 @@ __all__ = [
73
80
  "HttpFactory",
74
81
  "HttpMessageError",
75
82
  "InvalidArgumentError",
83
+ "LazyStream",
76
84
  "Request",
85
+ "RequestSource",
77
86
  "Response",
78
87
  "ServerRequest",
79
88
  "Stream",
@@ -0,0 +1,115 @@
1
+ """The connected side of a server request: born over a source, read through it.
2
+
3
+ A request that arrived is not always known in full when it becomes a
4
+ message — a connection hands over its head at once, but its body is
5
+ still on the wire. This is the plumbing that lets the one server request
6
+ class be born that way, connected to a source that answers the body side
7
+ late, while the eager path the class has always had stays exactly as it
8
+ was. A mixin, for the same reason the message's own traits are: this is
9
+ one part of what a server request answers, and the class composing it
10
+ carries the rest.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TYPE_CHECKING, ClassVar, Self
16
+
17
+ from typing_extensions import override
18
+
19
+ from ._request_trait import RequestTrait
20
+ from .lazy_stream import LazyStream
21
+
22
+ if TYPE_CHECKING:
23
+ from xtr_http_message_contracts import StreamInterface, UriInterface
24
+
25
+ from .request_source import RequestSource
26
+
27
+ __all__ = ["ConnectedTrait"]
28
+
29
+
30
+ class ConnectedTrait(RequestTrait):
31
+ """The body side of a server request that arrives late, over a source.
32
+
33
+ The ``with_*`` members a connected request answers detach: the copy
34
+ is a plain eager request — the live parts frozen to what they answer
35
+ at that moment — so a change a listener makes never reaches the
36
+ connection. Because the copy is the same class, the ``Self`` the
37
+ ``with_*`` members promise is finally the whole truth.
38
+ """
39
+
40
+ __slots__: ClassVar[tuple[str, ...]] = ("_source",)
41
+
42
+ def __init__(self) -> None:
43
+ """Start connected to nothing; :meth:`ServerRequest.over` connects."""
44
+ super().__init__()
45
+ self._source: RequestSource | None = None
46
+
47
+ @override
48
+ def get_body(self) -> StreamInterface:
49
+ """Return the body of the message.
50
+
51
+ A connected request's body is one lazy stream over its source,
52
+ handed out however many times the body is asked for: the first
53
+ read materialises the whole body once, and every later read is
54
+ served out of the buffer.
55
+
56
+ Returns:
57
+ The body, as a stream.
58
+ """
59
+ if self._source is not None and self._stream is None:
60
+ self._stream: StreamInterface | None = LazyStream(self._source.materialise)
61
+
62
+ return super().get_body()
63
+
64
+ async def ensure_parsed(self) -> None:
65
+ """Materialise the body side, when connected and nothing has read it yet.
66
+
67
+ The seam that can still await calls this before the message is
68
+ handed on: a consumer written against the contract reads
69
+ :meth:`~xtr_http_message.server_request.ServerRequest.get_parsed_body`
70
+ without reading the stream first, and only a body that has been
71
+ read can answer it. A no-op for an eager request, and for one
72
+ whose body has already been read.
73
+ """
74
+ if self._source is None:
75
+ return
76
+
77
+ body = self.get_body()
78
+ if isinstance(body, LazyStream) and not body.materialised:
79
+ _ = await body.get_contents()
80
+
81
+ @override
82
+ def with_protocol_version(self, version: str) -> Self:
83
+ """Return an instance carrying ``version``; a connected one detaches first."""
84
+ if self._source is not None:
85
+ return self._copy().with_protocol_version(version)
86
+
87
+ return super().with_protocol_version(version)
88
+
89
+ @override
90
+ def without_header(self, name: str) -> Self:
91
+ """Return an instance with the ``name`` header gone; a connected one detaches first."""
92
+ if self._source is not None:
93
+ return self._copy().without_header(name)
94
+
95
+ return super().without_header(name)
96
+
97
+ @override
98
+ def with_body(self, body: StreamInterface) -> Self:
99
+ """Return an instance reading its body from ``body``; a connected one detaches first."""
100
+ if self._source is not None:
101
+ return self._copy().with_body(body)
102
+
103
+ return super().with_body(body)
104
+
105
+ @override
106
+ def with_uri(self, uri: UriInterface, preserve_host: bool = False) -> Self:
107
+ """Return an instance for ``uri``; a connected one detaches first.
108
+
109
+ The copy is a plain eager request, so the URI it carries is a
110
+ value, not a view — and a ``Host`` header is kept where asked.
111
+ """
112
+ if self._source is not None:
113
+ return self._copy().with_uri(uri, preserve_host)
114
+
115
+ return super().with_uri(uri, preserve_host)
@@ -0,0 +1,157 @@
1
+ """A body stream that reads its bytes once, on its first read.
2
+
3
+ A request built over a connection must hand out a stream, but nobody
4
+ has read that connection's body until something asks to — reading it
5
+ eagerly is exactly what a lazy request may not do. This stream stands
6
+ in for the body until the first read, then materialises the whole of it
7
+ once and serves every later read out of the buffer: seekable and
8
+ rewindable, so a reader that inspects can rewind for whoever reads
9
+ after it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from typing import TYPE_CHECKING, final
16
+
17
+ from typing_extensions import override
18
+ from xtr_http_message_contracts import StreamInterface
19
+
20
+ from .stream import Stream
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Awaitable, Callable
24
+ from typing import BinaryIO
25
+
26
+ __all__ = ["LazyStream"]
27
+
28
+
29
+ @final
30
+ class LazyStream(StreamInterface):
31
+ """A body stream that reads its bytes once, on its first read.
32
+
33
+ Built with a coroutine that returns the buffered body — one that
34
+ reads the connection and parses it per its content type — and holds
35
+ nothing until the first :meth:`read` or :meth:`get_contents` calls
36
+ it. From then on it is a plain view over the buffer, and every
37
+ question the contract asks of an unread stream is answered for the
38
+ buffer it will be.
39
+ """
40
+
41
+ __slots__ = ("_buffered", "_materialise")
42
+
43
+ def __init__(self, materialise: Callable[[], Awaitable[bytes]]) -> None:
44
+ """Read the body through ``materialise``, when anything first asks to.
45
+
46
+ Args:
47
+ materialise: Reads the whole body and returns its bytes.
48
+ """
49
+ self._materialise = materialise
50
+ self._buffered: StreamInterface | None = None
51
+
52
+ @property
53
+ def materialised(self) -> bool:
54
+ """Tell whether the body has been read into memory."""
55
+ return self._buffered is not None
56
+
57
+ async def _held(self) -> StreamInterface:
58
+ """Return the buffered body, reading it off the connection if nothing has yet."""
59
+ if self._buffered is None:
60
+ self._buffered = Stream.create(await self._materialise())
61
+ return self._buffered
62
+
63
+ @override
64
+ async def close(self) -> None:
65
+ """Close the buffer, if there is one to close.
66
+
67
+ An unread body holds nothing open, so closing one before the
68
+ first read is not a read: it is a no-op, and the next reader
69
+ still gets the bytes off the connection.
70
+ """
71
+ if self._buffered is not None:
72
+ await self._buffered.close()
73
+
74
+ @override
75
+ def detach(self) -> BinaryIO | None:
76
+ """Hand out the buffer's file object, or ``None`` while there is none."""
77
+ return self._buffered.detach() if self._buffered is not None else None
78
+
79
+ @override
80
+ def get_size(self) -> int | None:
81
+ """Return the size of the buffered body, or ``None`` before there is one."""
82
+ return self._buffered.get_size() if self._buffered is not None else None
83
+
84
+ @override
85
+ def tell(self) -> int:
86
+ """Return where the cursor sits: the first byte, until there is a buffer."""
87
+ return self._buffered.tell() if self._buffered is not None else 0
88
+
89
+ @override
90
+ def eof(self) -> bool:
91
+ """Tell whether a read reached the end: no read has, until there is a buffer."""
92
+ return self._buffered.eof() if self._buffered is not None else False
93
+
94
+ @override
95
+ def is_seekable(self) -> bool:
96
+ """Tell whether the cursor moves: the buffer it will be is over bytes in memory."""
97
+ return True
98
+
99
+ @override
100
+ async def seek(self, offset: int, whence: int = os.SEEK_SET) -> None:
101
+ """Move the cursor, materialising the body if nothing has read it yet.
102
+
103
+ Raises:
104
+ StreamError: When the buffer cannot be moved, once there is one.
105
+ """
106
+ await (await self._held()).seek(offset, whence)
107
+
108
+ @override
109
+ async def rewind(self) -> None:
110
+ """Move the cursor back to the first byte, materialising if nothing has read."""
111
+ await (await self._held()).rewind()
112
+
113
+ @override
114
+ def is_writable(self) -> bool:
115
+ """Tell whether bytes can be written to the buffer: it is a buffer, so yes."""
116
+ return True
117
+
118
+ @override
119
+ async def write(self, data: bytes) -> int:
120
+ """Write at the cursor, materialising the body if nothing has read it yet.
121
+
122
+ Raises:
123
+ StreamError: When the buffer cannot be written to.
124
+ """
125
+ return await (await self._held()).write(data)
126
+
127
+ @override
128
+ def is_readable(self) -> bool:
129
+ """Tell whether bytes can be read: they can, once anything has been read."""
130
+ return True
131
+
132
+ @override
133
+ async def read(self, length: int) -> bytes:
134
+ """Read up to ``length`` bytes, materialising the body if nothing has read it yet.
135
+
136
+ Raises:
137
+ StreamError: When the buffer cannot be read.
138
+ """
139
+ return await (await self._held()).read(length)
140
+
141
+ @override
142
+ async def get_contents(self) -> bytes:
143
+ """Return every byte of the body, materialising it if nothing has read it yet.
144
+
145
+ Raises:
146
+ StreamError: When the buffer cannot be read.
147
+ """
148
+ return await (await self._held()).get_contents()
149
+
150
+ @override
151
+ def get_metadata(self, key: str | None = None) -> object:
152
+ """Return what the buffer knows about itself, or nothing while there is none."""
153
+ return (
154
+ self._buffered.get_metadata(key)
155
+ if self._buffered is not None
156
+ else ({} if key is None else None)
157
+ )
@@ -0,0 +1,71 @@
1
+ """Where a connected request reads its late answers from.
2
+
3
+ A request built over a connection is born before anything has read its
4
+ body: the head is already on the connection, but the body, the parsed
5
+ form and the uploaded files arrive only as the body is read. This is
6
+ the seam between the two moments — framework-free, so the message
7
+ package stays tied to no web framework, and small enough that a
8
+ connection adapter is all an integration needs to write.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Mapping
17
+
18
+ __all__ = ["RequestSource"]
19
+
20
+
21
+ @runtime_checkable
22
+ class RequestSource(Protocol):
23
+ """Where a connected request reads its late answers from.
24
+
25
+ A request built over a connection materialises its body side
26
+ lazily: the sync getters answer what materialisation has yielded so
27
+ far, and the body stream reads the rest through here on its first
28
+ read. The head — method, URI, headers, cookies, query, environment —
29
+ never comes from here: it is passed eagerly when the request is
30
+ built, because it costs no input and never changes mid-request.
31
+ """
32
+
33
+ async def materialise(self) -> bytes:
34
+ """Read the whole body once, and answer its bytes.
35
+
36
+ Runs at most once — the implementation memoises — filling the
37
+ parsed side and the uploaded files as a side effect, so the sync
38
+ peeks answer from then on. The bytes answered are the raw body
39
+ bytes: for a submitted form, the raw form bytes, with the form
40
+ itself landing in the parsed side.
41
+
42
+ Returns:
43
+ The raw bytes of the body.
44
+ """
45
+ ...
46
+
47
+ def parsed_body(self) -> object | None:
48
+ """Return what parsing the body yielded, or ``None`` until it has.
49
+
50
+ A sync peek at what :meth:`materialise` has yielded so far: the
51
+ form for form content types, a JSON object for JSON, ``None``
52
+ before any body read.
53
+ """
54
+ ...
55
+
56
+ def uploaded_files(self) -> Mapping[str, object]:
57
+ """Return the files that came with the body, empty until it is read.
58
+
59
+ A sync peek, as :meth:`parsed_body` is one: the files are
60
+ gathered when the body is materialised, so before any body read
61
+ this answers an empty mapping.
62
+ """
63
+ ...
64
+
65
+ def attributes(self) -> Mapping[str, object]:
66
+ """Return what has been derived from the request while handling it.
67
+
68
+ A live view: routing fills the named path parameters after the
69
+ request exists, and each read answers them as they are now.
70
+ """
71
+ ...
@@ -9,7 +9,7 @@ from urllib.parse import parse_qsl
9
9
  from typing_extensions import override
10
10
  from xtr_http_message_contracts import ServerRequestInterface
11
11
 
12
- from ._request_trait import RequestTrait
12
+ from ._connected_trait import ConnectedTrait
13
13
  from .exception import InvalidArgumentError
14
14
  from .uri import Uri
15
15
 
@@ -19,6 +19,8 @@ if TYPE_CHECKING:
19
19
 
20
20
  from xtr_http_message_contracts import StreamInterface, UriInterface
21
21
 
22
+ from .request_source import RequestSource
23
+
22
24
  __all__ = ["ServerRequest"]
23
25
 
24
26
  _HOST = "Host"
@@ -61,7 +63,7 @@ def _parse_query(query: str) -> dict[str, object]:
61
63
 
62
64
 
63
65
  @final
64
- class ServerRequest(RequestTrait, ServerRequestInterface):
66
+ class ServerRequest(ConnectedTrait, ServerRequestInterface):
65
67
  """An incoming, server-side request.
66
68
 
67
69
  On top of what every request carries, this one gathers what reached the
@@ -88,6 +90,14 @@ class ServerRequest(RequestTrait, ServerRequestInterface):
88
90
  mapping of a kind that answers more than a single value per name reaches
89
91
  whoever reads it back as that kind, rather than flattened into a plain
90
92
  one.
93
+
94
+ A request is also born connected, over a
95
+ :class:`~xtr_http_message.request_source.RequestSource` — see
96
+ :meth:`over`. Its head is passed eagerly; its body is one lazy stream
97
+ that materialises on first read; its parsed body, uploaded files and
98
+ attributes answer what the source has yielded so far, the attributes
99
+ live. The ``with_*`` members of such a request detach: plain eager
100
+ copies, so a change a handler makes never reaches the connection.
91
101
  """
92
102
 
93
103
  __slots__ = (
@@ -146,6 +156,77 @@ class ServerRequest(RequestTrait, ServerRequestInterface):
146
156
 
147
157
  self._set_body(body)
148
158
 
159
+ @classmethod
160
+ def over(
161
+ cls,
162
+ method: str,
163
+ uri: UriInterface | str,
164
+ *,
165
+ source: RequestSource,
166
+ body: bytes | BinaryIO | StreamInterface | None = None,
167
+ headers: Mapping[str, str | Sequence[str]] | None = None,
168
+ server_params: Mapping[str, object] | None = None,
169
+ cookies: Mapping[str, str] | None = None,
170
+ query: Mapping[str, object] | None = None,
171
+ protocol_version: str = "1.1",
172
+ ) -> Self:
173
+ """Build a server request over ``source``, born connected to it.
174
+
175
+ The head is passed eagerly — it costs no input and never changes
176
+ mid-request — and the body side comes from the source: the body
177
+ is one lazy stream that materialises on its first read, and the
178
+ parsed body and the uploaded files answer what materialisation
179
+ has yielded so far.
180
+
181
+ Args:
182
+ method: The HTTP method, kept exactly as given.
183
+ uri: The URI of the request. Given as text, it is read into a
184
+ :class:`~xtr_http_message.uri.Uri`.
185
+ source: Where the connected request reads its late answers
186
+ from.
187
+ body: Refused — the body of a request built over a source is
188
+ the source's to materialise, and there is one owner of
189
+ the body. The parameter exists so a caller used to the
190
+ constructor's shape is told the rule rather than left
191
+ with an unexpected keyword.
192
+ headers: Each header name mapped to one value or to several.
193
+ A header the message grammar refuses is left out rather
194
+ than refusing the request: a connection let it through,
195
+ and a message is no place to re-litigate that.
196
+ server_params: The parameters of the environment the request
197
+ arrived in, taken exactly as given.
198
+ cookies: The cookies the client sent; left out, none.
199
+ query: The query string, parsed into parameters; left out,
200
+ the URI's own query stands.
201
+ protocol_version: The HTTP version number alone.
202
+
203
+ Returns:
204
+ The request, connected to ``source``.
205
+
206
+ Raises:
207
+ InvalidArgumentError: When ``body`` is given alongside
208
+ ``source``.
209
+ """
210
+ if body is not None:
211
+ raise InvalidArgumentError(
212
+ "One owner of the body: body= and source= cannot both be given"
213
+ )
214
+
215
+ request = cls(method, uri, None, None, protocol_version, server_params)
216
+ for name, values in (headers or {}).items():
217
+ try:
218
+ request = request.with_header(name, values)
219
+ except ValueError:
220
+ continue
221
+
222
+ if cookies is not None:
223
+ request = request.with_cookie_params(cookies)
224
+ if query is not None:
225
+ request = request.with_query_params(query)
226
+
227
+ request._source = source # noqa: SLF001 — a fresh instance of this very class.
228
+ return request
229
+
149
230
  @override
150
231
  def get_server_params(self) -> Mapping[str, object]:
151
232
  """Return the parameters of the environment the request arrived in.
@@ -225,8 +306,13 @@ class ServerRequest(RequestTrait, ServerRequestInterface):
225
306
  A mapping shaped like the names the files were submitted under,
226
307
  every leaf of it an uploaded file — the mapping as it was given
227
308
  where nothing can change it, otherwise a fresh one. Empty when no
228
- file came with the request.
309
+ file came with the request. A request built over a connection
310
+ answers what its body's materialisation has gathered so far:
311
+ empty until the body has been read.
229
312
  """
313
+ if self._source is not None:
314
+ return self._source.uploaded_files()
315
+
230
316
  return _held(self._uploaded_files)
231
317
 
232
318
  @override
@@ -251,8 +337,13 @@ class ServerRequest(RequestTrait, ServerRequestInterface):
251
337
 
252
338
  Returns:
253
339
  Whatever parsing the body yielded, or ``None`` when nothing has
254
- been parsed into the request.
340
+ been parsed into the request. A request built over a connection
341
+ answers what its body's materialisation has yielded so far:
342
+ ``None`` until the body has been read.
255
343
  """
344
+ if self._source is not None:
345
+ return self._source.parsed_body()
346
+
256
347
  return self._parsed_body
257
348
 
258
349
  @override
@@ -287,8 +378,14 @@ class ServerRequest(RequestTrait, ServerRequestInterface):
287
378
  """Return what was derived from the request while handling it.
288
379
 
289
380
  Returns:
290
- A fresh mapping of each attribute name to its value.
381
+ A fresh mapping of each attribute name to its value. A request
382
+ built over a connection reads them live, off its source: the
383
+ named parameters the matched route fills in are there as soon
384
+ as routing has filled them — which is after the request exists.
291
385
  """
386
+ if self._source is not None:
387
+ return self._source.attributes()
388
+
292
389
  return dict(self._attributes)
293
390
 
294
391
  @override
@@ -307,6 +404,9 @@ class ServerRequest(RequestTrait, ServerRequestInterface):
307
404
  Returns:
308
405
  The value under ``name``, or ``default``.
309
406
  """
407
+ if self._source is not None:
408
+ return self._source.attributes().get(name, default)
409
+
310
410
  if name not in self._attributes:
311
411
  return default
312
412
 
@@ -324,7 +424,9 @@ class ServerRequest(RequestTrait, ServerRequestInterface):
324
424
  A new instance; this one is left as it was.
325
425
  """
326
426
  new = self._copy()
327
- new._attributes = {**self._attributes, name: value} # noqa: SLF001 — a copy of this class.
427
+ # Read the copy's bag, not this instance's: a connected request's own
428
+ # is empty, its attributes living on the source the copy detached from.
429
+ new._attributes = {**new._attributes, name: value} # noqa: SLF001 — a copy of this class.
328
430
 
329
431
  return new
330
432
 
@@ -337,8 +439,19 @@ class ServerRequest(RequestTrait, ServerRequestInterface):
337
439
 
338
440
  Returns:
339
441
  A new instance, or this one when the request carries no such
340
- attribute.
442
+ attribute. A connected request always answers a copy: its
443
+ attributes are live, so the name is looked for in what they
444
+ answer now, and the copy detaches either way.
341
445
  """
446
+ source = self._source
447
+ if source is not None:
448
+ new = self._copy()
449
+ new._attributes = { # noqa: SLF001 — a copy of this very class.
450
+ held: value for held, value in source.attributes().items() if held != name
451
+ }
452
+
453
+ return new
454
+
342
455
  if name not in self._attributes:
343
456
  return self
344
457
 
@@ -348,3 +461,28 @@ class ServerRequest(RequestTrait, ServerRequestInterface):
348
461
  }
349
462
 
350
463
  return new
464
+
465
+ @override
466
+ def _copy(self) -> Self:
467
+ """Return a copy to change, leaving this instance as it is.
468
+
469
+ A connected request's copy detaches: it is a plain eager request,
470
+ carrying the head as it was passed and the live parts frozen to
471
+ what they answer at this moment — the attributes as a snapshot,
472
+ the parsed body and the uploaded files as the source's peeks. The
473
+ body is the one lazy stream, shared: one materialisation serves
474
+ the request and every copy of it, and the copy never reads the
475
+ source again.
476
+ """
477
+ source = self._source
478
+ if source is None:
479
+ return super()._copy()
480
+
481
+ new = super()._copy()
482
+ new._stream = self.get_body() # noqa: SLF001 — a copy of this very class.
483
+ new._attributes = dict(source.attributes()) # noqa: SLF001 — a copy of this class.
484
+ new._parsed_body = source.parsed_body() # noqa: SLF001 — a copy of this class.
485
+ new._uploaded_files = source.uploaded_files() # noqa: SLF001 — a copy of this class.
486
+ new._source = None # noqa: SLF001 — a copy of this very class.
487
+
488
+ return new