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.
- xtr_http_message/.agents/skills/xtr-http-message/SKILL.md +296 -0
- xtr_http_message/__init__.py +85 -0
- xtr_http_message/_ascii.py +26 -0
- xtr_http_message/_message_trait.py +354 -0
- xtr_http_message/_request_trait.py +164 -0
- xtr_http_message/exception/__init__.py +24 -0
- xtr_http_message/exception/http_message_error.py +14 -0
- xtr_http_message/exception/invalid_argument_error.py +17 -0
- xtr_http_message/exception/stream_error.py +17 -0
- xtr_http_message/exception/uploaded_file_error.py +17 -0
- xtr_http_message/factory/__init__.py +20 -0
- xtr_http_message/factory/http_factory.py +235 -0
- xtr_http_message/py.typed +0 -0
- xtr_http_message/request.py +74 -0
- xtr_http_message/response.py +196 -0
- xtr_http_message/server_request.py +350 -0
- xtr_http_message/stream.py +404 -0
- xtr_http_message/uploaded_file.py +270 -0
- xtr_http_message/uri.py +660 -0
- xtr_http_message-4.0.2.dist-info/METADATA +280 -0
- xtr_http_message-4.0.2.dist-info/RECORD +23 -0
- xtr_http_message-4.0.2.dist-info/WHEEL +4 -0
- xtr_http_message-4.0.2.dist-info/licenses/LICENSE +23 -0
|
@@ -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)
|