__init__.py

Summary

Lines:

661 of 677 covered (97.6%)

Branches:

222 of 228 taken (97.4%), 6 lines partially

Coverage:

97.6%

Directory:

URI

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}"