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
xtr_http_message/uri.py
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
"""A URI as a value: every component readable, every change a new instance.
|
|
2
|
+
|
|
3
|
+
A string is read as RFC 3986 defines a URI reference, and `uritools` does the
|
|
4
|
+
splitting — the five-part grammar of its appendix B, the scheme of §3.1, the
|
|
5
|
+
authority of §3.2. The standard library's splitter cannot be used for this: it
|
|
6
|
+
deletes a tab, a carriage return and a newline wherever it finds one, and trims
|
|
7
|
+
the blanks and control characters off both ends, all before anything of ours
|
|
8
|
+
sees the string, so it answers about a URI that was never asked about.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import copy
|
|
14
|
+
import re
|
|
15
|
+
from ipaddress import IPv6Address
|
|
16
|
+
from types import MappingProxyType
|
|
17
|
+
from typing import TYPE_CHECKING, Final, Self, final
|
|
18
|
+
from urllib.parse import quote
|
|
19
|
+
|
|
20
|
+
from typing_extensions import override
|
|
21
|
+
from uritools import uridefrag, urisplit
|
|
22
|
+
from xtr_http_message_contracts import UriInterface
|
|
23
|
+
|
|
24
|
+
from ._ascii import lower
|
|
25
|
+
from .exception import InvalidArgumentError
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Mapping
|
|
29
|
+
|
|
30
|
+
from uritools import SplitResult
|
|
31
|
+
|
|
32
|
+
__all__ = ["Uri"]
|
|
33
|
+
|
|
34
|
+
# The schemes whose standard port is worth dropping from an authority.
|
|
35
|
+
_SCHEMES: Final[Mapping[str, int]] = MappingProxyType({"http": 80, "https": 443})
|
|
36
|
+
|
|
37
|
+
# The schemes that name a server to reach rather than a thing to look up, so
|
|
38
|
+
# that an authority with no host in it names nothing at all: RFC 9110 §4.2.1
|
|
39
|
+
# and §4.2.2 have a recipient reject such a reference instead of reading it.
|
|
40
|
+
_NEEDS_A_HOST: Final[frozenset[str]] = frozenset({"http", "https"})
|
|
41
|
+
|
|
42
|
+
_MAX_PORT = 0xFFFF
|
|
43
|
+
|
|
44
|
+
_CHAR_UNRESERVED = r"a-zA-Z0-9_\-\.~"
|
|
45
|
+
_CHAR_SUB_DELIMS = r"!\$&\'\(\)\*\+,;="
|
|
46
|
+
_CHAR_GEN_DELIMS = r":\/\?#\[\]@"
|
|
47
|
+
|
|
48
|
+
# RFC 3986 §3.2.2: ``reg-name = *( unreserved / pct-encoded / sub-delims )``.
|
|
49
|
+
# A registered name carries nothing else, so a blank, a tab, a control
|
|
50
|
+
# character or a stray per cent sign in one leaves a host nobody can look up.
|
|
51
|
+
_REG_NAME = re.compile(f"(?:[{_CHAR_UNRESERVED}{_CHAR_SUB_DELIMS}]|%[A-Fa-f0-9]{{2}})*")
|
|
52
|
+
|
|
53
|
+
# A run of delimiters in user information, which has to be encoded to stay
|
|
54
|
+
# inside the authority, or a per cent sign that does not open an escape —
|
|
55
|
+
# encoding one of those again would double it.
|
|
56
|
+
_IN_USER_INFO = re.compile(f"(?:[{_CHAR_GEN_DELIMS}{_CHAR_SUB_DELIMS}]+|%(?![A-Fa-f0-9]{{2}}))")
|
|
57
|
+
# A run of characters a path may not carry as it is, or a per cent sign that
|
|
58
|
+
# does not open an escape — encoding one of those again would double it.
|
|
59
|
+
_IN_PATH = re.compile(f"(?:[^{_CHAR_UNRESERVED}{_CHAR_SUB_DELIMS}%:@/]+|%(?![A-Fa-f0-9]{{2}}))")
|
|
60
|
+
# The same, for a query string or a fragment, where a question mark is data.
|
|
61
|
+
_IN_QUERY_OR_FRAGMENT = re.compile(
|
|
62
|
+
f"(?:[^{_CHAR_UNRESERVED}{_CHAR_SUB_DELIMS}%:@/?]+|%(?![A-Fa-f0-9]{{2}}))"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _encode(match: re.Match[str]) -> str:
|
|
67
|
+
"""Return the matched run of characters, per cent-encoded.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
match: What one of the filter patterns matched.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
The run with every byte of its UTF-8 form escaped, except for the
|
|
74
|
+
characters no URI ever needs escaped.
|
|
75
|
+
"""
|
|
76
|
+
return quote(match[0], safe="")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _unparseable(uri: str) -> InvalidArgumentError:
|
|
80
|
+
"""Return the error for a string that is not a URI.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
uri: The string that could not be read as one.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
The error to raise, carrying the string it was raised over.
|
|
87
|
+
"""
|
|
88
|
+
return InvalidArgumentError(f'Unable to parse URI: "{uri}"')
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _filter_path(path: str) -> str:
|
|
92
|
+
"""Return a path with everything that has to be encoded encoded, and nothing twice."""
|
|
93
|
+
return _IN_PATH.sub(_encode, path)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _filter_user_info(user_info: str) -> str:
|
|
97
|
+
"""Return user information encoded as RFC 3986 §3.2.1 writes it, and nothing twice.
|
|
98
|
+
|
|
99
|
+
The first colon is the one the grammar keeps, between the user name and
|
|
100
|
+
what follows it; a later one is data and goes the way of every other
|
|
101
|
+
delimiter. Without this a user information read off a string would be
|
|
102
|
+
written back out as it arrived, and an ``@`` or a bracket in it would move
|
|
103
|
+
where the authority ends.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
user_info: The user information, as the authority holds it.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The user information, every delimiter and every broken escape in it
|
|
110
|
+
per cent-encoded.
|
|
111
|
+
"""
|
|
112
|
+
user, colon, rest = user_info.partition(":")
|
|
113
|
+
encoded = _IN_USER_INFO.sub(_encode, user)
|
|
114
|
+
if colon == "":
|
|
115
|
+
return encoded
|
|
116
|
+
|
|
117
|
+
return f"{encoded}:{_IN_USER_INFO.sub(_encode, rest)}"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _filter_query_or_fragment(text: str) -> str:
|
|
121
|
+
"""Return a query string or fragment encoded the same way, and nothing twice."""
|
|
122
|
+
return _IN_QUERY_OR_FRAGMENT.sub(_encode, text)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _is_an_ip_literal(host: str) -> bool:
|
|
126
|
+
"""Tell whether what two brackets wrap is an address, as RFC 3986 §3.2.2 writes one.
|
|
127
|
+
|
|
128
|
+
``IP-literal = "[" ( IPv6address / IPvFuture ) "]"``, and the section says
|
|
129
|
+
of that grammar that it "does not support IPv6 scoped addressing zone
|
|
130
|
+
identifiers" — so a per cent sign between the brackets is a zone
|
|
131
|
+
identifier or an escape with no business there, and neither names a host.
|
|
132
|
+
An ``IPvFuture`` is a format announced by a version flag but left
|
|
133
|
+
undefined, which §3.2.2 has an application report an error over rather
|
|
134
|
+
than guess at: it is not an IPv6 address either, so it is refused here.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
host: The host, brackets included.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Whether the brackets wrap an address.
|
|
141
|
+
"""
|
|
142
|
+
address = host[1:-1]
|
|
143
|
+
if "%" in address:
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
_ = IPv6Address(address)
|
|
148
|
+
except ValueError:
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
return True
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _is_a_host(host: str) -> bool:
|
|
155
|
+
"""Tell whether a string is a host, as RFC 3986 §3.2.2 writes one.
|
|
156
|
+
|
|
157
|
+
Brackets are only ever there to wrap an IP-literal, so what they wrap has
|
|
158
|
+
to be one; anything else is a registered name, which carries only
|
|
159
|
+
unreserved characters, per cent escapes and sub-delimiters. Empty is a
|
|
160
|
+
host too: §3.2.2 lets an authority name none.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
host: The host, as an authority holds it.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Whether it is one.
|
|
167
|
+
"""
|
|
168
|
+
if host.startswith("[") and host.endswith("]"):
|
|
169
|
+
return _is_an_ip_literal(host)
|
|
170
|
+
|
|
171
|
+
return _REG_NAME.fullmatch(host) is not None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _is_non_standard_port(scheme: str, port: int) -> bool:
|
|
175
|
+
"""Tell whether a port says anything the scheme does not already imply."""
|
|
176
|
+
return _SCHEMES.get(scheme) != port
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _read_authority(parts: SplitResult[str], scheme: str, uri: str) -> tuple[str, str, int | None]:
|
|
180
|
+
"""Return the user information, host and port an authority names, each checked.
|
|
181
|
+
|
|
182
|
+
RFC 3986 §3.2 reads an authority as ``[ userinfo "@" ] host [ ":" port ]``.
|
|
183
|
+
The host is checked against §3.2.2 by :func:`_is_a_host`. The port is
|
|
184
|
+
checked against §3.2.3, which allows digits and nothing else, an empty port
|
|
185
|
+
meaning none; the range is the sixteen bits a TCP or UDP port is assigned
|
|
186
|
+
from, as the contract refuses anything outside them. A port with no host in
|
|
187
|
+
front of it is refused: §3.2 hangs the port off the host, so there is no
|
|
188
|
+
authority to write it into.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
parts: The URI reference as RFC 3986 appendix B splits it.
|
|
192
|
+
scheme: The scheme already read off it, lowercased.
|
|
193
|
+
uri: The whole string, which a refusal names.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
The user information, the host and the port — the first two empty and
|
|
197
|
+
the last ``None`` where the URI names none of them.
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
InvalidArgumentError: When the authority names something that is not a
|
|
201
|
+
host or not a port, names a port with no host to belong to, or
|
|
202
|
+
names no host under a scheme that needs one.
|
|
203
|
+
"""
|
|
204
|
+
host = parts.host or ""
|
|
205
|
+
|
|
206
|
+
if not _is_a_host(host):
|
|
207
|
+
raise _unparseable(uri)
|
|
208
|
+
|
|
209
|
+
# RFC 3986 §3.2.2 lets a host be empty; RFC 9110 §4.2.1 and §4.2.2 do not,
|
|
210
|
+
# for the two schemes that name a server to reach.
|
|
211
|
+
if host == "" and parts.authority is not None and scheme in _NEEDS_A_HOST:
|
|
212
|
+
raise _unparseable(uri)
|
|
213
|
+
|
|
214
|
+
port = parts.getport()
|
|
215
|
+
# RFC 3986 §3.2.3 leaves the range to the scheme, and the split lets
|
|
216
|
+
# nothing but digits through, so only the ceiling the contract sets — the
|
|
217
|
+
# sixteen bits a TCP or UDP port is assigned from — can be broken.
|
|
218
|
+
if port is not None and port > _MAX_PORT:
|
|
219
|
+
raise _unparseable(uri)
|
|
220
|
+
|
|
221
|
+
# A port belongs to the host it is written after, and with no host there is
|
|
222
|
+
# no authority to write down at all (:meth:`Uri.get_authority`). Reading a
|
|
223
|
+
# port off such a reference would leave an instance reporting one that no
|
|
224
|
+
# text of its own could carry, so the reference is refused instead.
|
|
225
|
+
if host == "" and port is not None:
|
|
226
|
+
raise _unparseable(uri)
|
|
227
|
+
|
|
228
|
+
return parts.userinfo or "", host, port
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@final
|
|
232
|
+
class Uri(UriInterface):
|
|
233
|
+
"""A URI, taken apart into its components and put back together as text.
|
|
234
|
+
|
|
235
|
+
Build one from a string and read any component off it; change one and get
|
|
236
|
+
a new instance, since a URI is a value rather than a thing with a state:
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
from xtr_http_message import Uri
|
|
240
|
+
|
|
241
|
+
uri = Uri("https://example.com/orders?page=2")
|
|
242
|
+
assert uri.get_host() == "example.com"
|
|
243
|
+
assert str(uri.with_path("/orders/7")) == "https://example.com/orders/7?page=2"
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Components arrive encoded or not, as the caller has them, and come back
|
|
247
|
+
encoded exactly once. The standard port of the scheme is dropped, since it
|
|
248
|
+
says nothing the scheme does not: ``Uri("https://example.com:443")`` has
|
|
249
|
+
no port to report.
|
|
250
|
+
|
|
251
|
+
A string is read as RFC 3986 defines a URI reference: a scheme opens with a
|
|
252
|
+
letter (§3.1), a host is an IPv6 address in brackets — a grammar with no
|
|
253
|
+
room for a zone identifier — or a registered name of unreserved
|
|
254
|
+
characters, per cent escapes and sub-delimiters (§3.2.2), and a port is
|
|
255
|
+
digits inside sixteen bits, written after a host (§3.2.3 and §3.2). A
|
|
256
|
+
string that breaks one of those — ``http://exa mple.com``,
|
|
257
|
+
``http://[::1``, ``http://[fe80::1%eth0]``, ``http://h:65536``, ``//:80``
|
|
258
|
+
— is refused, as is an ``http`` or ``https`` URI naming an authority with
|
|
259
|
+
no host in it, such as ``http://`` (RFC 9110 §4.2.1 and §4.2.2). Nothing
|
|
260
|
+
is trimmed and no character is dropped: whatever a path, a query string or
|
|
261
|
+
a fragment may not carry as it is comes back per cent-encoded.
|
|
262
|
+
|
|
263
|
+
Being read is not the same as being a URI reference. A string whose front
|
|
264
|
+
is no scheme — ``ht tp://h/``, ``0:0`` — is read as a path and comes back
|
|
265
|
+
per cent-encoded, and the result may still be no URI reference: §4.2
|
|
266
|
+
leaves a colon out of the first segment of a relative-path one, so
|
|
267
|
+
``0:0`` would read as a scheme to somebody resolving it. Put ``./`` in
|
|
268
|
+
front of such a path, or give the reference a scheme.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
__slots__ = ("_fragment", "_host", "_path", "_port", "_query", "_scheme", "_user_info")
|
|
272
|
+
|
|
273
|
+
def __init__(self, uri: str = "") -> None:
|
|
274
|
+
"""Read a URI out of a string, as RFC 3986 defines one.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
uri: The URI. Empty — the default — builds one with no component
|
|
278
|
+
at all, to be assembled through the ``with_*`` methods.
|
|
279
|
+
|
|
280
|
+
Raises:
|
|
281
|
+
InvalidArgumentError: When the string cannot be read as a URI
|
|
282
|
+
reference — a host or a port that is not one, a port with no
|
|
283
|
+
host to belong to, or no host under a scheme that needs one.
|
|
284
|
+
"""
|
|
285
|
+
self._scheme: str = ""
|
|
286
|
+
self._user_info: str = ""
|
|
287
|
+
self._host: str = ""
|
|
288
|
+
self._port: int | None = None
|
|
289
|
+
self._path: str = ""
|
|
290
|
+
self._query: str = ""
|
|
291
|
+
self._fragment: str = ""
|
|
292
|
+
|
|
293
|
+
if uri == "":
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
# RFC 3986 §3.5 puts the fragment behind the first ``#``, and it is cut
|
|
297
|
+
# off first because the appendix B pattern reads the rest of the
|
|
298
|
+
# reference but stops a fragment at a newline.
|
|
299
|
+
head, fragment = uridefrag(uri)
|
|
300
|
+
parts = urisplit(head)
|
|
301
|
+
|
|
302
|
+
self._scheme = lower(parts.scheme or "")
|
|
303
|
+
user_info, host, port = _read_authority(parts, self._scheme, uri)
|
|
304
|
+
self._user_info = _filter_user_info(user_info)
|
|
305
|
+
self._host = lower(host)
|
|
306
|
+
# The scheme is recorded first: whether a port is worth keeping is
|
|
307
|
+
# weighed against it.
|
|
308
|
+
self._port = self._filter_port(port)
|
|
309
|
+
self._path = _filter_path(parts.path)
|
|
310
|
+
self._query = _filter_query_or_fragment(parts.query or "")
|
|
311
|
+
self._fragment = _filter_query_or_fragment(fragment or "")
|
|
312
|
+
|
|
313
|
+
@override
|
|
314
|
+
def __str__(self) -> str:
|
|
315
|
+
"""Return the URI as text, either absolute or a relative reference.
|
|
316
|
+
|
|
317
|
+
Never raises: a path that would read as something else once the other
|
|
318
|
+
components are put in front of it is corrected rather than refused.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
The assembled URI.
|
|
322
|
+
"""
|
|
323
|
+
return _assemble(
|
|
324
|
+
self._scheme,
|
|
325
|
+
self.get_authority(),
|
|
326
|
+
self._path,
|
|
327
|
+
self._query,
|
|
328
|
+
self._fragment,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
@override
|
|
332
|
+
def get_scheme(self) -> str:
|
|
333
|
+
"""Return the scheme, lowercased, without its trailing colon.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
The scheme, or an empty string when the URI has none.
|
|
337
|
+
"""
|
|
338
|
+
return self._scheme
|
|
339
|
+
|
|
340
|
+
@override
|
|
341
|
+
def get_authority(self) -> str:
|
|
342
|
+
"""Return the authority as ``[user-info@]host[:port]``.
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
The authority, or an empty string when the URI has no host — user
|
|
346
|
+
information without a host cannot be written down.
|
|
347
|
+
"""
|
|
348
|
+
if self._host == "":
|
|
349
|
+
return ""
|
|
350
|
+
|
|
351
|
+
authority = self._host
|
|
352
|
+
if self._user_info != "":
|
|
353
|
+
authority = f"{self._user_info}@{authority}"
|
|
354
|
+
if self._port is not None:
|
|
355
|
+
authority = f"{authority}:{self._port}"
|
|
356
|
+
|
|
357
|
+
return authority
|
|
358
|
+
|
|
359
|
+
@override
|
|
360
|
+
def get_user_info(self) -> str:
|
|
361
|
+
"""Return the user information as ``username[:password]``.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
The user information, or an empty string when the URI has none.
|
|
365
|
+
"""
|
|
366
|
+
return self._user_info
|
|
367
|
+
|
|
368
|
+
@override
|
|
369
|
+
def get_host(self) -> str:
|
|
370
|
+
"""Return the host, lowercased.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
The host, or an empty string when the URI has none.
|
|
374
|
+
"""
|
|
375
|
+
return self._host
|
|
376
|
+
|
|
377
|
+
@override
|
|
378
|
+
def get_port(self) -> int | None:
|
|
379
|
+
"""Return the port, when one is worth reporting.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
The port when it is not the standard one of the scheme, otherwise
|
|
383
|
+
``None``.
|
|
384
|
+
"""
|
|
385
|
+
return self._port
|
|
386
|
+
|
|
387
|
+
@override
|
|
388
|
+
def get_path(self) -> str:
|
|
389
|
+
"""Return the path, percent-encoded.
|
|
390
|
+
|
|
391
|
+
Two corrections are applied, so that the path read off a URI is one
|
|
392
|
+
that can be used as it is: a rootless path gains a leading ``/`` when
|
|
393
|
+
there is an authority to keep it apart from, and a path opening with
|
|
394
|
+
more than one ``/`` has those reduced to one.
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
The path.
|
|
398
|
+
"""
|
|
399
|
+
path = self._path
|
|
400
|
+
|
|
401
|
+
if path.startswith("//"):
|
|
402
|
+
return "/" + path.lstrip("/")
|
|
403
|
+
if path != "" and not path.startswith("/") and self._host != "":
|
|
404
|
+
return f"/{path}"
|
|
405
|
+
|
|
406
|
+
return path
|
|
407
|
+
|
|
408
|
+
@override
|
|
409
|
+
def get_query(self) -> str:
|
|
410
|
+
"""Return the query string, percent-encoded, without its leading ``?``.
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
The query string, or an empty string when the URI has none.
|
|
414
|
+
"""
|
|
415
|
+
return self._query
|
|
416
|
+
|
|
417
|
+
@override
|
|
418
|
+
def get_fragment(self) -> str:
|
|
419
|
+
"""Return the fragment, percent-encoded, without its leading ``#``.
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
The fragment, or an empty string when the URI has none.
|
|
423
|
+
"""
|
|
424
|
+
return self._fragment
|
|
425
|
+
|
|
426
|
+
@override
|
|
427
|
+
def with_scheme(self, scheme: str) -> Self:
|
|
428
|
+
"""Return an instance carrying ``scheme``.
|
|
429
|
+
|
|
430
|
+
A port that was non-standard for the old scheme may be standard for
|
|
431
|
+
the new one, so the port is weighed again.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
scheme: The scheme the new instance carries, in any case. Empty
|
|
435
|
+
removes the scheme.
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
A new instance, or this one when the scheme is already that.
|
|
439
|
+
"""
|
|
440
|
+
scheme = lower(scheme)
|
|
441
|
+
if self._scheme == scheme:
|
|
442
|
+
return self
|
|
443
|
+
|
|
444
|
+
new = self._copy()
|
|
445
|
+
new._scheme = scheme # noqa: SLF001 — a copy of this very class.
|
|
446
|
+
new._port = new._filter_port(new._port) # noqa: SLF001 — a copy of this very class.
|
|
447
|
+
|
|
448
|
+
return new
|
|
449
|
+
|
|
450
|
+
@override
|
|
451
|
+
def with_user_info(self, user: str, password: str | None = None) -> Self:
|
|
452
|
+
"""Return an instance carrying ``user`` and, optionally, ``password``.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
user: The user name. Empty with no password removes the user
|
|
456
|
+
information; empty with a password keeps the password under a
|
|
457
|
+
user name that is empty, which is what ``:pass`` says.
|
|
458
|
+
password: The password that goes with ``user``, when there is one.
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
A new instance, or this one when the user information is already
|
|
462
|
+
that.
|
|
463
|
+
"""
|
|
464
|
+
info = _IN_USER_INFO.sub(_encode, user)
|
|
465
|
+
if password is not None and password != "":
|
|
466
|
+
info = f"{info}:{_IN_USER_INFO.sub(_encode, password)}"
|
|
467
|
+
|
|
468
|
+
if self._user_info == info:
|
|
469
|
+
return self
|
|
470
|
+
|
|
471
|
+
new = self._copy()
|
|
472
|
+
new._user_info = info # noqa: SLF001 — a copy of this very class.
|
|
473
|
+
|
|
474
|
+
return new
|
|
475
|
+
|
|
476
|
+
@override
|
|
477
|
+
def with_host(self, host: str) -> Self:
|
|
478
|
+
"""Return an instance carrying ``host``.
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
host: The host, in any case. Empty removes the host.
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
A new instance, or this one when the host is already that.
|
|
485
|
+
|
|
486
|
+
Raises:
|
|
487
|
+
InvalidArgumentError: When the host is not one as RFC 3986 §3.2.2
|
|
488
|
+
writes it. That grammar is ASCII throughout, so a name in
|
|
489
|
+
another script is given in its ``xn--`` form — and a ``Host``
|
|
490
|
+
header is written straight off this component, which is the
|
|
491
|
+
other reason nothing else may reach it.
|
|
492
|
+
"""
|
|
493
|
+
host = lower(host)
|
|
494
|
+
if not _is_a_host(host):
|
|
495
|
+
message = f'Invalid host: "{host}". Must be an address in brackets or a registered name'
|
|
496
|
+
raise InvalidArgumentError(message)
|
|
497
|
+
|
|
498
|
+
if self._host == host:
|
|
499
|
+
return self
|
|
500
|
+
|
|
501
|
+
new = self._copy()
|
|
502
|
+
new._host = host # noqa: SLF001 — a copy of this very class.
|
|
503
|
+
|
|
504
|
+
return new
|
|
505
|
+
|
|
506
|
+
@override
|
|
507
|
+
def with_port(self, port: int | None) -> Self:
|
|
508
|
+
"""Return an instance carrying ``port``.
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
port: The port, or ``None`` to remove it. The standard port of the
|
|
512
|
+
current scheme is taken as ``None``.
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
A new instance, or this one when the port is already that.
|
|
516
|
+
|
|
517
|
+
Raises:
|
|
518
|
+
InvalidArgumentError: When the port falls outside the range a TCP
|
|
519
|
+
or UDP port is assigned from.
|
|
520
|
+
"""
|
|
521
|
+
filtered = self._filter_port(port)
|
|
522
|
+
if self._port == filtered:
|
|
523
|
+
return self
|
|
524
|
+
|
|
525
|
+
new = self._copy()
|
|
526
|
+
new._port = filtered # noqa: SLF001 — a copy of this very class.
|
|
527
|
+
|
|
528
|
+
return new
|
|
529
|
+
|
|
530
|
+
@override
|
|
531
|
+
def with_path(self, path: str) -> Self:
|
|
532
|
+
"""Return an instance carrying ``path``.
|
|
533
|
+
|
|
534
|
+
Args:
|
|
535
|
+
path: The path, encoded or not. It may be empty, absolute, or
|
|
536
|
+
rootless.
|
|
537
|
+
|
|
538
|
+
Returns:
|
|
539
|
+
A new instance, or this one when the path is already that.
|
|
540
|
+
"""
|
|
541
|
+
path = _filter_path(path)
|
|
542
|
+
if self._path == path:
|
|
543
|
+
return self
|
|
544
|
+
|
|
545
|
+
new = self._copy()
|
|
546
|
+
new._path = path # noqa: SLF001 — a copy of this very class.
|
|
547
|
+
|
|
548
|
+
return new
|
|
549
|
+
|
|
550
|
+
@override
|
|
551
|
+
def with_query(self, query: str) -> Self:
|
|
552
|
+
"""Return an instance carrying ``query``.
|
|
553
|
+
|
|
554
|
+
Args:
|
|
555
|
+
query: The query string, encoded or not, without a leading ``?``
|
|
556
|
+
unless that question mark is data. Empty removes it.
|
|
557
|
+
|
|
558
|
+
Returns:
|
|
559
|
+
A new instance, or this one when the query string is already that.
|
|
560
|
+
"""
|
|
561
|
+
query = _filter_query_or_fragment(query)
|
|
562
|
+
if self._query == query:
|
|
563
|
+
return self
|
|
564
|
+
|
|
565
|
+
new = self._copy()
|
|
566
|
+
new._query = query # noqa: SLF001 — a copy of this very class.
|
|
567
|
+
|
|
568
|
+
return new
|
|
569
|
+
|
|
570
|
+
@override
|
|
571
|
+
def with_fragment(self, fragment: str) -> Self:
|
|
572
|
+
"""Return an instance carrying ``fragment``.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
fragment: The fragment, encoded or not, without a leading ``#``
|
|
576
|
+
unless that hash is data. Empty removes it.
|
|
577
|
+
|
|
578
|
+
Returns:
|
|
579
|
+
A new instance, or this one when the fragment is already that.
|
|
580
|
+
"""
|
|
581
|
+
fragment = _filter_query_or_fragment(fragment)
|
|
582
|
+
if self._fragment == fragment:
|
|
583
|
+
return self
|
|
584
|
+
|
|
585
|
+
new = self._copy()
|
|
586
|
+
new._fragment = fragment # noqa: SLF001 — a copy of this very class.
|
|
587
|
+
|
|
588
|
+
return new
|
|
589
|
+
|
|
590
|
+
def _copy(self) -> Self:
|
|
591
|
+
"""Return a copy to change, leaving this instance as it is.
|
|
592
|
+
|
|
593
|
+
Every component is a string or a number, so a shallow copy shares
|
|
594
|
+
nothing that could be changed through it.
|
|
595
|
+
"""
|
|
596
|
+
return copy.copy(self)
|
|
597
|
+
|
|
598
|
+
def _filter_port(self, port: int | None) -> int | None:
|
|
599
|
+
"""Return a port worth keeping, weighed against the current scheme.
|
|
600
|
+
|
|
601
|
+
Args:
|
|
602
|
+
port: The port to weigh, or ``None``.
|
|
603
|
+
|
|
604
|
+
Returns:
|
|
605
|
+
The port when the scheme does not already imply it, otherwise
|
|
606
|
+
``None``.
|
|
607
|
+
|
|
608
|
+
Raises:
|
|
609
|
+
InvalidArgumentError: When the port falls outside the range a TCP
|
|
610
|
+
or UDP port is assigned from.
|
|
611
|
+
"""
|
|
612
|
+
if port is None:
|
|
613
|
+
return None
|
|
614
|
+
|
|
615
|
+
if port < 0 or port > _MAX_PORT:
|
|
616
|
+
message = f"Invalid port: {port}. Must be between 0 and 65535"
|
|
617
|
+
raise InvalidArgumentError(message)
|
|
618
|
+
|
|
619
|
+
return port if _is_non_standard_port(self._scheme, port) else None
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _assemble(scheme: str, authority: str, path: str, query: str, fragment: str) -> str:
|
|
623
|
+
"""Put a URI back together from its components.
|
|
624
|
+
|
|
625
|
+
The path is corrected where joining it to the components in front of it
|
|
626
|
+
would read as something else: a rootless path after an authority gains a
|
|
627
|
+
leading ``/``, and a path opening with more than one ``/`` with no
|
|
628
|
+
authority in front of it has those reduced to one — otherwise it would
|
|
629
|
+
read as an authority itself.
|
|
630
|
+
|
|
631
|
+
Args:
|
|
632
|
+
scheme: The scheme, or empty.
|
|
633
|
+
authority: The authority, or empty.
|
|
634
|
+
path: The path, as it is held rather than as :meth:`Uri.get_path`
|
|
635
|
+
reports it.
|
|
636
|
+
query: The query string, or empty.
|
|
637
|
+
fragment: The fragment, or empty.
|
|
638
|
+
|
|
639
|
+
Returns:
|
|
640
|
+
The assembled URI.
|
|
641
|
+
"""
|
|
642
|
+
uri = f"{scheme}:" if scheme != "" else ""
|
|
643
|
+
|
|
644
|
+
if authority != "":
|
|
645
|
+
uri = f"{uri}//{authority}"
|
|
646
|
+
|
|
647
|
+
if path != "":
|
|
648
|
+
if path.startswith("//") and authority == "":
|
|
649
|
+
path = "/" + path.lstrip("/")
|
|
650
|
+
elif not path.startswith("/") and authority != "":
|
|
651
|
+
path = f"/{path}"
|
|
652
|
+
uri = f"{uri}{path}"
|
|
653
|
+
|
|
654
|
+
if query != "":
|
|
655
|
+
uri = f"{uri}?{query}"
|
|
656
|
+
|
|
657
|
+
if fragment != "":
|
|
658
|
+
uri = f"{uri}#{fragment}"
|
|
659
|
+
|
|
660
|
+
return uri
|