__init__.py
Summary
- Lines:
661 of 677 covered (97.6%)
- Branches:
222 of 228 taken (97.4%), 6 lines partially
- Coverage:
97.6%
- Directory:
- Missed lines:
359, 662-664, 891-893, 898-900, 905-907, 911-913
Source
pyTooling/URI/__init__.pycoveredpartialuncoveredexcludednot executable
1# ==================================================================================================================== #
2# _____ _ _ _ _ ____ ___ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ | | | | _ \|_ _| #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` || | | | |_) || | #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| || |_| | _ < | | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)___/|_| \_\___| #
7# |_| |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2017-2026 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32This package provides representations of Uniform Resource Identifiers (URI): the generic URI, the Uniform Resource
33Locator (URL) and the Uniform Resource Name (URN, :rfc:`8141`).
34
35.. code-block::
36
37 scheme://[user[:password]@]domain.tld[:port][/path/to/file][?query][#fragment]
38
39The syntax is the one of :rfc:`3986` (an absolute URI with an authority); ``file`` URLs follow :rfc:`8089`. An IRI
40(:rfc:`3987`), which may hold Unicode characters, is read with ``URL.Parse(..., iri=True)``.
41"""
42from __future__ import annotations
43
44from enum import Flag
45from re import compile as re_compile
46from string import punctuation
47from urllib.parse import quote as urlQuote
48from typing import ClassVar, Iterable, Optional as Nullable, Union
49
50from pyTooling.Decorators import export, readonly
51from pyTooling.Exceptions import MissingDependencyError, ToolingException
52from pyTooling.Common import getFullyQualifiedName
53from pyTooling.MetaClasses import ExtendedType, abstractmethod
54from pyTooling.GenericPath import RootMixin, ElementMixin, PathMixin
55
56
57__all__ = [
58 "URI_PATTERN", "URI_REGEXP", "URN_PATTERN", "URN_REGEXP", "URL_PATTERN", "URL_REGEXP", "SCP_ADDRESS_PATTERN",
59 "SCP_ADDRESS_REGEXP"
60]
61
62URL_PATTERN = (
63 r"""(?P<scheme>[a-zA-Z][-a-zA-Z0-9+.]*)://"""
64 r"""(?:(?P<user>(?:[-a-zA-Z0-9._~!$&'()*+,;=]|%[0-9A-Fa-f]{2})+)"""
65 r"""(?::(?P<password>(?:[-a-zA-Z0-9._~!$&'()*+,;=]|%[0-9A-Fa-f]{2})+))?@)?"""
66 r"""(?P<host>\[[0-9A-Fa-f:.]+\]|(?:[-a-zA-Z0-9_]+)(?:\.[-a-zA-Z0-9_]+)*\.?)?"""
67 r"""(?:\:(?P<port>\d+))?"""
68 r"""(?P<path>(?:/[^?#]*)?)"""
69 r"""(?:\?(?P<query>[^#]*?))?"""
70 r"""(?:#(?P<fragment>.*?))?"""
71)
72"""Regular expression pattern for validating and splitting a URL."""
73
74URL_REGEXP = re_compile("^" + URL_PATTERN + r"\Z")
75"""Precompiled regular expression for URL validation."""
76
77URI_PATTERN = (
78 r"""(?P<scheme>[a-zA-Z][-a-zA-Z0-9+.]*):"""
79 r"""(?P<schemeSpecificPart>[^?#]*)"""
80 r"""(?:\?(?P<query>[^#]*))?"""
81 r"""(?:#(?P<fragment>.*))?"""
82)
83"""Regular expression pattern for validating and splitting a URI: scheme, scheme-specific part, query and fragment."""
84
85URI_REGEXP = re_compile("^" + URI_PATTERN + r"\Z")
86"""Precompiled regular expression for URI validation."""
87
88_PCHAR = r"""(?:[-a-zA-Z0-9._~!$&'()*+,;=:@]|%[0-9A-Fa-f]{2})"""
89"""Regular expression pattern of one path character of :rfc:`3986` (``pchar``)."""
90
91URN_PATTERN = (
92 r"""(?P<scheme>[uU][rR][nN]):"""
93 r"""(?P<namespaceID>[a-zA-Z0-9][-a-zA-Z0-9]{0,30}[a-zA-Z0-9]):"""
94 r"""(?P<namespaceSpecificString>""" + _PCHAR + r"""(?:""" + _PCHAR + r"""|/)*)"""
95 r"""(?:\?\+(?P<resolution>""" + _PCHAR + r"""(?:(?!\?=)(?:""" + _PCHAR + r"""|[/?]))*))?"""
96 r"""(?:\?=(?P<query>""" + _PCHAR + r"""(?:""" + _PCHAR + r"""|[/?])*))?"""
97 r"""(?:#(?P<fragment>(?:""" + _PCHAR + r"""|[/?])*))?"""
98)
99"""Regular expression pattern for validating and splitting a URN (:rfc:`8141`)."""
100
101URN_REGEXP = re_compile("^" + URN_PATTERN + r"\Z")
102"""Precompiled regular expression for URN validation."""
103
104_NAMESPACE_ID = re_compile(r"[a-zA-Z0-9][-a-zA-Z0-9]{0,30}[a-zA-Z0-9]")
105"""Precompiled regular expression for a URN's namespace identifier (:rfc:`8141`, section 2)."""
106
107SCP_ADDRESS_PATTERN = (
108 r"""(?:(?P<user>[^@/:\[\]]+)@)?"""
109 r"""(?P<host>\[[0-9A-Fa-f:.]+\]|(?:[-a-zA-Z0-9_]+)(?:\.[-a-zA-Z0-9_]+)*\.?)"""
110 r""":(?P<path>[^\x00-\x1f\x7f]*)"""
111)
112"""Regular expression pattern for validating and splitting an SCP-like address ``[user@]host:[path]``."""
113
114SCP_ADDRESS_REGEXP = re_compile("^" + SCP_ADDRESS_PATTERN + r"\Z")
115"""Precompiled regular expression for SCP-like address validation."""
116
117_SCHEME_PREFIX = re_compile(r"[a-zA-Z][-a-zA-Z0-9+.]*://")
118"""Precompiled regular expression for the leading scheme."""
119
120_SCHEME = re_compile(r"[a-zA-Z][-a-zA-Z0-9+.]*")
121"""Precompiled regular expression for a scheme name (:rfc:`3986`, section 3.1)."""
122
123_IRI_HOST = re_compile(
124 r"""(?P<prefix>[a-zA-Z][-a-zA-Z0-9+.]*://(?:[^/?#@]*@)?)"""
125 r"""(?P<host>[^/?#:@\[\]]*)"""
126 r"""(?P<rest>.*)"""
127)
128"""Precompiled regular expression splitting an IRI into scheme and user information, host, and the rest."""
129
130_PORT = re_compile(
131 r"""(?P<prefix>(?P<scheme>[a-zA-Z][-a-zA-Z0-9+.]*)://(?:[^/?#@]*@)?(?P<host>\[[^\]/?#]*\]|[^/?#:@\[\]]*))"""
132 r""":(?P<port>[^/?#]*)"""
133)
134"""Precompiled regular expression for the text after the host's ``:``, where a URL has its port."""
135
136
137FORBIDDEN_CHARACTERS = " <>\"{}|\\^`"
138"""Characters :rfc:`3986` forbids in a URL unless they are percent-encoded."""
139
140
141@export
142class URLError(ToolingException):
143 """
144 Raised when a URL can't be parsed, or a hostname's A-label can't be decoded.
145
146 .. note::
147
148 :class:`urllib.error.URLError` from the standard library carries the same name and a different meaning - it
149 reports that a *request* failed. A module using both imports one of them under another name.
150 """
151
152
153@export
154class Protocols(Flag):
155 """
156 Enumeration of supported URL schemes.
157
158 The members are flags, so a scheme secured by a transport is the combination of that transport and the plain
159 protocol: :attr:`HTTPS` is :attr:`TLS` with :attr:`HTTP`, and :attr:`SFTP` is :attr:`SSH` carrying its own file
160 transfer. A scheme can therefore be asked which transport secures it, without enumerating every variant:
161
162 .. code-block:: Python
163
164 if Protocols.TLS in url.Scheme:
165 print(f"'{url}' is secured by TLS.")
166
167 if url.Scheme.IsEncrypted:
168 print(f"'{url}' is encrypted.")
169
170 .. attention::
171
172 :attr:`TLS` answers *"is this TLS"*, not *"is this encrypted"* - :attr:`SSH` and :attr:`SFTP` are encrypted
173 and carry no :attr:`TLS` flag. Use :attr:`IsEncrypted`, which asks for both.
174
175 :attr:`SFTP` is **not** :attr:`FTP` carried by :attr:`SSH`: it is a protocol of its own, defined as an SSH
176 subsystem and sharing nothing with FTP but its purpose. :attr:`FTP` secured by :attr:`TLS` is :attr:`FTPS`.
177
178 .. note::
179
180 :attr:`TCP` and :attr:`UDP` name a scheme written as ``tcp://host:port``, which is what ZeroMQ, the Docker
181 daemon and syslog configurations use. They do **not** combine with the other members the way :attr:`TLS` and
182 :attr:`SSH` do: ``http://`` runs over TCP without saying so, so :attr:`HTTP` carries no :attr:`TCP` flag.
183 ``tcp`` is not registered with IANA - ``udp`` is registered provisionally - but both appear often enough that
184 refusing them would be unhelpful.
185
186 .. caution::
187
188 The enumeration lists the schemes written as ``scheme://``, and :meth:`URL.Parse` raises
189 :exc:`URLError` for one it doesn't know - so a scheme missing here makes a URL unparseable. Schemes are added
190 as they are needed.
191
192 An **opaque** scheme - ``mailto:a@b.org``, ``urn:isbn:…``, and ``sip:alice@atlanta.com``, whose URI carries no
193 ``//`` - is a different matter: :data:`URL_PATTERN` only recognises a scheme before ``://``, so
194 :meth:`URL.Parse` rejects such a URI for its missing scheme. Listing it here would change nothing.
195 """
196
197 TLS = 1 #: Transport Layer Security
198 FILE = 2 #: Local files
199 HTTP = 4 #: Hyper Text Transfer Protocol
200 FTP = 8 #: File Transfer Protocol
201 WS = 16 #: WebSocket
202 SSH = 32 #: Secure Shell
203 GIT = 64 #: Git's own transport
204 LDAP = 128 #: Lightweight Directory Access Protocol
205 TCP = 512 #: A raw TCP endpoint, the higher protocol unspecified - as ZeroMQ and Docker write it.
206 UDP = 1024 #: A raw UDP endpoint of an unspecified higher protocol.
207 UNIX = 2048 #: A local Unix domain socket, addressed by a file system path.
208 MQTT = 4096 #: Message Queuing Telemetry Transport
209 AMQP = 8192 #: Advanced Message Queuing Protocol - the protocol RabbitMQ speaks.
210 REDIS = 16384 #: Redis
211 MONGODB = 32768 #: MongoDB
212 POSTGRES = 65536 #: PostgreSQL
213
214 HTTPS = TLS | HTTP #: SSL/TLS secured HTTP: combination of :attr:`TLS` and :attr:`HTTP`.
215 FTPS = TLS | FTP #: SSL/TLS secured FTP: combination of :attr:`TLS` and :attr:`FTP`.
216 WSS = TLS | WS #: SSL/TLS secured WebSocket: combination of :attr:`TLS` and :attr:`WS`.
217 LDAPS = TLS | LDAP #: SSL/TLS secured LDAP: combination of :attr:`TLS` and :attr:`LDAP`.
218 SFTP = SSH | 256 #: SSH File Transfer Protocol, carried by :attr:`SSH`.
219 MQTTS = TLS | MQTT #: SSL/TLS secured MQTT: combination of :attr:`TLS` and :attr:`MQTT`.
220 AMQPS = TLS | AMQP #: SSL/TLS secured AMQP: combination of :attr:`TLS` and :attr:`AMQP`.
221 REDISS = TLS | REDIS #: SSL/TLS secured Redis: combination of :attr:`TLS` and :attr:`REDIS`.
222
223 POSTGRESQL = POSTGRES #: Alias of :attr:`POSTGRES`, the spelling ``libpq`` documents.
224
225 @readonly
226 def IsEncrypted(self) -> bool:
227 """
228 Read-only property to return whether the scheme is encrypted.
229
230 A scheme is encrypted when it is secured by :attr:`TLS` or carried by :attr:`SSH`, so this answers e.g.
231 for :attr:`HTTPS` or :attr:`SFTP` alike - which testing a single flag doesn't.
232
233 :returns: ``True``, if the scheme is secured by TLS or carried by SSH.
234 """
235 return bool(self & (Protocols.TLS | Protocols.SSH))
236
237
238@export
239class Host(RootMixin):
240 """Represents a host as either hostname, DNS or IP-address including the port number in a URL."""
241
242 _hostname: str #: Name of the host (DNS name or IP address).
243 _port: Nullable[int] #: Optional port number.
244
245 def __init__(
246 self,
247 hostname: str,
248 port: Nullable[int] = None
249 ) -> None:
250 """
251 Initialize a host instance described by host name and port number.
252
253 :param hostname: Name of the host (either IP address or DNS).
254 :param port: Optional, port number.
255 :raises ValueError: If parameter 'hostname' is None or empty.
256 :raises TypeError: If parameter 'hostname' is not of type :class:`str`.
257 :raises TypeError: If parameter 'port' is not of type :class:`int`.
258 :raises ValueError: If parameter 'port' is out of range 0..65535.
259 """
260 super().__init__()
261
262 if hostname is None:
263 raise ValueError("Parameter 'hostname' is None.")
264 elif not isinstance(hostname, str):
265 ex = TypeError("Parameter 'hostname' is not of type 'str'.")
266 ex.add_note(f"Got type '{getFullyQualifiedName(hostname)}'.")
267 raise ex
268 elif hostname == "":
269 raise ValueError("Parameter 'hostname' is empty.")
270
271 self._hostname = hostname
272
273 if port is None:
274 pass
275 elif not isinstance(port, int):
276 ex = TypeError("Parameter 'port' is not of type 'int'.")
277 ex.add_note(f"Got type '{getFullyQualifiedName(port)}'.")
278 raise ex
279 elif not (0 <= port < 65536):
280 ex = ValueError("Parameter 'port' is out of range 0..65535.")
281 ex.add_note(f"Got value '{port}'.")
282 raise ex
283
284 self._port = port
285
286 @readonly
287 def Hostname(self) -> str:
288 """
289 Read-only property to access the hostname.
290
291 :returns: Hostname as DNS name or IP address.
292 """
293 return self._hostname
294
295 @readonly
296 def UnicodeHostname(self) -> str:
297 """
298 Read-only property to return the hostname with its A-labels decoded to Unicode.
299
300 A URL carries an internationalized domain name (IDN) as A-labels, the ASCII form of IDNA 2008 (:rfc:`5890`), and
301 this property returns the Unicode form a user reads: ``www.xn--mller-kva.de`` |rarr| ``www.müller.de``. A label
302 that is no A-label - an ASCII label, an IP address - is returned unchanged. Only decoding an A-label needs the
303 ``idna`` package.
304
305 :returns: Hostname with its A-labels decoded to Unicode.
306 :raises MissingDependencyError: If the hostname holds an A-label and the ``idna`` extra isn't installed.
307 :raises URLError: If a label starts with ``xn--`` and is no A-label of IDNA 2008. |br|
308 The note gives the reason of the ``idna`` package.
309 """
310 if "xn--" not in self._hostname.lower():
311 return self._hostname
312
313 try:
314 from idna import IDNAError, decode as idnaDecode
315 except ImportError as ex:
316 raise MissingDependencyError(dependency="idna", extra="idna") from ex
317
318 labels = []
319 for label in self._hostname.split("."):
320 if label[:4].lower() == "xn--":
321 try:
322 labels.append(idnaDecode(label))
323 except IDNAError as cause:
324 ex = URLError(f"Label {label!r} of hostname {self._hostname!r} is no A-label of IDNA 2008.")
325 ex.add_note(f"IDNA: {cause}")
326 raise ex from cause
327 else:
328 labels.append(label)
329
330 return ".".join(labels)
331
332 @readonly
333 def Port(self) -> Nullable[int]:
334 """
335 Read-only property to access the optional port number.
336
337 :returns: Optional port number.
338 """
339 return self._port
340
341 def __str__(self) -> str:
342 """
343 Return a string representation of this host.
344
345 :returns: Hostname, followed by ``:port`` if a port is specified.
346 """
347 result = self._hostname
348 if self._port is not None:
349 result += f":{self._port}"
350
351 return result
352
353 def Copy(self) -> Host:
354 """
355 Create a copy of this object.
356
357 :returns: A new :class:`Host` instance.
358 """
359 return self.__class__(
360 self._hostname,
361 self._port
362 )
363
364
365@export
366class Element(ElementMixin):
367 """Derived class for the URL context."""
368
369
370@export
371class Path(PathMixin):
372 """Represents a path in a URL."""
373
374 ELEMENT_DELIMITER: ClassVar[str] = "/" #: Delimiter symbol in URLs between path elements.
375 ROOT_DELIMITER: ClassVar[str] = "/" #: Delimiter symbol in URLs between root and first element.
376 ELEMENT_TYPE: ClassVar[type[Element]] = Element #: Type an element of a URL's path has.
377
378
379@export
380class QueryParameter(metaclass=ExtendedType, slots=True):
381 """
382 Base-class of a parameter in a URL's query: a key with one value or with a sequence of values.
383
384 :rfc:`3986` puts no structure into a query. The common form are ``key=value`` pairs separated by ``&``, in which a
385 key may repeat - a form with a multi-select or a group of checkboxes sends ``tag=a&tag=b``. A key written once is a
386 :class:`KeyValuePair`, a key written several times a :class:`KeySequencePair`. Both answer :attr:`Value` and
387 :attr:`Values`, so a reader needn't know which one it got.
388 """
389
390 _key: str #: The parameter's name.
391
392 def __init__(self, key: str) -> None:
393 """
394 Initializes a query parameter by its key.
395
396 :param key: The parameter's name.
397 :raises ValueError: If parameter 'key' is ``None``.
398 :raises TypeError: If parameter 'key' is not of type :class:`str`.
399 """
400 if key is None:
401 raise ValueError("Parameter 'key' is None.")
402 elif not isinstance(key, str):
403 ex = TypeError("Parameter 'key' is not of type 'str'.")
404 ex.add_note(f"Got type '{getFullyQualifiedName(key)}'.")
405 raise ex
406
407 self._key = key
408
409 @readonly
410 def Key(self) -> str:
411 """
412 Read-only property to access the parameter's name (:attr:`_key`).
413
414 :returns: The parameter's name.
415 """
416 return self._key
417
418 @readonly
419 @abstractmethod
420 def Value(self) -> Nullable[str]: # type: ignore[empty-body]
421 """
422 Read-only property to return the parameter's value.
423
424 A parameter written without ``=`` (``?verbose``) has the value ``None``, one with an empty value (``?verbose=``)
425 the value ``""``. A key written several times answers its last value, as a dictionary of the pairs would.
426
427 :returns: The parameter's (last) value.
428 """
429
430 @readonly
431 @abstractmethod
432 def Values(self) -> tuple[Nullable[str], ...]: # type: ignore[empty-body]
433 """
434 Read-only property to return all values of the parameter, in the order they were written.
435
436 :returns: The parameter's values; one value for a key written once.
437 """
438
439 @abstractmethod
440 def __str__(self) -> str: # type: ignore[empty-body]
441 """
442 Return the parameter as it is written in a query.
443
444 :returns: ``key=value`` per value, joined by ``&``; ``key`` alone for the value ``None``.
445 """
446
447
448@export
449class KeyValuePair(QueryParameter):
450 """A parameter of a URL's query whose key is written once: ``?key=value``."""
451
452 _value: Nullable[str] #: The parameter's value; ``None`` for a parameter without ``=``.
453
454 def __init__(self, key: str, value: Nullable[str]) -> None:
455 """
456 Initializes a query parameter with one value.
457
458 :param key: The parameter's name.
459 :param value: The parameter's value. ``None`` is a parameter without ``=``.
460 :raises ValueError: If parameter 'key' is ``None``.
461 :raises TypeError: If parameter 'key' is not of type :class:`str`.
462 :raises TypeError: If parameter 'value' is not of type :class:`str`.
463 """
464 super().__init__(key)
465
466 if value is not None and not isinstance(value, str):
467 ex = TypeError("Parameter 'value' is not of type 'str'.")
468 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
469 raise ex
470
471 self._value = value
472
473 @readonly
474 def Value(self) -> Nullable[str]:
475 """
476 Read-only property to access the parameter's value (:attr:`_value`).
477
478 A parameter written without ``=`` (``?verbose``) has the value ``None``, one with an empty value (``?verbose=``)
479 the value ``""``.
480
481 :returns: The parameter's value.
482 """
483 return self._value
484
485 @readonly
486 def Values(self) -> tuple[Nullable[str], ...]:
487 """
488 Read-only property to return the parameter's value as a sequence of values.
489
490 :returns: A tuple holding the one value.
491 """
492 return (self._value, )
493
494 def __str__(self) -> str:
495 """
496 Return the parameter as it is written in a query.
497
498 :returns: ``key=value``, or ``key`` alone for the value ``None``.
499 """
500 return self._key if self._value is None else f"{self._key}={self._value}"
501
502
503@export
504class KeySequencePair(QueryParameter):
505 """A parameter of a URL's query whose key is written several times: ``?key=value1&key=value2``."""
506
507 _values: tuple[Nullable[str], ...] #: The parameter's values, in the order they were written.
508
509 def __init__(self, key: str, values: Iterable[Nullable[str]]) -> None:
510 """
511 Initializes a query parameter with a sequence of values.
512
513 :param key: The parameter's name.
514 :param values: The parameter's values, in the order they are written. ``None`` is a value without ``=``.
515 :raises ValueError: If parameter 'key' is ``None``.
516 :raises TypeError: If parameter 'key' is not of type :class:`str`.
517 :raises ValueError: If parameter 'values' is ``None``.
518 :raises TypeError: If parameter 'values' is not iterable.
519 :raises TypeError: If an element of parameter 'values' is not of type :class:`str`.
520 :raises ValueError: If parameter 'values' is empty.
521 """
522 super().__init__(key)
523
524 if values is None:
525 raise ValueError("Parameter 'values' is None.")
526 elif not isinstance(values, Iterable):
527 ex = TypeError("Parameter 'values' is not iterable.")
528 ex.add_note(f"Got type '{getFullyQualifiedName(values)}'.")
529 raise ex
530
531 valueTuple = tuple(values)
532 for value in valueTuple:
533 if value is not None and not isinstance(value, str):
534 ex = TypeError("Element of parameter 'values' is not of type 'str'.")
535 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
536 raise ex
537
538 if len(valueTuple) == 0:
539 raise ValueError("Parameter 'values' is empty.")
540
541 self._values = valueTuple
542
543 @readonly
544 def Value(self) -> Nullable[str]:
545 """
546 Read-only property to return the parameter's last value.
547
548 That is what a dictionary of the pairs would keep, so code reading one value per key gets the same result as
549 before the key was written twice. All values are in :attr:`Values`.
550
551 :returns: The parameter's last value.
552 """
553 return self._values[-1]
554
555 @readonly
556 def Values(self) -> tuple[Nullable[str], ...]:
557 """
558 Read-only property to access the parameter's values (:attr:`_values`).
559
560 :returns: The parameter's values, in the order they were written.
561 """
562 return self._values
563
564 def __str__(self) -> str:
565 """
566 Return the parameter as it is written in a query.
567
568 :returns: ``key=value`` per value, joined by ``&``; ``key`` alone for the value ``None``.
569 """
570 return "&".join(self._key if value is None else f"{self._key}={value}" for value in self._values)
571
572
573@export
574class URI(metaclass=ExtendedType, slots=True):
575 """
576 Represents a URI (Uniform Resource Identifier): a scheme, a scheme-specific part, a query and a fragment.
577
578 .. code-block::
579
580 scheme:scheme-specific-part[?query][#fragment]
581
582 This is the generic syntax of :rfc:`3986`, which accepts every scheme, also those naming no host: ``mailto:``,
583 ``tel:``, ``urn:``. A URI with an authority (``//host``) and a scheme of :class:`Protocols` is a :class:`URL`, which
584 derives from this class and splits the scheme-specific part into user, password, host and path.
585 :meth:`Parse` returns a :class:`URL` for such a URI.
586 """
587
588 _scheme: Union[str, Protocols] #: Scheme in lower case; a URL's Protocols member.
589 _schemeSpecificPart: Nullable[str] #: Scheme-specific part; ``None`` in a URL.
590 _query: Nullable[dict[str, QueryParameter]] #: Query parameters by name; ``None`` without query.
591 _fragment: Nullable[str] #: Fragment; ``None`` if the URI has none.
592
593 def __init__(
594 self,
595 scheme: Union[str, Protocols],
596 schemeSpecificPart: Nullable[str],
597 query: Nullable[Iterable[QueryParameter]] = None,
598 fragment: Nullable[str] = None
599 ) -> None:
600 """
601 Initializes a Uniform Resource Identifier (URI).
602
603 :param scheme: The scheme's name, e.g. ``mailto``; a :class:`URL` passes its :class:`Protocols` member.
604 :param schemeSpecificPart: The part between the scheme's ``:`` and the query, e.g. ``user@example.org``.
605 ``None`` is passed by a :class:`URL`, which computes it from its parts.
606 :param query: Optional, the query's parameters. An empty iterable is an empty query (``?``).
607 :param fragment: Optional, fragment.
608 :raises ValueError: If parameter 'scheme' is ``None``.
609 :raises TypeError: If parameter 'scheme' is not of type :class:`str`.
610 :raises ValueError: If parameter 'scheme' is no scheme name of :rfc:`3986`.
611 :raises TypeError: If parameter 'schemeSpecificPart' is not of type :class:`str`.
612 :raises TypeError: If parameter 'query' is not iterable.
613 :raises TypeError: If an element of parameter 'query' is not of type :class:`QueryParameter`.
614 :raises ValueError: If parameter 'query' holds a key twice. |br|
615 A key with several values is a :class:`KeySequencePair`.
616 :raises TypeError: If parameter 'fragment' is not of type :class:`str`.
617 """
618 if scheme is None:
619 raise ValueError("Parameter 'scheme' is None.")
620 elif isinstance(scheme, Protocols):
621 self._scheme = scheme
622 elif not isinstance(scheme, str):
623 ex = TypeError("Parameter 'scheme' is not of type 'str'.")
624 ex.add_note(f"Got type '{getFullyQualifiedName(scheme)}'.")
625 raise ex
626 elif _SCHEME.fullmatch(scheme) is None:
627 ex = ValueError("Parameter 'scheme' is no scheme name.")
628 ex.add_note(f"Got value '{scheme}'. A scheme starts with a letter, followed by letters, digits, '+', '-' or '.'.")
629 raise ex
630 else:
631 self._scheme = scheme.lower()
632
633 if schemeSpecificPart is not None and not isinstance(schemeSpecificPart, str):
634 ex = TypeError("Parameter 'schemeSpecificPart' is not of type 'str'.")
635 ex.add_note(f"Got type '{getFullyQualifiedName(schemeSpecificPart)}'.")
636 raise ex
637
638 self._schemeSpecificPart = schemeSpecificPart
639
640 if query is not None:
641 if not isinstance(query, Iterable):
642 ex = TypeError("Parameter 'query' is not iterable.")
643 ex.add_note(f"Got type '{getFullyQualifiedName(query)}'.")
644 raise ex
645
646 self._query = parameters = {}
647 for parameter in query:
648 if not isinstance(parameter, QueryParameter):
649 ex = TypeError("Element of parameter 'query' is not of type 'QueryParameter'.")
650 ex.add_note(f"Got type '{getFullyQualifiedName(parameter)}'.")
651 raise ex
652 elif parameter._key in parameters:
653 ex = ValueError(f"Parameter 'query' has key '{parameter._key}' twice.")
654 ex.add_note("A key with several values is a 'KeySequencePair'.")
655 raise ex
656
657 parameters[parameter._key] = parameter
658 else:
659 self._query = None
660
661 if fragment is not None and not isinstance(fragment, str):
662 ex = TypeError("Parameter 'fragment' is not of type 'str'.")
663 ex.add_note(f"Got type '{getFullyQualifiedName(fragment)}'.")
664 raise ex
665
666 self._fragment = fragment
667
668 @readonly
669 def Scheme(self) -> str:
670 """
671 Read-only property to access the scheme (:attr:`_scheme`).
672
673 :returns: The scheme's name in lower case, e.g. ``mailto``.
674 """
675 return self._scheme # type: ignore[return-value]
676
677 @readonly
678 def SchemeSpecificPart(self) -> str:
679 """
680 Read-only property to access the scheme-specific part (:attr:`_schemeSpecificPart`).
681
682 :returns: The part between the scheme's ``:`` and the query, e.g. ``user@example.org`` in
683 ``mailto:user@example.org``.
684 """
685 return self._schemeSpecificPart # type: ignore[return-value]
686
687 @readonly
688 def Query(self) -> Nullable[dict[str, QueryParameter]]:
689 """
690 Read-only property to access the query's parameters by key (:attr:`_query`).
691
692 A key written once is a :class:`KeyValuePair`, a key written several times (``?tag=a&tag=b``) a
693 :class:`KeySequencePair`; both answer :attr:`~QueryParameter.Value` and :attr:`~QueryParameter.Values`. A
694 parameter written without ``=`` (``?verbose``) has the value ``None``, one with an empty value (``?verbose=``)
695 the value ``""``. A URL with an empty query (``https://example.org/?``) has an empty dictionary, a URL without
696 a query ``None``.
697
698 :returns: The query's parameters by key.
699 """
700 return self._query
701
702 @readonly
703 def Fragment(self) -> Nullable[str]:
704 """
705 Read-only property to access the fragment (:attr:`_fragment`).
706
707 :returns: The fragment, ``None`` if the URI has none.
708 """
709 return self._fragment
710
711 @classmethod
712 def Parse(cls, uri: str) -> URI:
713 """
714 Parse a URI string and return the URI object - a :class:`URL`, if the URI is one.
715
716 A URI is read by the generic syntax of :rfc:`3986` (:data:`URI_PATTERN`): a scheme, ``:``, the scheme-specific
717 part, an optional query and an optional fragment. Every scheme is accepted. If the scheme-specific part starts
718 with ``//`` (an authority) and the scheme is one of :class:`Protocols`, the URI is a URL and is read by
719 :meth:`URL.Parse`, with that method's checks; ``URI.Parse("https://example.org/")`` returns a :class:`URL`,
720 ``URI.Parse("mailto:user@example.org")`` a :class:`URI`. A URI of the scheme ``urn`` is read by
721 :meth:`URN.Parse` and returned as a :class:`URN`.
722
723 :param uri: The URI.
724 :returns: The URI object, a :class:`URL` for a URL, a :class:`URN` for a URN.
725 :raises ValueError: If parameter 'uri' is ``None``.
726 :raises TypeError: If parameter 'uri' is not of type :class:`str`.
727 :raises URLError: When the URI holds a character :rfc:`3986` forbids, which is not allowed in a URI. |br|
728 The note names the character and how to write it percent-encoded.
729 :raises URLError: When the URI holds a non-ASCII character.
730 :raises URLError: When the URI doesn't start with a scheme followed by ``:``.
731 :raises URLError: When a URL or a URN doesn't follow its syntax, as :meth:`URL.Parse` and :meth:`URN.Parse`
732 report it.
733 """
734 if uri is None:
735 raise ValueError("Parameter 'uri' is None.")
736 elif not isinstance(uri, str):
737 ex = TypeError("Parameter 'uri' is not of type 'str'.")
738 ex.add_note(f"Got type '{getFullyQualifiedName(uri)}'.")
739 raise ex
740
741 for character in uri:
742 if character in FORBIDDEN_CHARACTERS or character.isspace() or not character.isprintable():
743 ex = URLError(f"Forbidden character when parsing URI {uri!r}.")
744 ex.add_note(
745 f"Character {character!r} is not allowed in a URI. Write it percent-encoded as "
746 f"'{urlQuote(character, safe='')}'."
747 )
748 raise ex
749
750 if not uri.isascii():
751 raise URLError(f"Non-ASCII character when parsing URI {uri!r}.")
752 elif (matches := URI_REGEXP.match(uri)) is None:
753 ex = URLError(f"Missing scheme when parsing URI {uri!r}.")
754 ex.add_note("A URI starts with a scheme followed by ':', e.g. 'mailto:'.")
755 raise ex
756
757 scheme = matches.group("scheme")
758 schemeSpecificPart = matches.group("schemeSpecificPart")
759 if schemeSpecificPart.startswith("//") and scheme.upper() in Protocols.__members__:
760 return URL.Parse(uri)
761 elif scheme.lower() == "urn":
762 return URN.Parse(uri)
763
764 query = matches.group("query")
765 return URI(
766 scheme,
767 schemeSpecificPart,
768 None if query is None else cls._ParseQuery(query),
769 matches.group("fragment")
770 )
771
772 @classmethod
773 def _ParseQuery(cls, query: str) -> list[QueryParameter]:
774 """
775 Parse a URI's query into its parameters.
776
777 :rfc:`3986` puts no structure into a query; the common ``key=value`` pairs separated by ``&`` are read. A key
778 written once becomes a :class:`KeyValuePair`, a key written several times a :class:`KeySequencePair` holding
779 its values in order, at the position of its first occurrence. A parameter without ``=`` has the value ``None``;
780 an empty query (``?``) has no parameters.
781
782 :param query: The query, without the leading ``?``.
783 :returns: The parameters, in the order their keys first occur.
784 """
785 if query == "":
786 return []
787
788 values: dict[str, list[Nullable[str]]] = {}
789 for pair in query.split("&"):
790 key, separator, value = pair.partition("=")
791 if key in values:
792 values[key].append(value if separator == "=" else None)
793 else:
794 values[key] = [value if separator == "=" else None]
795
796 return [
797 KeyValuePair(key, valueList[0]) if len(valueList) == 1 else KeySequencePair(key, valueList)
798 for key, valueList in values.items()
799 ]
800
801 def _FormatQueryAndFragment(self) -> str:
802 """
803 Format the query and the fragment as they follow the rest of a URI.
804
805 :returns: ``?query`` and ``#fragment``, each only if the URI has it.
806 """
807 result = ""
808 if self._query is not None:
809 result = "?" + "&".join(str(parameter) for parameter in self._query.values())
810
811 if self._fragment is not None:
812 result = result + "#" + self._fragment
813
814 return result
815
816 def __str__(self) -> str:
817 """
818 Formats the URI as it is written: ``scheme:scheme-specific-part[?query][#fragment]``.
819
820 :returns: The URI.
821 """
822 return f"{self._scheme}:{self._schemeSpecificPart}{self._FormatQueryAndFragment()}"
823
824
825_Host = Host
826"""Alias of :class:`Host` for annotations in a class with a property named ``Host``, which the class body binds."""
827
828_Path = Path
829"""Alias of :class:`Path` for annotations in a class with a property named ``Path``, which the class body binds."""
830
831
832@export
833class URL(URI):
834 """
835 Represents a URL (Uniform Resource Locator) including scheme, host, credentials, path, query and fragment.
836
837 .. code-block::
838
839 scheme://[user[:password]@]domain.tld[:port][/path/to/file][?query][#fragment]
840
841 The syntax is the one of :rfc:`3986` (an absolute URI with an authority); ``file`` URLs follow :rfc:`8089`. A URL is
842 a :class:`URI` whose scheme is one of :class:`Protocols` and whose scheme-specific part is split into user,
843 password, host and path.
844 """
845
846 _user: Nullable[str] #: User name of the URL's authority part.
847 _password: Nullable[str] #: Password of the URL's authority part.
848 _host: Nullable[_Host] #: Host name and port of the URL's authority part.
849 _path: _Path #: Path part of the URL.
850
851 def __init__(
852 self,
853 scheme: Protocols,
854 path: Path,
855 host: Nullable[Host] = None,
856 user: Nullable[str] = None,
857 password: Nullable[str] = None,
858 query: Nullable[Iterable[QueryParameter]] = None,
859 fragment: Nullable[str] = None
860 ) -> None:
861 """
862 Initializes a Uniform Resource Locator (URL).
863
864 :param scheme: Transport scheme to be used for a specified resource.
865 :param path: Path to the resource.
866 :param host: Optional, hostname where the resource is located.
867 :param user: Optional, username for basic authentication.
868 :param password: Optional, password for basic authentication.
869 :param query: Optional, the query's parameters. An empty iterable is an empty query (``?``).
870 :param fragment: Optional, fragment.
871 :raises ValueError: If parameter 'scheme' is ``None``.
872 :raises TypeError: If parameter 'scheme' is not of type :class:`Protocols`.
873 :raises TypeError: If parameter 'path' is not of type :class:`Path`.
874 :raises TypeError: If parameter 'host' is not of type :class:`Host`.
875 :raises TypeError: If parameter 'user' is not of type :class:`str`.
876 :raises TypeError: If parameter 'password' is not of type :class:`str`.
877 :raises TypeError: If parameter 'query' is not iterable.
878 :raises TypeError: If an element of parameter 'query' is not of type :class:`QueryParameter`.
879 :raises ValueError: If parameter 'query' holds a key twice. |br|
880 A key with several values is a :class:`KeySequencePair`.
881 :raises TypeError: If parameter 'fragment' is not of type :class:`str`.
882 """
883 super().__init__(scheme, None, query, fragment)
884
885 if not isinstance(scheme, Protocols):
886 ex = TypeError("Parameter 'scheme' is not of type 'Protocols'.")
887 ex.add_note(f"Got type '{getFullyQualifiedName(scheme)}'.")
888 raise ex
889
890 if user is not None and not isinstance(user, str):
891 ex = TypeError("Parameter 'user' is not of type 'str'.")
892 ex.add_note(f"Got type '{getFullyQualifiedName(user)}'.")
893 raise ex
894
895 self._user = user
896
897 if password is not None and not isinstance(password, str):
898 ex = TypeError("Parameter 'password' is not of type 'str'.")
899 ex.add_note(f"Got type '{getFullyQualifiedName(password)}'.")
900 raise ex
901
902 self._password = password
903
904 if host is not None and not isinstance(host, Host):
905 ex = TypeError("Parameter 'host' is not of type 'Host'.")
906 ex.add_note(f"Got type '{getFullyQualifiedName(host)}'.")
907 raise ex
908 self._host = host
909
910 if path is not None and not isinstance(path, Path):
911 ex = TypeError("Parameter 'path' is not of type 'Path'.")
912 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
913 raise ex
914
915 self._path = path
916
917 @readonly
918 def Scheme(self) -> Protocols: # type: ignore[override]
919 """
920 Read-only property to access the URL scheme (:attr:`_scheme`).
921
922 A :class:`URI` answers its scheme's name; a URL answers the member of :class:`Protocols`, which also tells
923 whether the scheme is secured (:attr:`~Protocols.IsEncrypted`).
924
925 :returns: URL scheme of the URL.
926 """
927 return self._scheme # type: ignore[return-value]
928
929 @readonly
930 def SchemeSpecificPart(self) -> str:
931 """
932 Read-only property to return the scheme-specific part, computed from the URL's parts.
933
934 :returns: ``//``, the user information, the host and the path, e.g. ``//user@example.org:8080/api``.
935 """
936 result = str(self._path)
937
938 if self._host is not None:
939 result = str(self._host) + result
940
941 if self._user is not None:
942 if self._password is not None:
943 result = f"{self._user}:{self._password}@{result}"
944 else:
945 result = f"{self._user}@{result}"
946
947 return "//" + result
948
949 @readonly
950 def User(self) -> Nullable[str]:
951 """
952 Read-only property to access the optional username.
953
954 :returns: Optional username within the URL.
955 """
956 return self._user
957
958 @readonly
959 def Password(self) -> Nullable[str]:
960 """
961 Read-only property to access the optional password.
962
963 :returns: Optional password within a URL.
964 """
965 return self._password
966
967 @readonly
968 def Host(self) -> Nullable[Host]:
969 """
970 Read-only property to access the host part (hostname and port number) of the URL.
971
972 :returns: The host part of the URL.
973 """
974 return self._host
975
976 @readonly
977 def Path(self) -> Path:
978 """
979 Read-only property to access the path part of the URL.
980
981 :returns: Path part of the URL.
982 """
983 return self._path
984
985 # http://semaphore.plc2.de:5000/api/v1/semaphore?name=Riviera&foo=bar#page2
986 @classmethod
987 def Parse( # type: ignore[override]
988 cls,
989 url: str,
990 *,
991 iri: bool = False,
992 defaultScheme: Nullable[Protocols] = None
993 ) -> URL:
994 """
995 Parse a URL string and returns the URL object.
996
997 A URL starts with a scheme followed by ``://`` and a host, as :rfc:`3986` writes an absolute URI with an
998 authority: ``https://example.org/path``. After the host and port, a path starts with a slash. Only a ``file`` or
999 ``unix`` URL may leave the host empty, and then names an absolute path: ``file:///etc/hosts``.
1000
1001 With ``iri``, the string is read as an IRI (Internationalized Resource Identifier, :rfc:`3987`), which may hold
1002 Unicode characters. A Unicode host is converted to its A-labels by IDNA 2008 (:rfc:`5891`) after the mapping of
1003 `UTS #46 <https://www.unicode.org/reports/tr46/>`__, and every other non-ASCII character is percent-encoded as
1004 UTF-8 (:rfc:`RFC 3987, section 3.1 <3987#section-3.1>`). The URL object is an :rfc:`3986` URL either way:
1005 ``https://www.müller.de/`` is read as ``https://www.xn--mller-kva.de/``. An error found in the converted URL
1006 names that URL, and a note names the IRI it was converted from.
1007
1008 With ``defaultScheme``, a URL without ``scheme://`` gets that scheme prepended, as a browser does with what is typed
1009 into its address bar: ``www.example.org/path`` is read as ``https://www.example.org/path`` with
1010 ``defaultScheme=Protocols.HTTPS``, ``//example.org/path`` (a network-path reference) as
1011 ``https://example.org/path``. An error found in the completed URL names that URL, and a note names the scheme
1012 that was prepended. Strict parsing stays the default.
1013
1014 :param url: URL as string to be parsed.
1015 :param iri: Optional, read ``url`` as an IRI (Internationalized Resource Identifier,
1016 :rfc:`3987`) instead of a URL. Default: ``False``.
1017 :param defaultScheme: Optional, scheme prepended to a URL without ``scheme://``. Default: ``None``, such a
1018 URL raises.
1019 :returns: A URL object.
1020 :raises ValueError: If parameter 'url' is ``None``.
1021 :raises TypeError: If parameter 'url' is not of type :class:`str`.
1022 :raises ValueError: If parameter 'iri' is ``None``.
1023 :raises TypeError: If parameter 'iri' is not of type :class:`bool`.
1024 :raises TypeError: If parameter 'defaultScheme' is not of type :class:`Protocols`.
1025 :raises URLError: When the URL holds a character :rfc:`3986` forbids. |br|
1026 The note names the character and how to percent-encode it.
1027 :raises MissingDependencyError: When ``iri`` is set, the host holds a Unicode character, and the ``idna`` extra
1028 isn't installed.
1029 :raises URLError: When ``iri`` is set and IDNA 2008 rejects the host. |br|
1030 The note gives the reason of the ``idna`` package.
1031 :raises URLError: When the URL doesn't start with a scheme followed by ``://``. |br|
1032 For an SCP-like address (``user@host:path``), the notes offer
1033 :meth:`SCPAddress.Parse` and give its ``ssh://`` URL.
1034 :raises URLError: When a ``file`` URL names a drive letter as its host. |br|
1035 The note gives the URL with the drive letter in the path.
1036 :raises URLError: When the URL has a ``:`` after the host, but no port. |br|
1037 The note gives the URL without the ``:``.
1038 :raises URLError: When the port isn't a number. |br|
1039 The note gives the URL with a path after ``/`` instead.
1040 :raises URLError: When syntax does not match. |br|
1041 If the URL holds a Unicode character, the note offers ``iri=True``.
1042 :raises URLError: When the URL names a scheme that is not in :class:`Protocols`. |br|
1043 The note lists the known schemes.
1044 :raises URLError: When the URL names no host, and isn't a ``file`` or ``unix`` URL without user and
1045 port.
1046 :raises URLError: When a ``file`` or ``unix`` URL names neither a host nor a path.
1047 :raises URLError: When the port is out of range 0..65535.
1048 """
1049 if url is None:
1050 raise ValueError("Parameter 'url' is None.")
1051 elif not isinstance(url, str):
1052 ex = TypeError("Parameter 'url' is not of type 'str'.")
1053 ex.add_note(f"Got type '{getFullyQualifiedName(url)}'.")
1054 raise ex
1055
1056 if iri is None:
1057 raise ValueError("Parameter 'iri' is None.")
1058 elif not isinstance(iri, bool):
1059 ex = TypeError("Parameter 'iri' is not of type 'bool'.")
1060 ex.add_note(f"Got type '{getFullyQualifiedName(iri)}'.")
1061 raise ex
1062
1063 if defaultScheme is not None:
1064 if not isinstance(defaultScheme, Protocols):
1065 ex = TypeError("Parameter 'defaultScheme' is not of type 'Protocols'.")
1066 ex.add_note(f"Got type '{getFullyQualifiedName(defaultScheme)}'.")
1067 raise ex
1068 elif _SCHEME_PREFIX.match(url) is None:
1069 scheme = defaultScheme.name.lower() # type: ignore[union-attr]
1070 completed = f"{scheme}:{url}" if url.startswith("//") else f"{scheme}://{url}"
1071 try:
1072 return cls.Parse(completed, iri=iri)
1073 except URLError as ex:
1074 ex.add_note(f"Scheme '{scheme}' was prepended to {url!r}.")
1075 raise
1076
1077 for character in url:
1078 if character in FORBIDDEN_CHARACTERS or character.isspace() or not character.isprintable():
1079 ex = URLError(f"Forbidden character when parsing URL {url!r}.")
1080 ex.add_note(
1081 f"Character {character!r} is not allowed in a URL. Write it percent-encoded as "
1082 f"'{urlQuote(character, safe='')}'."
1083 )
1084 raise ex
1085
1086 if iri and not url.isascii() and (parts := _IRI_HOST.match(url)) is not None:
1087 host = parts["host"]
1088 if not host.isascii():
1089 try:
1090 from idna import IDNAError, encode as idnaEncode
1091 except ImportError as ex:
1092 raise MissingDependencyError(dependency="idna", extra="idna") from ex
1093
1094 try:
1095 host = idnaEncode(host, uts46=True).decode("ascii")
1096 except IDNAError as cause:
1097 ex = URLError(f"Host {host!r} is no domain name of IDNA 2008 when parsing IRI {url!r}.")
1098 ex.add_note(f"IDNA: {cause}")
1099 raise ex from cause
1100
1101 prefix = urlQuote(parts["prefix"], safe=punctuation)
1102 rest = urlQuote(parts["rest"], safe=punctuation)
1103 try:
1104 return cls.Parse(prefix + host + rest)
1105 except URLError as ex:
1106 ex.add_note(f"Converted from IRI {url!r}.")
1107 raise
1108
1109 if (matches := URL_REGEXP.match(url)) is not None:
1110 scheme = matches.group("scheme")
1111 user = matches.group("user")
1112 password = matches.group("password")
1113 host = matches.group("host")
1114
1115 port = matches.group("port")
1116 if port is not None:
1117 port = int(port)
1118 path = matches.group("path")
1119 query = matches.group("query")
1120 fragment = matches.group("fragment")
1121
1122 try:
1123 scheme = Protocols[scheme.upper()]
1124 except KeyError as ex:
1125 error = URLError(f"Unknown scheme '{scheme}' when parsing URL '{url}'.")
1126 error.add_note(f"Known schemes: {', '.join(name.lower() for name in Protocols.__members__)}.")
1127 raise error from ex
1128
1129 if host is None:
1130 if user is not None or port is not None or scheme not in (Protocols.FILE, Protocols.UNIX):
1131 ex = URLError(f"Missing host when parsing URL {url!r}.")
1132 ex.add_note("Only a 'file' or 'unix' URL may leave the host empty, e.g. 'file:///etc/hosts'.")
1133 raise ex
1134 elif path == "":
1135 ex = URLError(f"Missing path when parsing URL {url!r}.")
1136 ex.add_note("A 'file' or 'unix' URL without a host names an absolute path, e.g. 'file:///etc/hosts'.")
1137 raise ex
1138
1139 if port is not None and port > 65535:
1140 raise URLError(f"Port {port} is out of range 0..65535 when parsing URL {url!r}.")
1141
1142 hostObj = None if host is None else Host(host, port)
1143 pathObj = Path.Parse(path, hostObj)
1144 parameters = None if query is None else cls._ParseQuery(query)
1145
1146 return cls(
1147 scheme,
1148 pathObj,
1149 hostObj,
1150 user,
1151 password,
1152 parameters,
1153 fragment
1154 )
1155
1156 if _SCHEME_PREFIX.match(url) is None:
1157 ex = URLError(f"Missing scheme when parsing URL {url!r}.")
1158 if (
1159 (scpLike := SCP_ADDRESS_REGEXP.match(url)) is not None
1160 and scpLike.group("user") is not None
1161 and len(scpLike.group("host")) > 1
1162 ):
1163 ex.add_note("An SCP-like address 'user@host:path' (Git, scp) is no URL. Read it with 'SCPAddress.Parse'.")
1164 ex.add_note(f"As URL: '{SCPAddress.Parse(url).ToURL()}'.")
1165 else:
1166 ex.add_note("A URL starts with a scheme followed by '://', e.g. 'https://'.")
1167 raise ex
1168
1169 if (portMatch := _PORT.match(url)) is not None and not portMatch.group("port").isdigit():
1170 port = portMatch.group("port")
1171 prefix = portMatch.group("prefix")
1172 host = portMatch.group("host")
1173 if port == "" and len(host) == 1 and host.isalpha() and portMatch.group("scheme").lower() == "file":
1174 ex = URLError(f"Drive letter '{host}' as host when parsing URL {url!r}.")
1175 ex.add_note(f"A drive letter belongs to the path: 'file:///{host}:{url[portMatch.end():]}'.")
1176 elif port == "":
1177 ex = URLError(f"Missing port after ':' when parsing URL {url!r}.")
1178 ex.add_note(f"Without ':', the URL is '{prefix}{url[portMatch.end():]}'.")
1179 else:
1180 ex = URLError(f"Port '{port}' is not a number when parsing URL {url!r}.")
1181 ex.add_note(f"With a path after '/' instead, the URL is '{prefix}/{url[portMatch.start('port'):]}'.")
1182 raise ex
1183
1184 ex = URLError(f"Syntax error when parsing URL {url!r}.")
1185 if not url.isascii():
1186 ex.add_note("A URL holds ASCII characters only. Read an IRI, which holds Unicode characters, with 'iri=True'.")
1187 raise ex
1188
1189 @classmethod
1190 def _CheckRelativeReference(cls, reference: str) -> None:
1191 """
1192 Check a string names a resource below a URL.
1193
1194 A relative reference of :rfc:`3986` names no scheme and no authority: it starts with no ``//``, and the first
1195 segment of its path holds no ``:``, which is the ``path-noscheme`` rule. A complete URL is read by
1196 :meth:`Parse` instead of being appended to another.
1197
1198 :param reference: The right side of the ``/`` operator.
1199 :raises URLError: When the reference names a scheme or an authority.
1200 :raises URLError: When the reference holds a character :rfc:`3986` forbids. |br|
1201 The note names the character and how to percent-encode it.
1202 """
1203 path = reference.partition("#")[0].partition("?")[0]
1204 if path.startswith("//"):
1205 error = URLError(f"Relative reference '{reference}' names an authority.")
1206 error.add_note("A reference below a URL carries no host, user, password or port.")
1207 error.add_note("Read a complete URL with 'URL.Parse' instead of appending it.")
1208 raise error
1209 elif ":" in path.split(Path.ELEMENT_DELIMITER, 1)[0]:
1210 error = URLError(f"Relative reference '{reference}' names a scheme.")
1211 error.add_note("The first element of a relative path carries no ':' - it would read as a scheme.")
1212 error.add_note("Read a complete URL with 'URL.Parse' instead of appending it. Write a ':' further down "
1213 "the path percent-encoded as '%3A'.")
1214 raise error
1215
1216 for character in reference:
1217 if character in FORBIDDEN_CHARACTERS or character.isspace() or not character.isprintable():
1218 error = URLError(f"Relative reference '{reference}' holds a forbidden character.")
1219 error.add_note(
1220 f"Character {character!r} is not allowed in a URL. Write it percent-encoded as "
1221 f"'{urlQuote(character, safe='')}'."
1222 )
1223 raise error
1224
1225 def __truediv__(self, other: Union[str, Path]) -> URL:
1226 """
1227 Return this URL with a resource below it.
1228
1229 The right side is a **relative reference**: its path is appended below this URL's, and it brings its own query
1230 and fragment, which this URL's are not carried into - :rfc:`3986` resolves a reference the same way. A path
1231 that starts with a slash names where it starts itself and replaces this URL's path.
1232
1233 .. code-block:: python
1234
1235 URL.Parse("https://example.org/api/v3") / "things/4711?fields=name"
1236 # https://example.org/api/v3/things/4711?fields=name
1237
1238 :param other: The resource below this URL, as a string to parse or as a :class:`Path`.
1239 :returns: A new URL naming that resource.
1240 :raises TypeError: If parameter 'other' is neither of type :class:`str` nor of type :class:`Path`.
1241 :raises URLError: When the right side names a scheme or an authority instead of a resource.
1242 :raises URLError: When the right side holds a character :rfc:`3986` forbids. |br|
1243 The note names the character and how to percent-encode it.
1244 """
1245 if isinstance(other, str):
1246 self._CheckRelativeReference(other)
1247
1248 resource, fragmentSeparator, fragment = other.partition("#")
1249 resource, querySeparator, query = resource.partition("?")
1250 parameters = None if querySeparator == "" else self._ParseQuery(query)
1251 fragment = None if fragmentSeparator == "" else fragment
1252 elif isinstance(other, Path):
1253 resource = other
1254 parameters = None
1255 fragment = None
1256 else:
1257 ex = TypeError("Second operand is not supported by / operator.")
1258 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
1259 ex.add_note("Supported types for second operand: 'str' or 'Path'.")
1260 raise ex
1261
1262 return self.__class__(
1263 scheme=self._scheme,
1264 path=self._path / resource,
1265 host=self._host,
1266 user=self._user,
1267 password=self._password,
1268 query=parameters,
1269 fragment=fragment
1270 )
1271
1272 def __str__(self) -> str:
1273 """
1274 Formats the URL object as a string representation.
1275
1276 :returns: Formatted URL object.
1277 """
1278 result = self.SchemeSpecificPart
1279
1280 # 'Protocols' is a 'Flag', so a single member and a combination both have a name; only 'Protocols(0)' has
1281 # none - and a scheme without any flag is no scheme, so it renders nothing either way.
1282 if (scheme := self._scheme.name) is not None: # type: ignore[union-attr]
1283 result = scheme.lower() + ":" + result
1284
1285 return result + self._FormatQueryAndFragment()
1286
1287 def WithoutCredentials(self) -> URL:
1288 """
1289 Returns a URL object without credentials (username and password).
1290
1291 :returns: New URL object without credentials.
1292 """
1293 return self.__class__(
1294 scheme=self._scheme,
1295 path=self._path,
1296 host=self._host,
1297 query=None if self._query is None else self._query.values(),
1298 fragment=self._fragment
1299 )
1300
1301 def WithoutTrailingSlash(self) -> URL:
1302 """
1303 Returns a URL object whose path doesn't end in a slash.
1304
1305 A URL's element delimiter is the slash, so this is :meth:`~pyTooling.GenericPath.PathMixin.WithoutTrailingDelimiter`
1306 applied to the URL's path. A trailing slash is an empty last element, so ``https://example.org/api/v3/`` and
1307 ``https://example.org/api/v3`` differ although they usually address the same resource. A URL that is composed
1308 with a path below it wants the latter, or the composition yields a double slash.
1309
1310 :returns: New URL object without a trailing slash, or this URL, if its path has none.
1311
1312 .. seealso::
1313
1314 :meth:`~pyTooling.GenericPath.PathMixin.WithoutTrailingDelimiter`
1315 |rarr| What it does to the path, and what it leaves alone.
1316 """
1317 if (path := self._path.WithoutTrailingDelimiter()) is self._path:
1318 return self
1319
1320 return self.__class__(
1321 scheme=self._scheme,
1322 path=path,
1323 host=self._host,
1324 user=self._user,
1325 password=self._password,
1326 query=None if self._query is None else self._query.values(),
1327 fragment=self._fragment
1328 )
1329
1330
1331@export
1332class URN(URI):
1333 """
1334 Represents a URN (Uniform Resource Name, :rfc:`8141`): a namespace and a name in it, which names a resource without
1335 locating it.
1336
1337 .. code-block::
1338
1339 urn:<namespace-id>:<namespace-specific-string>[?+r-component][?=q-component][#f-component]
1340
1341 The namespace identifier (``isbn``, ``ietf``, ``uuid``, ...) decides the structure of the namespace-specific string;
1342 a ``:`` in it separates parts only by that namespace's rules, so a URN is no hierarchical path. The r-component
1343 (``?+``) carries parameters for a resolver, the q-component (``?=``) parameters for the named resource - read as
1344 ``key=value`` pairs into :attr:`Query` -, and the f-component (``#``) is a fragment.
1345 """
1346
1347 _namespaceID: str #: Namespace identifier, e.g. ``isbn``.
1348 _namespaceSpecificString: str #: Name within the namespace, e.g. ``0451450523``.
1349 _resolution: Nullable[str] #: The r-component, without ``?+``; ``None`` if the URN has none.
1350
1351 def __init__(
1352 self,
1353 namespaceID: str,
1354 namespaceSpecificString: str,
1355 resolution: Nullable[str] = None,
1356 query: Nullable[Iterable[QueryParameter]] = None,
1357 fragment: Nullable[str] = None
1358 ) -> None:
1359 """
1360 Initializes a Uniform Resource Name (URN).
1361
1362 :param namespaceID: Namespace identifier, e.g. ``isbn``.
1363 :param namespaceSpecificString: Name within the namespace, e.g. ``0451450523``.
1364 :param resolution: Optional, the r-component, without ``?+``.
1365 :param query: Optional, the q-component's parameters.
1366 :param fragment: Optional, the f-component.
1367 :raises ValueError: If parameter 'namespaceID' is ``None``.
1368 :raises TypeError: If parameter 'namespaceID' is not of type :class:`str`.
1369 :raises ValueError: If parameter 'namespaceID' is no namespace identifier of :rfc:`8141`. |br|
1370 A namespace identifier has 2 to 32 letters, digits or ``-``, and neither starts nor
1371 ends with ``-``.
1372 :raises ValueError: If parameter 'namespaceSpecificString' is ``None``.
1373 :raises TypeError: If parameter 'namespaceSpecificString' is not of type :class:`str`.
1374 :raises ValueError: If parameter 'namespaceSpecificString' is empty.
1375 :raises TypeError: If parameter 'resolution' is not of type :class:`str`.
1376 :raises TypeError: If parameter 'query' is not iterable.
1377 :raises TypeError: If an element of parameter 'query' is not of type :class:`QueryParameter`.
1378 :raises ValueError: If parameter 'query' holds a key twice. |br|
1379 A key with several values is a :class:`KeySequencePair`.
1380 :raises TypeError: If parameter 'fragment' is not of type :class:`str`.
1381 """
1382 super().__init__("urn", None, query, fragment)
1383
1384 if namespaceID is None:
1385 raise ValueError("Parameter 'namespaceID' is None.")
1386 elif not isinstance(namespaceID, str):
1387 ex = TypeError("Parameter 'namespaceID' is not of type 'str'.")
1388 ex.add_note(f"Got type '{getFullyQualifiedName(namespaceID)}'.")
1389 raise ex
1390 elif _NAMESPACE_ID.fullmatch(namespaceID) is None:
1391 ex = ValueError("Parameter 'namespaceID' is no namespace identifier.")
1392 ex.add_note(
1393 f"Got value '{namespaceID}'. A namespace identifier has 2 to 32 letters, digits or '-', and neither starts "
1394 f"nor ends with '-'."
1395 )
1396 raise ex
1397
1398 if namespaceSpecificString is None:
1399 raise ValueError("Parameter 'namespaceSpecificString' is None.")
1400 elif not isinstance(namespaceSpecificString, str):
1401 ex = TypeError("Parameter 'namespaceSpecificString' is not of type 'str'.")
1402 ex.add_note(f"Got type '{getFullyQualifiedName(namespaceSpecificString)}'.")
1403 raise ex
1404 elif namespaceSpecificString == "":
1405 raise ValueError("Parameter 'namespaceSpecificString' is empty.")
1406
1407 if resolution is not None and not isinstance(resolution, str):
1408 ex = TypeError("Parameter 'resolution' is not of type 'str'.")
1409 ex.add_note(f"Got type '{getFullyQualifiedName(resolution)}'.")
1410 raise ex
1411
1412 self._namespaceID = namespaceID
1413 self._namespaceSpecificString = namespaceSpecificString
1414 self._resolution = resolution
1415
1416 @readonly
1417 def NamespaceID(self) -> str:
1418 """
1419 Read-only property to access the namespace identifier (:attr:`_namespaceID`).
1420
1421 :rfc:`8141` compares it case-insensitively; it is kept as written.
1422
1423 :returns: The namespace identifier, e.g. ``isbn``.
1424 """
1425 return self._namespaceID
1426
1427 @readonly
1428 def NamespaceSpecificString(self) -> str:
1429 """
1430 Read-only property to access the namespace-specific string (:attr:`_namespaceSpecificString`).
1431
1432 :returns: The name within the namespace, e.g. ``0451450523`` in ``urn:isbn:0451450523``.
1433 """
1434 return self._namespaceSpecificString
1435
1436 @readonly
1437 def Resolution(self) -> Nullable[str]:
1438 """
1439 Read-only property to access the r-component (:attr:`_resolution`).
1440
1441 :returns: The r-component without ``?+``, ``None`` if the URN has none.
1442 """
1443 return self._resolution
1444
1445 @readonly
1446 def SchemeSpecificPart(self) -> str:
1447 """
1448 Read-only property to return the scheme-specific part, computed from the URN's parts.
1449
1450 :returns: The namespace identifier and the namespace-specific string, separated by ``:``.
1451 """
1452 return f"{self._namespaceID}:{self._namespaceSpecificString}"
1453
1454 @classmethod
1455 def Parse(cls, urn: str) -> URN:
1456 """
1457 Parse a URN string and return the URN object.
1458
1459 The URN is read by :rfc:`8141` (:data:`URN_PATTERN`): ``urn:`` (in any case), the namespace identifier, ``:``,
1460 the namespace-specific string, then an optional r-component (``?+``), q-component (``?=``) and f-component
1461 (``#``), in that order. The q-component is read as ``key=value`` pairs, as a URL's query.
1462
1463 :param urn: The URN.
1464 :returns: The URN object.
1465 :raises ValueError: If parameter 'urn' is ``None``.
1466 :raises TypeError: If parameter 'urn' is not of type :class:`str`.
1467 :raises URLError: When the URN holds a character :rfc:`3986` forbids, which is not allowed in a URN. |br|
1468 The note names the character and how to write it percent-encoded.
1469 :raises URLError: When the string doesn't start with ``urn:``.
1470 :raises URLError: When the namespace identifier is invalid. |br|
1471 A namespace identifier has 2 to 32 letters, digits or ``-``, and neither starts nor ends with
1472 ``-``.
1473 :raises URLError: When the namespace-specific string is missing or empty.
1474 :raises URLError: When syntax does not match.
1475 """
1476 if urn is None:
1477 raise ValueError("Parameter 'urn' is None.")
1478 elif not isinstance(urn, str):
1479 ex = TypeError("Parameter 'urn' is not of type 'str'.")
1480 ex.add_note(f"Got type '{getFullyQualifiedName(urn)}'.")
1481 raise ex
1482
1483 for character in urn:
1484 if character in FORBIDDEN_CHARACTERS or character.isspace() or not character.isprintable():
1485 ex = URLError(f"Forbidden character when parsing URN {urn!r}.")
1486 ex.add_note(
1487 f"Character {character!r} is not allowed in a URN. Write it percent-encoded as "
1488 f"'{urlQuote(character, safe='')}'."
1489 )
1490 raise ex
1491
1492 if urn[:4].lower() != "urn:":
1493 raise URLError(f"Missing 'urn:' when parsing URN {urn!r}.")
1494 elif (matches := URN_REGEXP.match(urn)) is None:
1495 namespaceID, _, namespaceSpecificString = urn[4:].partition(":")
1496 if _NAMESPACE_ID.fullmatch(namespaceID) is None:
1497 ex = URLError(f"Invalid namespace identifier {namespaceID!r} when parsing URN {urn!r}.")
1498 ex.add_note("A namespace identifier has 2 to 32 letters, digits or '-', and neither starts nor ends with '-'.")
1499 raise ex
1500 elif namespaceSpecificString == "":
1501 raise URLError(f"Missing namespace-specific string when parsing URN {urn!r}.")
1502
1503 raise URLError(f"Syntax error when parsing URN {urn!r}.")
1504
1505 query = matches.group("query")
1506 return cls(
1507 matches.group("namespaceID"),
1508 matches.group("namespaceSpecificString"),
1509 matches.group("resolution"),
1510 None if query is None else cls._ParseQuery(query),
1511 matches.group("fragment")
1512 )
1513
1514 def __str__(self) -> str:
1515 """
1516 Formats the URN as it is written: ``urn:<namespace-id>:<namespace-specific-string>[?+r][?=q][#f]``.
1517
1518 :returns: The URN.
1519 """
1520 result = f"urn:{self._namespaceID}:{self._namespaceSpecificString}"
1521
1522 if self._resolution is not None:
1523 result = f"{result}?+{self._resolution}"
1524
1525 if self._query is not None:
1526 result = result + "?=" + "&".join(str(parameter) for parameter in self._query.values())
1527
1528 if self._fragment is not None:
1529 result = f"{result}#{self._fragment}"
1530
1531 return result
1532
1533
1534@export
1535class SCPAddress(metaclass=ExtendedType, slots=True):
1536 """
1537 Represents an SCP-like address of a remote file or repository: a host, an optional user and a path.
1538
1539 .. code-block::
1540
1541 [user@]host:[path]
1542
1543 scp, rsync and Git write a remote location this way (`scp(1) <https://man.openbsd.org/scp.1>`__, Git's *scp-like
1544 syntax*). It is no URL - it has no scheme, no port, no query and no fragment -, and its path is read differently:
1545 a **relative** path starts in the user's home directory, an absolute one at the root. :meth:`ToURL` converts it to
1546 the ``ssh://`` URL naming the same location.
1547
1548 .. seealso::
1549
1550 :class:`URL`
1551 |rarr| A URL, which names a scheme and may name a port.
1552 """
1553
1554 _user: Nullable[str] #: User name, ``None`` if the address names none.
1555 _host: _Host #: Host name; an SCP-like address has no port.
1556 _path: _Path #: Path on the host; a relative path starts in the user's home directory.
1557
1558 def __init__(self, host: Host, path: Path, user: Nullable[str] = None) -> None:
1559 """
1560 Initializes an SCP-like address.
1561
1562 :param host: Host the file or repository is located on.
1563 :param path: Path on the host. A relative path starts in the user's home directory.
1564 :param user: Optional, user name to log in with. Default: the SSH client's default user.
1565 :raises ValueError: If parameter 'host' is ``None``.
1566 :raises TypeError: If parameter 'host' is not of type :class:`Host`.
1567 :raises ValueError: If parameter 'host' names a port. |br|
1568 An SCP-like address has no port; an ``ssh://`` URL has.
1569 :raises ValueError: If parameter 'path' is ``None``.
1570 :raises TypeError: If parameter 'path' is not of type :class:`Path`.
1571 :raises TypeError: If parameter 'user' is not of type :class:`str`.
1572 """
1573 if host is None:
1574 raise ValueError("Parameter 'host' is None.")
1575 elif not isinstance(host, Host):
1576 ex = TypeError("Parameter 'host' is not of type 'Host'.")
1577 ex.add_note(f"Got type '{getFullyQualifiedName(host)}'.")
1578 raise ex
1579 elif host._port is not None:
1580 ex = ValueError("Parameter 'host' names a port.")
1581 ex.add_note(f"Got port '{host._port}'. An SCP-like address has no port; an 'ssh://' URL has.")
1582 raise ex
1583
1584 if path is None:
1585 raise ValueError("Parameter 'path' is None.")
1586 elif not isinstance(path, Path):
1587 ex = TypeError("Parameter 'path' is not of type 'Path'.")
1588 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
1589 raise ex
1590
1591 if user is not None and not isinstance(user, str):
1592 ex = TypeError("Parameter 'user' is not of type 'str'.")
1593 ex.add_note(f"Got type '{getFullyQualifiedName(user)}'.")
1594 raise ex
1595
1596 self._host = host
1597 self._path = path
1598 self._user = user
1599
1600 @readonly
1601 def User(self) -> Nullable[str]:
1602 """
1603 Read-only property to access the optional user name (:attr:`_user`).
1604
1605 :returns: The user name, ``None`` if the address names none.
1606 """
1607 return self._user
1608
1609 @readonly
1610 def Host(self) -> Host:
1611 """
1612 Read-only property to access the host (:attr:`_host`).
1613
1614 :returns: The host the file or repository is located on.
1615 """
1616 return self._host
1617
1618 @readonly
1619 def Path(self) -> Path:
1620 """
1621 Read-only property to access the path on the host (:attr:`_path`).
1622
1623 :returns: The path; a relative path starts in the user's home directory.
1624 """
1625 return self._path
1626
1627 @classmethod
1628 def Parse(cls, address: str) -> SCPAddress:
1629 """
1630 Parse an SCP-like address string and return the address object.
1631
1632 The address is ``[user@]host:[path]``, as :data:`SCP_ADDRESS_PATTERN` reads it: a host name or a bracketed IPv6
1633 address, followed by ``:``; the path after it may be empty (the home directory). As in Git, the first ``:``
1634 separates the host, so the part before it holds no ``/``.
1635
1636 :param address: The SCP-like address.
1637 :returns: The address object.
1638 :raises ValueError: If parameter 'address' is ``None``.
1639 :raises TypeError: If parameter 'address' is not of type :class:`str`.
1640 :raises URLError: When the address names a scheme followed by ``://``. |br|
1641 The note offers :meth:`URL.Parse`.
1642 :raises URLError: When a single letter stands where the host is - a Windows drive letter.
1643 :raises URLError: When syntax does not match.
1644 """
1645 if address is None:
1646 raise ValueError("Parameter 'address' is None.")
1647 elif not isinstance(address, str):
1648 ex = TypeError("Parameter 'address' is not of type 'str'.")
1649 ex.add_note(f"Got type '{getFullyQualifiedName(address)}'.")
1650 raise ex
1651
1652 if _SCHEME_PREFIX.match(address) is not None:
1653 ex = URLError(f"URL instead of an SCP-like address when parsing {address!r}.")
1654 ex.add_note("An address with a scheme followed by '://' is a URL. Read it with 'URL.Parse'.")
1655 raise ex
1656 elif (matches := SCP_ADDRESS_REGEXP.match(address)) is None:
1657 raise URLError(f"Syntax error when parsing SCP-like address {address!r}.")
1658
1659 host = matches.group("host")
1660 if len(host) == 1 and host.isalpha():
1661 raise URLError(f"Drive letter '{host}' instead of a host when parsing SCP-like address {address!r}.")
1662
1663 hostObj = Host(host)
1664 return cls(hostObj, Path.Parse(matches.group("path"), hostObj), matches.group("user"))
1665
1666 def ToURL(self) -> URL:
1667 """
1668 Convert the address to the ``ssh://`` URL naming the same location.
1669
1670 An absolute path becomes the URL's path. A relative path starts in the user's home directory, which a URL can't
1671 express; Git spells it ``/~/`` - ``git@github.com:org/repo.git`` becomes
1672 ``ssh://git@github.com/~/org/repo.git`` -, and a path starting with ``~user`` keeps it:
1673 ``host:~user/repo`` becomes ``ssh://host/~user/repo``. Not every SSH server understands ``~``; Git does.
1674 Characters a URL can't hold are percent-encoded.
1675
1676 :returns: The ``ssh://`` URL.
1677 """
1678 path = str(self._path)
1679 if self._path._isAbsolute:
1680 urlPath = path
1681 elif path == "":
1682 urlPath = "/~"
1683 elif path.startswith("~"):
1684 urlPath = f"/{path}"
1685 else:
1686 urlPath = f"/~/{path}"
1687
1688 return URL(
1689 Protocols.SSH,
1690 Path.Parse(urlQuote(urlPath, safe="/!$&'()*+,;=:@~"), self._host),
1691 self._host,
1692 None if self._user is None else urlQuote(self._user, safe="!$&'()*+,;=")
1693 )
1694
1695 def __str__(self) -> str:
1696 """
1697 Formats the address as it is written: ``[user@]host:[path]``.
1698
1699 :returns: The SCP-like address.
1700 """
1701 if self._user is None:
1702 return f"{self._host}:{self._path}"
1703
1704 return f"{self._user}@{self._host}:{self._path}"