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,235 @@
1
+ """One factory for every kind of message, so a caller need name no class."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, cast, final
7
+
8
+ from typing_extensions import override
9
+ from xtr_http_message_contracts import UploadError
10
+ from xtr_http_message_contracts.factory import (
11
+ RequestFactoryInterface,
12
+ ResponseFactoryInterface,
13
+ ServerRequestFactoryInterface,
14
+ StreamFactoryInterface,
15
+ UploadedFileFactoryInterface,
16
+ UriFactoryInterface,
17
+ )
18
+
19
+ from xtr_http_message.exception import InvalidArgumentError, StreamError
20
+ from xtr_http_message.request import Request
21
+ from xtr_http_message.response import Response
22
+ from xtr_http_message.server_request import ServerRequest
23
+ from xtr_http_message.stream import Stream
24
+ from xtr_http_message.uploaded_file import UploadedFile
25
+ from xtr_http_message.uri import Uri
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Mapping
29
+ from typing import BinaryIO
30
+
31
+ from xtr_http_message_contracts import (
32
+ RequestInterface,
33
+ ResponseInterface,
34
+ ServerRequestInterface,
35
+ StreamInterface,
36
+ UploadedFileInterface,
37
+ UriInterface,
38
+ )
39
+
40
+ __all__ = ["HttpFactory"]
41
+
42
+
43
+ @final
44
+ class HttpFactory(
45
+ RequestFactoryInterface,
46
+ ResponseFactoryInterface,
47
+ ServerRequestFactoryInterface,
48
+ StreamFactoryInterface,
49
+ UploadedFileFactoryInterface,
50
+ UriFactoryInterface,
51
+ ):
52
+ """Builds every kind of message this package implements.
53
+
54
+ One object rather than six, because the six belong together: a library
55
+ given this one can build whatever it needs, and an application that wants
56
+ to swap the implementation out swaps one registration.
57
+
58
+ ```python
59
+ from xtr_http_message.factory import HttpFactory
60
+
61
+ factory = HttpFactory()
62
+ response = factory.create_response(404)
63
+ assert response.get_reason_phrase() == "Not Found"
64
+ ```
65
+
66
+ It holds no state, so one instance serves a whole application.
67
+ """
68
+
69
+ __slots__ = ()
70
+
71
+ @override
72
+ def create_request(self, method: str, uri: UriInterface | str) -> RequestInterface:
73
+ """Return a new request for ``method`` and ``uri``.
74
+
75
+ Args:
76
+ method: The HTTP method of the request.
77
+ uri: The URI of the request. Given as text, it is read into one.
78
+
79
+ Returns:
80
+ The request.
81
+ """
82
+ return Request(method, uri)
83
+
84
+ @override
85
+ def create_response(
86
+ self,
87
+ code: int = 200,
88
+ reason_phrase: str | None = None,
89
+ ) -> ResponseInterface:
90
+ """Return a new response for ``code``.
91
+
92
+ Args:
93
+ code: The status code.
94
+ reason_phrase: The phrase to go with ``code``. Left out, the
95
+ recommended phrase for the code is filled in where there is
96
+ one; given as an empty string, the response carries no phrase
97
+ at all. That is the difference between asking for the usual
98
+ phrase and asking for none.
99
+
100
+ Returns:
101
+ The response.
102
+ """
103
+ return Response(code, None, None, "1.1", reason_phrase)
104
+
105
+ @override
106
+ def create_server_request(
107
+ self,
108
+ method: str,
109
+ uri: UriInterface | str,
110
+ server_params: Mapping[str, object] | None = None,
111
+ ) -> ServerRequestInterface:
112
+ """Return a new server request for ``method`` and ``uri``.
113
+
114
+ The environment parameters are taken exactly as given: nothing in them
115
+ is read, and in particular neither the method nor the URI, which is
116
+ why both have to be passed.
117
+
118
+ Args:
119
+ method: The HTTP method of the request.
120
+ uri: The URI of the request. Given as text, it is read into one.
121
+ server_params: The environment parameters to seed the request
122
+ with. ``None`` seeds it with none.
123
+
124
+ Returns:
125
+ The server request.
126
+ """
127
+ return ServerRequest(method, uri, None, None, "1.1", server_params)
128
+
129
+ @override
130
+ def create_stream(self, content: bytes = b"") -> StreamInterface:
131
+ """Return a new stream over ``content``, held in memory.
132
+
133
+ Args:
134
+ content: The bytes the stream reads back.
135
+
136
+ Returns:
137
+ The stream, with its cursor at the first byte.
138
+ """
139
+ return Stream.create(content)
140
+
141
+ @override
142
+ def create_stream_from_file(self, filename: str, mode: str = "r") -> StreamInterface:
143
+ """Return a new stream over the file at ``filename``.
144
+
145
+ Args:
146
+ filename: The path to open.
147
+ mode: How to open it. The payload is bytes, so the mode is taken
148
+ as a binary one whether or not it says so.
149
+
150
+ Returns:
151
+ The stream.
152
+
153
+ Raises:
154
+ InvalidArgumentError: When the mode is not a mode.
155
+ StreamError: When the path is empty, or the file cannot be opened.
156
+ """
157
+ if filename == "":
158
+ raise StreamError("Path cannot be empty")
159
+
160
+ binary = mode if "b" in mode else f"{mode}b"
161
+
162
+ try:
163
+ # The stream takes the file over and closes it, so there is no
164
+ # context manager here to close it first (SIM115). The mode is
165
+ # binary by the line above, which a mode computed at run time
166
+ # cannot say in a type.
167
+ opened = Path(filename).open(binary) # noqa: SIM115
168
+ except ValueError as invalid:
169
+ # Not a mode at all, which is worth telling apart from a file that
170
+ # will not open.
171
+ message = f'The mode "{mode}" is invalid.'
172
+ raise InvalidArgumentError(message) from invalid
173
+ except OSError as failure:
174
+ message = f'The file "{filename}" cannot be opened: {failure}'
175
+ raise StreamError(message) from failure
176
+
177
+ return Stream.create(cast("BinaryIO", opened))
178
+
179
+ @override
180
+ def create_stream_from_resource(self, resource: BinaryIO) -> StreamInterface:
181
+ """Return a new stream over an open binary file object.
182
+
183
+ Args:
184
+ resource: The file object. The stream takes it over: closing the
185
+ stream closes it.
186
+
187
+ Returns:
188
+ The stream.
189
+ """
190
+ return Stream.create(resource)
191
+
192
+ @override
193
+ def create_uploaded_file(
194
+ self,
195
+ stream: StreamInterface,
196
+ size: int | None = None,
197
+ error: int = UploadError.OK,
198
+ client_filename: str | None = None,
199
+ client_media_type: str | None = None,
200
+ ) -> UploadedFileInterface:
201
+ """Return a new uploaded file reading from ``stream``.
202
+
203
+ Args:
204
+ stream: The contents of the uploaded file.
205
+ size: The size in bytes. Left out, it is read off the stream.
206
+ error: How the upload went.
207
+ client_filename: The file name the client sent, if any.
208
+ client_media_type: The media type the client sent, if any.
209
+
210
+ Returns:
211
+ The uploaded file.
212
+
213
+ Raises:
214
+ InvalidArgumentError: When ``error`` is not one of the upload
215
+ outcomes.
216
+ """
217
+ if size is None:
218
+ size = stream.get_size()
219
+
220
+ return UploadedFile(stream, size, error, client_filename, client_media_type)
221
+
222
+ @override
223
+ def create_uri(self, uri: str = "") -> UriInterface:
224
+ """Return a new URI read from ``uri``.
225
+
226
+ Args:
227
+ uri: The URI as text. Empty builds one with no component at all.
228
+
229
+ Returns:
230
+ The URI.
231
+
232
+ Raises:
233
+ InvalidArgumentError: When the text cannot be read as a URI.
234
+ """
235
+ return Uri(uri)
File without changes
@@ -0,0 +1,74 @@
1
+ """A request on its way out, as a client sends it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ from xtr_http_message_contracts import RequestInterface
8
+
9
+ from ._request_trait import RequestTrait
10
+ from .uri import Uri
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Mapping, Sequence
14
+ from typing import BinaryIO
15
+
16
+ from xtr_http_message_contracts import StreamInterface, UriInterface
17
+
18
+ __all__ = ["Request"]
19
+
20
+ _HOST = "Host"
21
+
22
+
23
+ @final
24
+ class Request(RequestTrait, RequestInterface):
25
+ """An outgoing, client-side request: a method, a URI, headers and a body.
26
+
27
+ ```python
28
+ from xtr_http_message import Request
29
+
30
+ request = Request("GET", "https://example.com/orders?page=2")
31
+ assert request.get_header_line("host") == "example.com"
32
+ assert request.get_request_target() == "/orders?page=2"
33
+ ```
34
+
35
+ The ``Host`` header is taken from the URI unless one was given with the
36
+ request, and is sent in front of every other header.
37
+ """
38
+
39
+ __slots__ = ()
40
+
41
+ def __init__(
42
+ self,
43
+ method: str,
44
+ uri: UriInterface | str,
45
+ headers: Mapping[str, str | Sequence[str]] | None = None,
46
+ body: bytes | BinaryIO | StreamInterface | None = None,
47
+ version: str = "1.1",
48
+ ) -> None:
49
+ """Build a request.
50
+
51
+ Args:
52
+ method: The HTTP method, kept exactly as given.
53
+ uri: The URI of the request. Given as text, it is read into a
54
+ :class:`~xtr_http_message.uri.Uri`.
55
+ headers: Each header name mapped to one value or to several.
56
+ body: The payload, in any of the shapes a stream is built from.
57
+ Left out, nothing is opened until the body is asked for.
58
+ version: The HTTP version number alone.
59
+
60
+ Raises:
61
+ InvalidArgumentError: When the URI cannot be read, or a header
62
+ name or value is not one.
63
+ """
64
+ super().__init__()
65
+
66
+ self._method = method
67
+ self._uri = Uri(uri) if isinstance(uri, str) else uri
68
+ self._set_headers(headers or {})
69
+ self._protocol = version
70
+
71
+ if not self.has_header(_HOST):
72
+ self._update_host_from_uri()
73
+
74
+ self._set_body(body)
@@ -0,0 +1,196 @@
1
+ """A response on its way out, as a server sends it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import MappingProxyType
6
+ from typing import TYPE_CHECKING, Final, Self, final
7
+
8
+ from typing_extensions import override
9
+ from xtr_http_message_contracts import ResponseInterface
10
+
11
+ from ._message_trait import MessageTrait
12
+ from .exception import InvalidArgumentError
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Mapping, Sequence
16
+ from typing import BinaryIO
17
+
18
+ from xtr_http_message_contracts import StreamInterface
19
+
20
+ __all__ = ["Response"]
21
+
22
+ _LOWEST_STATUS = 100
23
+ _HIGHEST_STATUS = 599
24
+
25
+ # The reason phrase recommended for each status code that has one. A code
26
+ # absent from here is still a status code; it simply has no phrase to offer.
27
+ _PHRASES: Final[Mapping[int, str]] = MappingProxyType(
28
+ {
29
+ 100: "Continue",
30
+ 101: "Switching Protocols",
31
+ 102: "Processing",
32
+ 200: "OK",
33
+ 201: "Created",
34
+ 202: "Accepted",
35
+ 203: "Non-Authoritative Information",
36
+ 204: "No Content",
37
+ 205: "Reset Content",
38
+ 206: "Partial Content",
39
+ 207: "Multi-status",
40
+ 208: "Already Reported",
41
+ 300: "Multiple Choices",
42
+ 301: "Moved Permanently",
43
+ 302: "Found",
44
+ 303: "See Other",
45
+ 304: "Not Modified",
46
+ 305: "Use Proxy",
47
+ 306: "Switch Proxy",
48
+ 307: "Temporary Redirect",
49
+ 400: "Bad Request",
50
+ 401: "Unauthorized",
51
+ 402: "Payment Required",
52
+ 403: "Forbidden",
53
+ 404: "Not Found",
54
+ 405: "Method Not Allowed",
55
+ 406: "Not Acceptable",
56
+ 407: "Proxy Authentication Required",
57
+ 408: "Request Time-out",
58
+ 409: "Conflict",
59
+ 410: "Gone",
60
+ 411: "Length Required",
61
+ 412: "Precondition Failed",
62
+ 413: "Request Entity Too Large",
63
+ 414: "Request-URI Too Large",
64
+ 415: "Unsupported Media Type",
65
+ 416: "Requested range not satisfiable",
66
+ 417: "Expectation Failed",
67
+ 418: "I'm a teapot",
68
+ 422: "Unprocessable Entity",
69
+ 423: "Locked",
70
+ 424: "Failed Dependency",
71
+ 425: "Unordered Collection",
72
+ 426: "Upgrade Required",
73
+ 428: "Precondition Required",
74
+ 429: "Too Many Requests",
75
+ 431: "Request Header Fields Too Large",
76
+ 451: "Unavailable For Legal Reasons",
77
+ 500: "Internal Server Error",
78
+ 501: "Not Implemented",
79
+ 502: "Bad Gateway",
80
+ 503: "Service Unavailable",
81
+ 504: "Gateway Time-out",
82
+ 505: "HTTP Version not supported",
83
+ 506: "Variant Also Negotiates",
84
+ 507: "Insufficient Storage",
85
+ 508: "Loop Detected",
86
+ 511: "Network Authentication Required",
87
+ }
88
+ )
89
+
90
+
91
+ @final
92
+ class Response(MessageTrait, ResponseInterface):
93
+ """A response: a status code, the phrase that goes with it, headers and a body.
94
+
95
+ ```python
96
+ from xtr_http_message import Response
97
+
98
+ response = Response(201, {"content-type": "application/json"}, b'{"id": 7}')
99
+ assert response.get_reason_phrase() == "Created"
100
+ ```
101
+
102
+ Given no reason phrase, the one recommended for the status code is filled
103
+ in; given an empty one, the response carries none, since a status line
104
+ need not have a phrase at all.
105
+ """
106
+
107
+ __slots__ = ("_reason_phrase", "_status_code")
108
+
109
+ def __init__(
110
+ self,
111
+ status: int = 200,
112
+ headers: Mapping[str, str | Sequence[str]] | None = None,
113
+ body: bytes | BinaryIO | StreamInterface | None = None,
114
+ version: str = "1.1",
115
+ reason: str | None = None,
116
+ ) -> None:
117
+ """Build a response.
118
+
119
+ Args:
120
+ status: The status code.
121
+ headers: Each header name mapped to one value or to several.
122
+ body: The payload, in any of the shapes a stream is built from.
123
+ Left out, nothing is opened until the body is asked for.
124
+ version: The HTTP version number alone.
125
+ reason: The reason phrase. ``None`` — the default — takes the one
126
+ recommended for ``status``; an empty string leaves the
127
+ response with none.
128
+
129
+ Raises:
130
+ InvalidArgumentError: When a header name or value is not one.
131
+ """
132
+ super().__init__()
133
+
134
+ self._set_body(body)
135
+
136
+ self._status_code: int = status
137
+ self._set_headers(headers or {})
138
+
139
+ if reason is None:
140
+ self._reason_phrase: str = _PHRASES.get(status, "")
141
+ else:
142
+ self._reason_phrase = reason
143
+
144
+ self._protocol = version
145
+
146
+ @override
147
+ def get_status_code(self) -> int:
148
+ """Return the status code of the response.
149
+
150
+ Returns:
151
+ The three-digit code saying how the server got on with the
152
+ request.
153
+ """
154
+ return self._status_code
155
+
156
+ @override
157
+ def get_reason_phrase(self) -> str:
158
+ """Return the reason phrase that goes with the status code.
159
+
160
+ Returns:
161
+ The reason phrase, or an empty string when the response carries
162
+ none.
163
+ """
164
+ return self._reason_phrase
165
+
166
+ @override
167
+ def with_status(self, code: int, reason_phrase: str = "") -> Self:
168
+ """Return an instance carrying ``code`` and, optionally, ``reason_phrase``.
169
+
170
+ Args:
171
+ code: The status code to set.
172
+ reason_phrase: The phrase to go with ``code``. Left out or empty,
173
+ the one recommended for ``code`` is filled in where there is
174
+ one.
175
+
176
+ Returns:
177
+ A new instance; this one is left as it was.
178
+
179
+ Raises:
180
+ InvalidArgumentError: When the code is not a status code.
181
+ """
182
+ if code < _LOWEST_STATUS or code > _HIGHEST_STATUS:
183
+ message = (
184
+ "Status code has to be an integer between 100 and 599. "
185
+ f"A status code of {code} was given"
186
+ )
187
+ raise InvalidArgumentError(message)
188
+
189
+ if reason_phrase == "":
190
+ reason_phrase = _PHRASES.get(code, "")
191
+
192
+ new = self._copy()
193
+ new._status_code = code # noqa: SLF001 — a copy of this very class.
194
+ new._reason_phrase = reason_phrase # noqa: SLF001 — a copy of this very class.
195
+
196
+ return new