Coverage for pyTooling/GenericPath/URL.py: 91%
261 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +0000
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 a representation for a Uniform Resource Locator (URL).
34.. code-block::
36 [schema://][user[:password]@]domain.tld[:port]/path/to/file[?query][#fragment]
37"""
38from __future__ import annotations
40from enum import Flag
41from re import compile as re_compile
42from urllib.parse import quote as urlQuote
43from typing import ClassVar, Optional as Nullable, Mapping, Union
45from pyTooling.Decorators import export, readonly
46from pyTooling.Exceptions import ToolingException
47from pyTooling.Common import getFullyQualifiedName
48from pyTooling.GenericPath import RootMixin, ElementMixin, PathMixin
51__all__ = ["URL_PATTERN", "URL_REGEXP"]
53URL_PATTERN = (
54 r"""(?:(?P<scheme>\w+)://)?"""
55 r"""(?:(?P<user>[-a-zA-Z0-9_]+)(?::(?P<password>[-a-zA-Z0-9_]+))?@)?"""
56 r"""(?:(?P<host>\[[0-9A-Fa-f:.]+\]|(?:[-a-zA-Z0-9_]+)(?:\.[-a-zA-Z0-9_]+)*\.?)(?:\:(?P<port>\d+))?)?"""
57 r"""(?P<path>[^?#]*?)"""
58 r"""(?:\?(?P<query>[^#]+?))?"""
59 r"""(?:#(?P<fragment>.+?))?"""
60) #: Regular expression pattern for validating and splitting a URL.
61URL_REGEXP = re_compile("^" + URL_PATTERN + "$") #: Precompiled regular expression for URL validation.
63FORBIDDEN_CHARACTERS = " <>\"{}|\\^`" #: Characters :rfc:`3986` forbids in a URL unless they are percent-encoded.
66@export
67class URLError(ToolingException):
68 """
69 Raised when a URL can't be parsed.
71 .. note::
73 :class:`urllib.error.URLError` from the standard library carries the same name and a different meaning - it
74 reports that a *request* failed. A module using both imports one of them under another name.
75 """
78@export
79class Protocols(Flag):
80 """
81 Enumeration of supported URL schemes.
83 The members are flags, so a scheme secured by a transport is the combination of that transport and the plain
84 protocol: :attr:`HTTPS` is :attr:`TLS` with :attr:`HTTP`, and :attr:`SFTP` is :attr:`SSH` carrying its own file
85 transfer. A scheme can therefore be asked which transport secures it, without enumerating every variant:
87 .. code-block:: Python
89 if Protocols.TLS in url.Scheme:
90 print(f"'{url}' is secured by TLS.")
92 if url.Scheme.IsEncrypted:
93 print(f"'{url}' is encrypted.")
95 .. attention::
97 :attr:`TLS` answers *"is this TLS"*, not *"is this encrypted"* - :attr:`SSH` and :attr:`SFTP` are encrypted
98 and carry no :attr:`TLS` flag. Use :attr:`IsEncrypted`, which asks for both.
100 :attr:`SFTP` is **not** :attr:`FTP` carried by :attr:`SSH`: it is a protocol of its own, defined as an SSH
101 subsystem and sharing nothing with FTP but its purpose. :attr:`FTP` secured by :attr:`TLS` is :attr:`FTPS`.
103 .. note::
105 :attr:`TCP` and :attr:`UDP` name a scheme written as ``tcp://host:port``, which is what ZeroMQ, the Docker
106 daemon and syslog configurations use. They do **not** combine with the other members the way :attr:`TLS` and
107 :attr:`SSH` do: ``http://`` runs over TCP without saying so, so :attr:`HTTP` carries no :attr:`TCP` flag.
108 ``tcp`` is not registered with IANA - ``udp`` is registered provisionally - but both appear often enough that
109 refusing them would be unhelpful.
111 .. caution::
113 The enumeration lists the schemes written as ``scheme://``, and :meth:`URL.Parse` raises
114 :exc:`URLError` for one it doesn't know - so a scheme missing here makes a URL unparseable. Schemes are added
115 as they are needed.
117 An **opaque** scheme - ``mailto:a@b.org``, ``urn:isbn:…``, and ``sip:alice@atlanta.com``, whose URI carries no
118 ``//`` - is a different matter: :data:`URL_PATTERN` only recognises a scheme before ``://``, so such a URL
119 parses with its scheme silently dropped rather than raising. Listing it here would change nothing.
120 """
122 TLS = 1 #: Transport Layer Security
123 FILE = 2 #: Local files
124 HTTP = 4 #: Hyper Text Transfer Protocol
125 FTP = 8 #: File Transfer Protocol
126 WS = 16 #: WebSocket
127 SSH = 32 #: Secure Shell
128 GIT = 64 #: Git's own transport
129 LDAP = 128 #: Lightweight Directory Access Protocol
130 TCP = 512 #: A raw TCP endpoint, the higher protocol unspecified - as ZeroMQ and Docker write it.
131 UDP = 1024 #: A raw UDP endpoint of an unspecified higher protocol.
132 UNIX = 2048 #: A local Unix domain socket, addressed by a file system path.
133 MQTT = 4096 #: Message Queuing Telemetry Transport
134 AMQP = 8192 #: Advanced Message Queuing Protocol - the protocol RabbitMQ speaks.
135 REDIS = 16384 #: Redis
136 MONGODB = 32768 #: MongoDB
137 POSTGRES = 65536 #: PostgreSQL
139 HTTPS = TLS | HTTP #: SSL/TLS secured HTTP: combination of :attr:`TLS` and :attr:`HTTP`.
140 FTPS = TLS | FTP #: SSL/TLS secured FTP: combination of :attr:`TLS` and :attr:`FTP`.
141 WSS = TLS | WS #: SSL/TLS secured WebSocket: combination of :attr:`TLS` and :attr:`WS`.
142 LDAPS = TLS | LDAP #: SSL/TLS secured LDAP: combination of :attr:`TLS` and :attr:`LDAP`.
143 SFTP = SSH | 256 #: SSH File Transfer Protocol, carried by :attr:`SSH`.
144 MQTTS = TLS | MQTT #: SSL/TLS secured MQTT: combination of :attr:`TLS` and :attr:`MQTT`.
145 AMQPS = TLS | AMQP #: SSL/TLS secured AMQP: combination of :attr:`TLS` and :attr:`AMQP`.
146 REDISS = TLS | REDIS #: SSL/TLS secured Redis: combination of :attr:`TLS` and :attr:`REDIS`.
148 POSTGRESQL = POSTGRES #: Alias of :attr:`POSTGRES`, the spelling ``libpq`` documents.
150 @readonly
151 def IsEncrypted(self) -> bool:
152 """
153 Read-only property to return whether the scheme is encrypted.
155 A scheme is encrypted when it is secured by :attr:`TLS` or carried by :attr:`SSH`, so this answers e.g.
156 for :attr:`HTTPS` or :attr:`SFTP` alike - which testing a single flag doesn't.
158 :returns: ``True``, if the scheme is secured by TLS or carried by SSH.
159 """
160 return bool(self & (Protocols.TLS | Protocols.SSH))
163@export
164class Host(RootMixin):
165 """Represents a host as either hostname, DNS or IP-address including the port number in a URL."""
167 _hostname: str #: Name of the host (DNS name or IP address).
168 _port: Nullable[int] #: Optional port number.
170 def __init__(
171 self,
172 hostname: str,
173 port: Nullable[int] = None
174 ) -> None:
175 """
176 Initialize a host instance described by host name and port number.
178 :param hostname: Name of the host (either IP address or DNS).
179 :param port: Optional, port number.
180 :raises ValueError: If parameter 'hostname' is None or empty.
181 :raises TypeError: If parameter 'hostname' is not of type :class:`str`.
182 :raises TypeError: If parameter 'port' is not of type :class:`int`.
183 :raises ValueError: If parameter 'port' is out of range 0..65535.
184 """
185 super().__init__()
187 if hostname is None:
188 raise ValueError("Parameter 'hostname' is None.")
189 elif not isinstance(hostname, str):
190 ex = TypeError("Parameter 'hostname' is not of type 'str'.")
191 ex.add_note(f"Got type '{getFullyQualifiedName(hostname)}'.")
192 raise ex
193 elif hostname == "":
194 raise ValueError("Parameter 'hostname' is empty.")
196 self._hostname = hostname
198 if port is None:
199 pass
200 elif not isinstance(port, int):
201 ex = TypeError("Parameter 'port' is not of type 'int'.")
202 ex.add_note(f"Got type '{getFullyQualifiedName(port)}'.")
203 raise ex
204 elif not (0 <= port < 65536):
205 ex = ValueError("Parameter 'port' is out of range 0..65535.")
206 ex.add_note(f"Got value '{port}'.")
207 raise ex
209 self._port = port
211 @readonly
212 def Hostname(self) -> str:
213 """
214 Read-only property to access the hostname.
216 :returns: Hostname as DNS name or IP address.
217 """
218 return self._hostname
220 @readonly
221 def Port(self) -> Nullable[int]:
222 """
223 Read-only property to access the optional port number.
225 :returns: Optional port number.
226 """
227 return self._port
229 def __str__(self) -> str:
230 """
231 Return a string representation of this host.
233 :returns: Hostname, followed by ``:port`` if a port is specified.
234 """
235 result = self._hostname
236 if self._port is not None:
237 result += f":{self._port}"
239 return result
241 def Copy(self) -> Host:
242 """
243 Create a copy of this object.
245 :returns: A new :class:`Host` instance.
246 """
247 return self.__class__(
248 self._hostname,
249 self._port
250 )
253@export
254class Element(ElementMixin):
255 """Derived class for the URL context."""
258@export
259class Path(PathMixin):
260 """Represents a path in a URL."""
262 ELEMENT_DELIMITER: ClassVar[str] = "/" #: Delimiter symbol in URLs between path elements.
263 ROOT_DELIMITER: ClassVar[str] = "/" #: Delimiter symbol in URLs between root and first element.
264 ELEMENT_TYPE: ClassVar[type[Element]] = Element #: Type an element of a URL's path has.
267@export
268class URL:
269 """
270 Represents a URL (Uniform Resource Locator) including scheme, host, credentials, path, query and fragment.
272 .. code-block::
274 [schema://][user[:password]@]domain.tld[:port]/path/to/file[?query][#fragment]
275 """
277 _scheme: Nullable[Protocols] #: Protocol (scheme) of the URL, ``None`` if the URL carries none.
278 _user: Nullable[str] #: User name of the URL's authority part.
279 _password: Nullable[str] #: Password of the URL's authority part.
280 _host: Nullable[Host] #: Host name and port of the URL's authority part.
281 _path: Path #: Path part of the URL.
282 _query: Nullable[dict[str, str]] #: Query parameters of the URL, by parameter name.
283 _fragment: Nullable[str] #: Fragment (anchor) of the URL.
285 def __init__(
286 self,
287 scheme: Nullable[Protocols],
288 path: Path,
289 host: Nullable[Host] = None,
290 user: Nullable[str] = None,
291 password: Nullable[str] = None,
292 query: Nullable[Mapping[str, str]] = None,
293 fragment: Nullable[str] = None
294 ) -> None:
295 """
296 Initializes a Uniform Resource Locator (URL).
298 :param scheme: Transport scheme to be used for a specified resource.
299 :param path: Path to the resource.
300 :param host: Optional, hostname where the resource is located.
301 :param user: Optional, username for basic authentication.
302 :param password: Optional, password for basic authentication.
303 :param query: Optional, query string.
304 :param fragment: Optional, fragment.
305 :raises TypeError: If parameter 'host' is not of type :class:`Host`.
306 """
307 if scheme is not None and not isinstance(scheme, Protocols): 307 ↛ 308line 307 didn't jump to line 308 because the condition on line 307 was never true
308 ex = TypeError("Parameter 'scheme' is not of type 'Protocols'.")
309 ex.add_note(f"Got type '{getFullyQualifiedName(scheme)}'.")
310 raise ex
312 self._scheme = scheme
314 if user is not None and not isinstance(user, str): 314 ↛ 315line 314 didn't jump to line 315 because the condition on line 314 was never true
315 ex = TypeError("Parameter 'user' is not of type 'str'.")
316 ex.add_note(f"Got type '{getFullyQualifiedName(user)}'.")
317 raise ex
319 self._user = user
321 if password is not None and not isinstance(password, str): 321 ↛ 322line 321 didn't jump to line 322 because the condition on line 321 was never true
322 ex = TypeError("Parameter 'password' is not of type 'str'.")
323 ex.add_note(f"Got type '{getFullyQualifiedName(password)}'.")
324 raise ex
326 self._password = password
328 if host is not None and not isinstance(host, Host): 328 ↛ 329line 328 didn't jump to line 329 because the condition on line 328 was never true
329 ex = TypeError("Parameter 'host' is not of type 'Host'.")
330 ex.add_note(f"Got type '{getFullyQualifiedName(host)}'.")
331 raise ex
332 self._host = host
334 if path is not None and not isinstance(path, Path): 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true
335 ex = TypeError("Parameter 'path' is not of type 'Path'.")
336 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
337 raise ex
339 self._path = path
341 if query is not None:
342 if not isinstance(query, Mapping): 342 ↛ 343line 342 didn't jump to line 343 because the condition on line 342 was never true
343 ex = TypeError("Parameter 'query' is not a mapping ('dict', ...).")
344 ex.add_note(f"Got type '{getFullyQualifiedName(query)}'.")
345 raise ex
347 self._query = dict(query)
348 else:
349 self._query = None
351 if fragment is not None and not isinstance(fragment, str): 351 ↛ 352line 351 didn't jump to line 352 because the condition on line 351 was never true
352 ex = TypeError("Parameter 'fragment' is not of type 'str'.")
353 ex.add_note(f"Got type '{getFullyQualifiedName(fragment)}'.")
354 raise ex
356 self._fragment = fragment
358 @readonly
359 def Scheme(self) -> Nullable[Protocols]:
360 """
361 Read-only property to access the URL scheme.
363 :returns: URL scheme of the URL, ``None`` if it carries none.
364 """
365 return self._scheme
367 @readonly
368 def User(self) -> Nullable[str]:
369 """
370 Read-only property to access the optional username.
372 :returns: Optional username within the URL.
373 """
374 return self._user
376 @readonly
377 def Password(self) -> Nullable[str]:
378 """
379 Read-only property to access the optional password.
381 :returns: Optional password within a URL.
382 """
383 return self._password
385 @readonly
386 def Host(self) -> Nullable[Host]:
387 """
388 Read-only property to access the host part (hostname and port number) of the URL.
390 :returns: The host part of the URL.
391 """
392 return self._host
394 @readonly
395 def Path(self) -> Path:
396 """
397 Read-only property to access the path part of the URL.
399 :returns: Path part of the URL.
400 """
401 return self._path
403 @readonly
404 def Query(self) -> Nullable[dict[str, str]]:
405 """
406 Read-only property to access the dictionary of key-value pairs representing the query part in the URL.
408 :returns: A dictionary representing the query as key-value pairs.
409 """
410 return self._query
412 @readonly
413 def Fragment(self) -> Nullable[str]:
414 """
415 Read-only property to access the fragment part of the URL.
417 :returns: The fragment part of the URL.
418 """
419 return self._fragment
421 # http://semaphore.plc2.de:5000/api/v1/semaphore?name=Riviera&foo=bar#page2
422 @classmethod
423 def Parse(cls, url: str) -> URL:
424 """
425 Parse a URL string and returns the URL object.
427 :param url: URL as string to be parsed.
428 :returns: A URL object.
429 :raises ValueError: If parameter 'url' is ``None``.
430 :raises TypeError: If parameter 'url' is not of type :class:`str`.
431 :raises URLError: When syntax does not match. |br|
432 A note names the first character :rfc:`3986` forbids, if the URL holds one, and how
433 to percent-encode it.
434 :raises URLError: When the URL names a scheme that is not in :class:`Protocols`. |br|
435 The note lists the known schemes.
436 :raises URLError: When a parameter of the query is not a ``key=value`` pair.
437 """
438 if url is None:
439 raise ValueError("Parameter 'url' is None.")
440 elif not isinstance(url, str):
441 ex = TypeError("Parameter 'url' is not of type 'str'.")
442 ex.add_note(f"Got type '{getFullyQualifiedName(url)}'.")
443 raise ex
445 if (matches := URL_REGEXP.match(url)) is not None:
446 scheme = matches.group("scheme")
447 user = matches.group("user")
448 password = matches.group("password")
449 host = matches.group("host")
451 port = matches.group("port")
452 if port is not None:
453 port = int(port)
454 path = matches.group("path")
455 query = matches.group("query")
456 fragment = matches.group("fragment")
458 if scheme is not None:
459 try:
460 scheme = Protocols[scheme.upper()]
461 except KeyError as ex:
462 error = URLError(f"Unknown scheme '{scheme}' when parsing URL '{url}'.")
463 error.add_note(f"Known schemes: {', '.join(name.lower() for name in Protocols.__members__)}.")
464 raise error from ex
466 hostObj = None if host is None else Host(host, port)
467 pathObj = Path.Parse(path, hostObj)
468 parameters = None if query is None else cls._ParseQuery(query, url)
470 return cls(
471 scheme,
472 pathObj,
473 hostObj,
474 user,
475 password,
476 parameters,
477 fragment
478 )
480 error = URLError(f"Syntax error when parsing URL {url!r}.")
481 for character in url:
482 if character in FORBIDDEN_CHARACTERS or character.isspace() or not character.isprintable():
483 error.add_note(
484 f"Character {character!r} is not allowed in a URL. Write it percent-encoded as "
485 f"'{urlQuote(character, safe='')}'."
486 )
487 break
489 raise error
491 @classmethod
492 def _ParseQuery(cls, query: str, url: str) -> dict[str, str]:
493 """
494 Parse a URL's query into its parameters.
496 :param query: The query, without the leading ``?``. It is not empty - a URL carrying no query has none.
497 :param url: The URL the query came from, for the exception's message.
498 :returns: The parameters by name.
499 :raises URLError: When a parameter of the query is not a ``key=value`` pair.
500 """
501 parameters = {}
502 for pair in query.split("&"):
503 key, separator, value = pair.partition("=")
504 if separator == "":
505 error = URLError(f"Query parameter '{pair}' is no 'key=value' pair in URL '{url}'.")
506 error.add_note("Every parameter of a query needs a '=', even when its value is empty.")
507 raise error
509 parameters[key] = value
511 return parameters
513 @classmethod
514 def _CheckRelativeReference(cls, reference: str) -> None:
515 """
516 Check a string names a resource below a URL.
518 A relative reference of :rfc:`3986` names no scheme and no authority: it starts with no ``//``, and the first
519 segment of its path holds no ``:``, which is the ``path-noscheme`` rule. A complete URL is read by
520 :meth:`Parse` instead of being appended to another.
522 :param reference: The right side of the ``/`` operator.
523 :raises URLError: When the reference names a scheme or an authority.
524 :raises URLError: When the reference holds a character :rfc:`3986` forbids. |br|
525 The note names the character and how to percent-encode it.
526 """
527 path = reference.partition("#")[0].partition("?")[0]
528 if path.startswith("//"):
529 error = URLError(f"Relative reference '{reference}' names an authority.")
530 error.add_note("A reference below a URL carries no host, user, password or port.")
531 error.add_note("Read a complete URL with 'URL.Parse' instead of appending it.")
532 raise error
533 elif ":" in path.split(Path.ELEMENT_DELIMITER, 1)[0]:
534 error = URLError(f"Relative reference '{reference}' names a scheme.")
535 error.add_note("The first element of a relative path carries no ':' - it would read as a scheme.")
536 error.add_note("Read a complete URL with 'URL.Parse' instead of appending it. Write a ':' further down "
537 "the path percent-encoded as '%3A'.")
538 raise error
540 for character in reference:
541 if character in FORBIDDEN_CHARACTERS or character.isspace() or not character.isprintable():
542 error = URLError(f"Relative reference '{reference}' holds a forbidden character.")
543 error.add_note(
544 f"Character {character!r} is not allowed in a URL. Write it percent-encoded as "
545 f"'{urlQuote(character, safe='')}'."
546 )
547 raise error
549 def __truediv__(self, other: Union[str, Path]) -> URL:
550 """
551 Return this URL with a resource below it.
553 The right side is a **relative reference**: its path is appended below this URL's, and it brings its own query
554 and fragment, which this URL's are not carried into - :rfc:`3986` resolves a reference the same way. A path
555 that starts with a slash names where it starts itself and replaces this URL's path.
557 .. code-block:: python
559 URL.Parse("https://example.org/api/v3") / "things/4711?fields=name"
560 # https://example.org/api/v3/things/4711?fields=name
562 :param other: The resource below this URL, as a string to parse or as a :class:`Path`.
563 :returns: A new URL naming that resource.
564 :raises TypeError: If parameter 'other' is neither of type :class:`str` nor of type :class:`Path`.
565 :raises URLError: When the right side names a scheme or an authority instead of a resource.
566 :raises URLError: When the right side holds a character :rfc:`3986` forbids. |br|
567 The note names the character and how to percent-encode it.
568 :raises URLError: When a parameter of the right side's query is not a ``key=value`` pair.
569 """
570 if isinstance(other, str):
571 self._CheckRelativeReference(other)
573 resource, _, fragment = other.partition("#")
574 resource, _, query = resource.partition("?")
575 parameters = None if query == "" else self._ParseQuery(query, other)
576 fragment = fragment if fragment != "" else None
577 elif isinstance(other, Path):
578 resource = other
579 parameters = None
580 fragment = None
581 else:
582 ex = TypeError("Second operand is not supported by / operator.")
583 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
584 ex.add_note("Supported types for second operand: 'str' or 'Path'.")
585 raise ex
587 return self.__class__(
588 scheme=self._scheme,
589 path=self._path / resource,
590 host=self._host,
591 user=self._user,
592 password=self._password,
593 query=parameters,
594 fragment=fragment
595 )
597 def __str__(self) -> str:
598 """
599 Formats the URL object as a string representation.
601 :returns: Formatted URL object.
602 """
603 result = str(self._path)
605 if self._host is not None: 605 ↛ 608line 605 didn't jump to line 608 because the condition on line 605 was always true
606 result = str(self._host) + result
608 if self._user is not None:
609 if self._password is not None:
610 result = f"{self._user}:{self._password}@{result}"
611 else:
612 result = f"{self._user}@{result}"
614 # 'Protocols' is a 'Flag', so a single member and a combination both have a name; only 'Protocols(0)' has
615 # none - and a scheme without any flag is no scheme, so it renders nothing either way.
616 if self._scheme is not None and (scheme := self._scheme.name) is not None:
617 result = scheme.lower() + "://" + result
619 if self._query is not None and len(self._query) > 0:
620 result = result + "?" + "&".join([f"{key}={value}" for key, value in self._query.items()])
622 if self._fragment is not None:
623 result = result + "#" + self._fragment
625 return result
627 def WithoutCredentials(self) -> URL:
628 """
629 Returns a URL object without credentials (username and password).
631 :returns: New URL object without credentials.
632 """
633 return self.__class__(
634 scheme=self._scheme,
635 path=self._path,
636 host=self._host,
637 query=self._query,
638 fragment=self._fragment
639 )
641 def WithoutTrailingSlash(self) -> URL:
642 """
643 Returns a URL object whose path doesn't end in a slash.
645 A URL's element delimiter is the slash, so this is :meth:`~pyTooling.GenericPath.PathMixin.WithoutTrailingDelimiter`
646 applied to the URL's path. A trailing slash is an empty last element, so ``https://example.org/api/v3/`` and
647 ``https://example.org/api/v3`` differ although they usually address the same resource. A URL that is composed
648 with a path below it wants the latter, or the composition yields a double slash.
650 :returns: New URL object without a trailing slash, or this URL, if its path has none.
652 .. seealso::
654 :meth:`~pyTooling.GenericPath.PathMixin.WithoutTrailingDelimiter`
655 |rarr| What it does to the path, and what it leaves alone.
656 """
657 if (path := self._path.WithoutTrailingDelimiter()) is self._path:
658 return self
660 return self.__class__(
661 scheme=self._scheme,
662 path=path,
663 host=self._host,
664 user=self._user,
665 password=self._password,
666 query=self._query,
667 fragment=self._fragment
668 )