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,354 @@
1
+ """What a request and a response have in common, in one place."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import re
7
+ from collections.abc import Sequence
8
+ from typing import TYPE_CHECKING, ClassVar, Self
9
+
10
+ from ._ascii import lower
11
+ from .exception import InvalidArgumentError
12
+ from .stream import Stream
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Mapping
16
+ from typing import BinaryIO
17
+
18
+ from xtr_http_message_contracts import StreamInterface
19
+
20
+ __all__ = ["MessageTrait"]
21
+
22
+ _DEFAULT_PROTOCOL = "1.1"
23
+
24
+ # A header name is one or more token characters and nothing else, so that no
25
+ # name can carry the delimiters that would let it inject a header of its own.
26
+ _HEADER_NAME = re.compile(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+")
27
+ # A header value is visible characters and inner blanks. The upper half of the
28
+ # range is written as the bytes it is, so a character above U+00FF — which no
29
+ # header field can carry undeclared — is refused rather than quietly sent.
30
+ _HEADER_VALUE = re.compile("[ \t\x21-\x7e\x80-\xff]*")
31
+ # The blanks around a header value belong to the field, not to the value.
32
+ _OPTIONAL_WHITESPACE = " \t"
33
+
34
+ _BAD_NAME = "Header name must be an RFC 7230 compatible string"
35
+ _BAD_VALUE = "Header values must be RFC 7230 compatible strings"
36
+ _NO_VALUE = "Header values must be a string or an array of strings, empty array given"
37
+
38
+
39
+ def _trimmed_value(value: object) -> str:
40
+ """Return a header value with its surrounding blanks taken off.
41
+
42
+ Args:
43
+ value: What is being offered as a value.
44
+
45
+ Returns:
46
+ The value, trimmed.
47
+
48
+ Raises:
49
+ InvalidArgumentError: When it is not a header value — not a string at
50
+ all, or a string carrying what no header field can.
51
+ """
52
+ if not isinstance(value, str) or _HEADER_VALUE.fullmatch(value) is None:
53
+ raise InvalidArgumentError(_BAD_VALUE)
54
+
55
+ return value.strip(_OPTIONAL_WHITESPACE)
56
+
57
+
58
+ def _validate_and_trim(name: str, value: object) -> list[str]:
59
+ """Check a header against the grammar and return its values, trimmed.
60
+
61
+ Args:
62
+ name: The header name.
63
+ value: One value, or every value the header is to hold. Taken as
64
+ anything at all, so that a caller whose types went unchecked is
65
+ told what was wrong with what it passed rather than left with a
66
+ type error from deeper in.
67
+
68
+ Returns:
69
+ The values, each with its surrounding blanks taken off.
70
+
71
+ Raises:
72
+ InvalidArgumentError: When the name is not a header name, a value is
73
+ not a header value, or a header is given no value at all.
74
+ """
75
+ if _HEADER_NAME.fullmatch(name) is None:
76
+ raise InvalidArgumentError(_BAD_NAME)
77
+
78
+ if isinstance(value, str):
79
+ return [_trimmed_value(value)]
80
+
81
+ if not isinstance(value, Sequence):
82
+ raise InvalidArgumentError(_BAD_VALUE)
83
+
84
+ offered: Sequence[object] = value
85
+ if len(offered) == 0:
86
+ raise InvalidArgumentError(_NO_VALUE)
87
+
88
+ return [_trimmed_value(one) for one in offered]
89
+
90
+
91
+ class MessageTrait:
92
+ """The protocol version, the headers and the body of any message.
93
+
94
+ A mixin rather than a class of its own: nothing here is an HTTP message by
95
+ itself, and the two things that are — a request and a response — differ in
96
+ everything else. A subclass sets the state up in its own ``__init__``
97
+ through :meth:`_set_headers`, then inherits every reader and every
98
+ ``with_*`` method from here.
99
+
100
+ Names are recorded in the case they were first given in and matched
101
+ without regard to case, which is what the two mappings are for: one holds
102
+ the headers under the name they will go out as, the other maps a
103
+ lowercased name to that one.
104
+ """
105
+
106
+ __slots__: ClassVar[tuple[str, ...]] = ("_header_names", "_headers", "_protocol", "_stream")
107
+
108
+ def __init__(self) -> None:
109
+ """Start with no header, the usual protocol version and no body yet."""
110
+ self._headers: dict[str, list[str]] = {}
111
+ self._header_names: dict[str, str] = {}
112
+ self._protocol: str = _DEFAULT_PROTOCOL
113
+ self._stream: StreamInterface | None = None
114
+
115
+ def get_protocol_version(self) -> str:
116
+ """Return the HTTP version of the message.
117
+
118
+ Returns:
119
+ The version number alone, such as ``"1.1"``.
120
+ """
121
+ return self._protocol
122
+
123
+ def with_protocol_version(self, version: str) -> Self:
124
+ """Return an instance carrying ``version``.
125
+
126
+ Args:
127
+ version: The HTTP version number alone, such as ``"1.1"``.
128
+
129
+ Returns:
130
+ A new instance, or this one when the version is already that.
131
+ """
132
+ if self._protocol == version:
133
+ return self
134
+
135
+ new = self._copy()
136
+ new._protocol = version # noqa: SLF001 — a copy of this very class.
137
+
138
+ return new
139
+
140
+ def get_headers(self) -> Mapping[str, Sequence[str]]:
141
+ """Return every header, each name mapped to all of its values.
142
+
143
+ Returns:
144
+ A fresh mapping of each header name, in the case it will go out
145
+ as, to the values recorded under it. Changing what comes back
146
+ cannot change the message.
147
+ """
148
+ return {name: list(values) for name, values in self._headers.items()}
149
+
150
+ def has_header(self, name: str) -> bool:
151
+ """Tell whether a header is present, matching ``name`` without case.
152
+
153
+ Args:
154
+ name: The header name, in any case.
155
+
156
+ Returns:
157
+ ``True`` when some header name matches ``name`` ignoring case.
158
+ """
159
+ return lower(name) in self._header_names
160
+
161
+ def get_header(self, name: str) -> Sequence[str]:
162
+ """Return every value recorded under a header, matched without case.
163
+
164
+ Args:
165
+ name: The header name, in any case.
166
+
167
+ Returns:
168
+ The values under that header, in order; an empty list for a header
169
+ the message does not carry.
170
+ """
171
+ recorded = self._header_names.get(lower(name))
172
+ if recorded is None:
173
+ return []
174
+
175
+ return list(self._headers[recorded])
176
+
177
+ def get_header_line(self, name: str) -> str:
178
+ """Return the values of a header joined with commas, matched without case.
179
+
180
+ Args:
181
+ name: The header name, in any case.
182
+
183
+ Returns:
184
+ The values joined with ``", "``; an empty string for a header the
185
+ message does not carry.
186
+ """
187
+ return ", ".join(self.get_header(name))
188
+
189
+ def with_header(self, name: str, value: str | Sequence[str]) -> Self:
190
+ """Return an instance whose ``name`` header holds ``value`` and nothing else.
191
+
192
+ Args:
193
+ name: The header name, in any case. Its case is what the new
194
+ instance will report and send, even when it replaces a header
195
+ recorded under another.
196
+ value: One value, or every value the header is to hold.
197
+
198
+ Returns:
199
+ A new instance; this one is left as it was.
200
+
201
+ Raises:
202
+ InvalidArgumentError: When the name or a value is not one.
203
+ """
204
+ values = _validate_and_trim(name, value)
205
+ normalized = lower(name)
206
+
207
+ new = self._copy()
208
+ replaced = new._header_names.get(normalized) # noqa: SLF001 — a copy of this very class.
209
+ if replaced is not None:
210
+ del new._headers[replaced] # noqa: SLF001 — a copy of this very class.
211
+ new._header_names[normalized] = name # noqa: SLF001 — a copy of this very class.
212
+ new._headers[name] = values # noqa: SLF001 — a copy of this very class.
213
+
214
+ return new
215
+
216
+ def with_added_header(self, name: str, value: str | Sequence[str]) -> Self:
217
+ """Return an instance with ``value`` appended to the ``name`` header.
218
+
219
+ Args:
220
+ name: The header name, in any case. A header already recorded
221
+ under another case keeps the case it has.
222
+ value: One value, or every value to append.
223
+
224
+ Returns:
225
+ A new instance; this one is left as it was.
226
+
227
+ Raises:
228
+ InvalidArgumentError: When the name or a value is not one.
229
+ """
230
+ new = self._copy()
231
+ new._set_headers({name: value}) # noqa: SLF001 — a copy of this very class.
232
+
233
+ return new
234
+
235
+ def without_header(self, name: str) -> Self:
236
+ """Return an instance with the ``name`` header gone.
237
+
238
+ Args:
239
+ name: The header name, in any case.
240
+
241
+ Returns:
242
+ A new instance, or this one when the message does not carry that
243
+ header.
244
+ """
245
+ normalized = lower(name)
246
+ recorded = self._header_names.get(normalized)
247
+ if recorded is None:
248
+ return self
249
+
250
+ new = self._copy()
251
+ del new._headers[recorded] # noqa: SLF001 — a copy of this very class.
252
+ del new._header_names[normalized] # noqa: SLF001 — a copy of this very class.
253
+
254
+ return new
255
+
256
+ def get_body(self) -> StreamInterface:
257
+ """Return the body of the message.
258
+
259
+ A message built with no body has none until it is asked for: the empty
260
+ stream is opened here, the first time, rather than for every message
261
+ that never reads one.
262
+
263
+ Returns:
264
+ The body, as a stream.
265
+ """
266
+ if self._stream is None:
267
+ self._stream = Stream.create()
268
+
269
+ return self._stream
270
+
271
+ def with_body(self, body: StreamInterface) -> Self:
272
+ """Return an instance carrying ``body``.
273
+
274
+ Args:
275
+ body: The stream the new instance reads its body from.
276
+
277
+ Returns:
278
+ A new instance, or this one when it already reads that very
279
+ stream.
280
+ """
281
+ if body is self._stream:
282
+ return self
283
+
284
+ new = self._copy()
285
+ new._stream = body # noqa: SLF001 — a copy of this very class.
286
+
287
+ return new
288
+
289
+ def _copy(self) -> Self:
290
+ """Return a copy to change, leaving this instance as it is.
291
+
292
+ The two header mappings are copied rather than shared, so that a
293
+ change made to the copy cannot reach back into this message. The body
294
+ is shared: a stream is the one thing here that is not a value.
295
+ """
296
+ new = copy.copy(self)
297
+ new._headers = { # noqa: SLF001 — a copy of this very class.
298
+ name: list(values) for name, values in self._headers.items()
299
+ }
300
+ new._header_names = dict(self._header_names) # noqa: SLF001 — a copy of this very class.
301
+
302
+ return new
303
+
304
+ def _set_headers(self, headers: Mapping[str, str | Sequence[str]]) -> None:
305
+ """Record headers on this instance, appending to any already there.
306
+
307
+ Only for a constructor and for :meth:`with_added_header`: it changes
308
+ the instance it is called on, which is either brand new or a copy
309
+ nobody else holds yet.
310
+
311
+ Args:
312
+ headers: Each header name mapped to one value or to several.
313
+
314
+ Raises:
315
+ InvalidArgumentError: When a name or a value is not one.
316
+ """
317
+ for name, value in headers.items():
318
+ values = _validate_and_trim(name, value)
319
+ normalized = lower(name)
320
+ recorded = self._header_names.get(normalized)
321
+ if recorded is None:
322
+ self._header_names[normalized] = name
323
+ self._headers[name] = values
324
+ else:
325
+ self._headers[recorded].extend(values)
326
+
327
+ def _set_body(self, body: bytes | BinaryIO | StreamInterface | None) -> None:
328
+ """Record the body of a message being built, leaving an empty one unopened.
329
+
330
+ Only for a constructor. Nothing is opened for a message with no body:
331
+ :meth:`get_body` sees to that if it is ever asked for.
332
+
333
+ Args:
334
+ body: The payload, in any of the shapes a stream is built from, or
335
+ ``None``.
336
+ """
337
+ if body is not None and body != b"":
338
+ self._stream = Stream.create(body)
339
+
340
+ def _put_header_first(self, name: str, values: list[str]) -> None:
341
+ """Record a header in front of every other, dropping where it was.
342
+
343
+ Only for a constructor and for the ``with_*`` methods of a request: it
344
+ changes the instance it is called on. A request sends its ``Host``
345
+ header first, which is the one thing the order of these mappings is
346
+ for.
347
+
348
+ Args:
349
+ name: The header name, already in the case it will go out as.
350
+ values: What the header is to hold.
351
+ """
352
+ rest = [(other, held) for other, held in self._headers.items() if other != name]
353
+ self._headers = {name: values}
354
+ self._headers.update(rest)
@@ -0,0 +1,164 @@
1
+ """What an outgoing request and an incoming one have in common, in one place."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import TYPE_CHECKING, ClassVar, Self
7
+
8
+ from ._message_trait import MessageTrait
9
+ from .exception import InvalidArgumentError
10
+ from .uri import Uri
11
+
12
+ if TYPE_CHECKING:
13
+ from xtr_http_message_contracts import UriInterface
14
+
15
+ __all__ = ["RequestTrait"]
16
+
17
+ _WHITESPACE = re.compile(r"\s")
18
+ _HOST = "Host"
19
+ _NORMALIZED_HOST = "host"
20
+
21
+
22
+ class RequestTrait(MessageTrait):
23
+ """The method, the request-target and the URI of any request.
24
+
25
+ A mixin, for the same reason
26
+ :class:`~xtr_http_message._message_trait.MessageTrait` is one: a request
27
+ going out and a request that arrived carry all of this and differ in what
28
+ else they carry. It extends that one, since every request is a message.
29
+
30
+ The ``Host`` header follows the URI unless a caller asks for it not to,
31
+ and is sent in front of every other header.
32
+ """
33
+
34
+ __slots__: ClassVar[tuple[str, ...]] = ("_method", "_request_target", "_uri")
35
+
36
+ def __init__(self) -> None:
37
+ """Start with no method, no request-target set by hand and no URI."""
38
+ super().__init__()
39
+ self._method: str = ""
40
+ self._request_target: str | None = None
41
+ self._uri: UriInterface = Uri()
42
+
43
+ def get_request_target(self) -> str:
44
+ """Return the request-target of the message.
45
+
46
+ Built from the URI — the path with the query string after it — unless
47
+ :meth:`with_request_target` set one by hand, in which case that is
48
+ returned exactly as it was given.
49
+
50
+ Returns:
51
+ The request-target, which is ``"/"`` when the URI has no path to
52
+ build one from.
53
+ """
54
+ if self._request_target is not None:
55
+ return self._request_target
56
+
57
+ target = self._uri.get_path()
58
+ if target == "":
59
+ target = "/"
60
+
61
+ query = self._uri.get_query()
62
+ if query != "":
63
+ target = f"{target}?{query}"
64
+
65
+ return target
66
+
67
+ def with_request_target(self, request_target: str) -> Self:
68
+ """Return an instance carrying ``request_target`` exactly as given.
69
+
70
+ Args:
71
+ request_target: The request-target, taken verbatim. Use it where
72
+ the path-and-query form will not do: an absolute URI, an
73
+ authority alone, or the asterisk.
74
+
75
+ Returns:
76
+ A new instance; this one is left as it was.
77
+
78
+ Raises:
79
+ InvalidArgumentError: When the request-target carries whitespace,
80
+ which would split the request line in two.
81
+ """
82
+ if _WHITESPACE.search(request_target) is not None:
83
+ raise InvalidArgumentError("Invalid request target provided; cannot contain whitespace")
84
+
85
+ new = self._copy()
86
+ new._request_target = request_target # noqa: SLF001 — a copy of this very class.
87
+
88
+ return new
89
+
90
+ def get_method(self) -> str:
91
+ """Return the HTTP method of the request.
92
+
93
+ Returns:
94
+ The method, in the case it was given in.
95
+ """
96
+ return self._method
97
+
98
+ def with_method(self, method: str) -> Self:
99
+ """Return an instance carrying ``method``.
100
+
101
+ Args:
102
+ method: The method, kept exactly as given: methods are compared
103
+ with regard to case, so recasing one would change it.
104
+
105
+ Returns:
106
+ A new instance; this one is left as it was.
107
+ """
108
+ new = self._copy()
109
+ new._method = method # noqa: SLF001 — a copy of this very class.
110
+
111
+ return new
112
+
113
+ def get_uri(self) -> UriInterface:
114
+ """Return the URI the request is for.
115
+
116
+ Returns:
117
+ The URI of the request.
118
+ """
119
+ return self._uri
120
+
121
+ def with_uri(self, uri: UriInterface, preserve_host: bool = False) -> Self:
122
+ """Return an instance carrying ``uri``.
123
+
124
+ Args:
125
+ uri: The URI the new instance is for.
126
+ preserve_host: Whether to keep a ``Host`` header the request
127
+ already carries. Left to its default, the header follows the
128
+ new URI.
129
+
130
+ Returns:
131
+ A new instance, or this one when it is already for that very URI.
132
+ """
133
+ if uri is self._uri:
134
+ return self
135
+
136
+ new = self._copy()
137
+ new._uri = uri # noqa: SLF001 — a copy of this very class.
138
+
139
+ if not preserve_host or not self.has_header(_HOST):
140
+ new._update_host_from_uri() # noqa: SLF001 — a copy of this very class.
141
+
142
+ return new
143
+
144
+ def _update_host_from_uri(self) -> None:
145
+ """Set the ``Host`` header from the URI, in front of every other header.
146
+
147
+ Only for a constructor and for :meth:`with_uri`: it changes the
148
+ instance it is called on. A URI with no host leaves the header alone,
149
+ since there would be nothing to put in it.
150
+ """
151
+ host = self._uri.get_host()
152
+ if host == "":
153
+ return
154
+
155
+ port = self._uri.get_port()
156
+ if port is not None:
157
+ host = f"{host}:{port}"
158
+
159
+ recorded = self._header_names.get(_NORMALIZED_HOST)
160
+ if recorded is None:
161
+ recorded = _HOST
162
+ self._header_names[_NORMALIZED_HOST] = recorded
163
+
164
+ self._put_header_first(recorded, [host])
@@ -0,0 +1,24 @@
1
+ """Everything this package raises, under one base class.
2
+
3
+ The contract names two builtins — :class:`ValueError` for an argument that
4
+ cannot be what it is being used as, :class:`RuntimeError` for an operation
5
+ that cannot be carried out now — so each error here derives from the builtin
6
+ the contract named *and* from
7
+ :class:`~xtr_http_message.exception.http_message_error.HttpMessageError`. A
8
+ caller written against the interfaces catches the builtin; one that chose this
9
+ implementation can catch the base class instead and get only its failures.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from .http_message_error import HttpMessageError
15
+ from .invalid_argument_error import InvalidArgumentError
16
+ from .stream_error import StreamError
17
+ from .uploaded_file_error import UploadedFileError
18
+
19
+ __all__ = [
20
+ "HttpMessageError",
21
+ "InvalidArgumentError",
22
+ "StreamError",
23
+ "UploadedFileError",
24
+ ]
@@ -0,0 +1,14 @@
1
+ """The one error every failure in this package derives from."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["HttpMessageError"]
6
+
7
+
8
+ class HttpMessageError(Exception):
9
+ """Something went wrong building or using a message, a stream or a URI.
10
+
11
+ Nothing raises this class itself; it exists so that an application can
12
+ catch everything this package raises with one ``except`` clause, whichever
13
+ of the four concrete errors reached it.
14
+ """
@@ -0,0 +1,17 @@
1
+ """An argument cannot be what it is being used as."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .http_message_error import HttpMessageError
6
+
7
+ __all__ = ["InvalidArgumentError"]
8
+
9
+
10
+ class InvalidArgumentError(HttpMessageError, ValueError):
11
+ """An argument's value rules it out: a malformed header name, an out-of-range port.
12
+
13
+ It derives from :class:`ValueError` as well as from
14
+ :class:`~xtr_http_message.exception.http_message_error.HttpMessageError`
15
+ because that is what the contract promises a caller: code written against
16
+ the interfaces catches the builtin and never names this package.
17
+ """
@@ -0,0 +1,17 @@
1
+ """A stream cannot do what is being asked of it now."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .http_message_error import HttpMessageError
6
+
7
+ __all__ = ["StreamError"]
8
+
9
+
10
+ class StreamError(HttpMessageError, RuntimeError):
11
+ """A stream operation cannot be carried out: detached, not seekable, not readable.
12
+
13
+ It derives from :class:`RuntimeError` as well as from
14
+ :class:`~xtr_http_message.exception.http_message_error.HttpMessageError`
15
+ because that is what the contract promises a caller: code written against
16
+ the interfaces catches the builtin and never names this package.
17
+ """
@@ -0,0 +1,17 @@
1
+ """An uploaded file cannot do what is being asked of it now."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .http_message_error import HttpMessageError
6
+
7
+ __all__ = ["UploadedFileError"]
8
+
9
+
10
+ class UploadedFileError(HttpMessageError, RuntimeError):
11
+ """An uploaded file is unusable: the upload failed, or the file is already moved.
12
+
13
+ It derives from :class:`RuntimeError` as well as from
14
+ :class:`~xtr_http_message.exception.http_message_error.HttpMessageError`
15
+ because that is what the contract promises a caller: code written against
16
+ the interfaces catches the builtin and never names this package.
17
+ """
@@ -0,0 +1,20 @@
1
+ """The factory that builds every kind of message.
2
+
3
+ One class implements all six factory interfaces, so a library that is handed
4
+ it can build a request, a server request, a response, a stream, a URI or an
5
+ uploaded file without ever naming the class that is one.
6
+
7
+ ```python
8
+ from xtr_http_message.factory import HttpFactory
9
+
10
+ factory = HttpFactory()
11
+ request = factory.create_request("GET", "https://example.com/orders")
12
+ assert request.get_header_line("host") == "example.com"
13
+ ```
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from .http_factory import HttpFactory
19
+
20
+ __all__ = ["HttpFactory"]