Coverage for pyTooling/GenericPath/URL.py: 87%
257 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +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 """
182 super().__init__()
184 if not isinstance(hostname, str): 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true
185 ex = TypeError("Parameter 'hostname' is not of type 'str'.")
186 ex.add_note(f"Got type '{getFullyQualifiedName(hostname)}'.")
187 raise ex
189 self._hostname = hostname
191 if port is None:
192 pass
193 elif not isinstance(port, int): 193 ↛ 194line 193 didn't jump to line 194 because the condition on line 193 was never true
194 ex = TypeError("Parameter 'port' is not of type 'int'.")
195 ex.add_note(f"Got type '{getFullyQualifiedName(port)}'.")
196 raise ex
197 elif not (0 <= port < 65536): 197 ↛ 198line 197 didn't jump to line 198 because the condition on line 197 was never true
198 ex = ValueError("Parameter 'port' is out of range 0..65535.")
199 ex.add_note(f"Got value '{port}'.")
200 raise ex
202 self._port = port
204 @readonly
205 def Hostname(self) -> str:
206 """
207 Read-only property to access the hostname.
209 :returns: Hostname as DNS name or IP address.
210 """
211 return self._hostname
213 @readonly
214 def Port(self) -> Nullable[int]:
215 """
216 Read-only property to access the optional port number.
218 :returns: Optional port number.
219 """
220 return self._port
222 def __str__(self) -> str:
223 """
224 Return a string representation of this host.
226 :returns: Hostname, followed by ``:port`` if a port is specified.
227 """
228 result = self._hostname
229 if self._port is not None:
230 result += f":{self._port}"
232 return result
234 def Copy(self) -> Host:
235 """
236 Create a copy of this object.
238 :returns: A new :class:`Host` instance.
239 """
240 return self.__class__(
241 self._hostname,
242 self._port
243 )
246@export
247class Element(ElementMixin):
248 """Derived class for the URL context."""
251@export
252class Path(PathMixin):
253 """Represents a path in a URL."""
255 ELEMENT_DELIMITER: ClassVar[str] = "/" #: Delimiter symbol in URLs between path elements.
256 ROOT_DELIMITER: ClassVar[str] = "/" #: Delimiter symbol in URLs between root and first element.
257 ELEMENT_TYPE: ClassVar[type[Element]] = Element #: Type an element of a URL's path has.
260@export
261class URL:
262 """
263 Represents a URL (Uniform Resource Locator) including scheme, host, credentials, path, query and fragment.
265 .. code-block::
267 [schema://][user[:password]@]domain.tld[:port]/path/to/file[?query][#fragment]
268 """
270 _scheme: Nullable[Protocols] #: Protocol (scheme) of the URL, ``None`` if the URL carries none.
271 _user: Nullable[str] #: User name of the URL's authority part.
272 _password: Nullable[str] #: Password of the URL's authority part.
273 _host: Nullable[Host] #: Host name and port of the URL's authority part.
274 _path: Path #: Path part of the URL.
275 _query: Nullable[dict[str, str]] #: Query parameters of the URL, by parameter name.
276 _fragment: Nullable[str] #: Fragment (anchor) of the URL.
278 def __init__(
279 self,
280 scheme: Nullable[Protocols],
281 path: Path,
282 host: Nullable[Host] = None,
283 user: Nullable[str] = None,
284 password: Nullable[str] = None,
285 query: Nullable[Mapping[str, str]] = None,
286 fragment: Nullable[str] = None
287 ) -> None:
288 """
289 Initializes a Uniform Resource Locator (URL).
291 :param scheme: Optional, transport scheme to be used for a specified resource.
292 :param path: Path to the resource.
293 :param host: Optional, hostname where the resource is located.
294 :param user: Optional, username for basic authentication.
295 :param password: Optional, password for basic authentication.
296 :param query: Optional, query string.
297 :param fragment: Optional, fragment.
298 :raises TypeError: If parameter 'host' is not of type :class:`Host`.
299 """
300 if scheme is not None and not isinstance(scheme, Protocols): 300 ↛ 301line 300 didn't jump to line 301 because the condition on line 300 was never true
301 ex = TypeError("Parameter 'scheme' is not of type 'Protocols'.")
302 ex.add_note(f"Got type '{getFullyQualifiedName(scheme)}'.")
303 raise ex
305 self._scheme = scheme
307 if user is not None and not isinstance(user, str): 307 ↛ 308line 307 didn't jump to line 308 because the condition on line 307 was never true
308 ex = TypeError("Parameter 'user' is not of type 'str'.")
309 ex.add_note(f"Got type '{getFullyQualifiedName(user)}'.")
310 raise ex
312 self._user = user
314 if password is not None and not isinstance(password, str): 314 ↛ 315line 314 didn't jump to line 315 because the condition on line 314 was never true
315 ex = TypeError("Parameter 'password' is not of type 'str'.")
316 ex.add_note(f"Got type '{getFullyQualifiedName(password)}'.")
317 raise ex
319 self._password = password
321 if host is not None and not isinstance(host, Host): 321 ↛ 322line 321 didn't jump to line 322 because the condition on line 321 was never true
322 ex = TypeError("Parameter 'host' is not of type 'Host'.")
323 ex.add_note(f"Got type '{getFullyQualifiedName(host)}'.")
324 raise ex
325 self._host = host
327 if path is not None and not isinstance(path, Path): 327 ↛ 328line 327 didn't jump to line 328 because the condition on line 327 was never true
328 ex = TypeError("Parameter 'path' is not of type 'Path'.")
329 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
330 raise ex
332 self._path = path
334 if query is not None:
335 if not isinstance(query, Mapping): 335 ↛ 336line 335 didn't jump to line 336 because the condition on line 335 was never true
336 ex = TypeError("Parameter 'query' is not a mapping ('dict', ...).")
337 ex.add_note(f"Got type '{getFullyQualifiedName(query)}'.")
338 raise ex
340 self._query = {keyword: value for keyword, value in query.items()}
341 else:
342 self._query = None
344 if fragment is not None and not isinstance(fragment, str): 344 ↛ 345line 344 didn't jump to line 345 because the condition on line 344 was never true
345 ex = TypeError("Parameter 'fragment' is not of type 'str'.")
346 ex.add_note(f"Got type '{getFullyQualifiedName(fragment)}'.")
347 raise ex
349 self._fragment = fragment
351 @readonly
352 def Scheme(self) -> Nullable[Protocols]:
353 """
354 Read-only property to access the URL scheme.
356 :returns: URL scheme of the URL, ``None`` if it carries none.
357 """
358 return self._scheme
360 @readonly
361 def User(self) -> Nullable[str]:
362 """
363 Read-only property to access the optional username.
365 :returns: Optional username within the URL.
366 """
367 return self._user
369 @readonly
370 def Password(self) -> Nullable[str]:
371 """
372 Read-only property to access the optional password.
374 :returns: Optional password within a URL.
375 """
376 return self._password
378 @readonly
379 def Host(self) -> Nullable[Host]:
380 """
381 Read-only property to access the host part (hostname and port number) of the URL.
383 :returns: The host part of the URL.
384 """
385 return self._host
387 @readonly
388 def Path(self) -> Path:
389 """
390 Read-only property to access the path part of the URL.
392 :returns: Path part of the URL.
393 """
394 return self._path
396 @readonly
397 def Query(self) -> Nullable[dict[str, str]]:
398 """
399 Read-only property to access the dictionary of key-value pairs representing the query part in the URL.
401 :returns: A dictionary representing the query as key-value pairs.
402 """
403 return self._query
405 @readonly
406 def Fragment(self) -> Nullable[str]:
407 """
408 Read-only property to access the fragment part of the URL.
410 :returns: The fragment part of the URL.
411 """
412 return self._fragment
414 # http://semaphore.plc2.de:5000/api/v1/semaphore?name=Riviera&foo=bar#page2
415 @classmethod
416 def Parse(cls, url: str) -> URL:
417 """
418 Parse a URL string and returns the URL object.
420 :param url: URL as string to be parsed.
421 :returns: A URL object.
422 :raises ValueError: If parameter 'url' is ``None``.
423 :raises TypeError: If parameter 'url' is not of type :class:`str`.
424 :raises URLError: When syntax does not match. |br|
425 A note names the first character :rfc:`3986` forbids, if the URL holds one, and how
426 to percent-encode it.
427 :raises URLError: When the URL names a scheme that is not in :class:`Protocols`. |br|
428 The note lists the known schemes.
429 :raises URLError: When a parameter of the query is not a ``key=value`` pair.
430 """
431 if url is None:
432 raise ValueError("Parameter 'url' is None.")
433 elif not isinstance(url, str):
434 ex = TypeError("Parameter 'url' is not of type 'str'.")
435 ex.add_note(f"Got type '{getFullyQualifiedName(url)}'.")
436 raise ex
438 if (matches := URL_REGEXP.match(url)) is not None:
439 scheme = matches.group("scheme")
440 user = matches.group("user")
441 password = matches.group("password")
442 host = matches.group("host")
444 port = matches.group("port")
445 if port is not None:
446 port = int(port)
447 path = matches.group("path")
448 query = matches.group("query")
449 fragment = matches.group("fragment")
451 if scheme is not None:
452 try:
453 scheme = Protocols[scheme.upper()]
454 except KeyError as ex:
455 error = URLError(f"Unknown scheme '{scheme}' when parsing URL '{url}'.")
456 error.add_note(f"Known schemes: {', '.join(name.lower() for name in Protocols.__members__)}.")
457 raise error from ex
459 hostObj = None if host is None else Host(host, port)
460 pathObj = Path.Parse(path, hostObj)
461 parameters = None if query is None else cls._ParseQuery(query, url)
463 return cls(
464 scheme,
465 pathObj,
466 hostObj,
467 user,
468 password,
469 parameters,
470 fragment
471 )
473 error = URLError(f"Syntax error when parsing URL {url!r}.")
474 for character in url:
475 if character in FORBIDDEN_CHARACTERS or character.isspace() or not character.isprintable():
476 error.add_note(
477 f"Character {character!r} is not allowed in a URL. Write it percent-encoded as "
478 f"'{urlQuote(character, safe='')}'."
479 )
480 break
482 raise error
484 @classmethod
485 def _ParseQuery(cls, query: str, url: str) -> dict[str, str]:
486 """
487 Parse a URL's query into its parameters.
489 :param query: The query, without the leading ``?``. It is not empty - a URL carrying no query has none.
490 :param url: The URL the query came from, for the exception's message.
491 :returns: The parameters by name.
492 :raises URLError: When a parameter of the query is not a ``key=value`` pair.
493 """
494 parameters = {}
495 for pair in query.split("&"):
496 key, separator, value = pair.partition("=")
497 if separator == "":
498 error = URLError(f"Query parameter '{pair}' is no 'key=value' pair in URL '{url}'.")
499 error.add_note("Every parameter of a query needs a '=', even when its value is empty.")
500 raise error
502 parameters[key] = value
504 return parameters
506 @classmethod
507 def _CheckRelativeReference(cls, reference: str) -> None:
508 """
509 Check a string names a resource below a URL.
511 A relative reference of :rfc:`3986` names no scheme and no authority: it starts with no ``//``, and the first
512 segment of its path holds no ``:``, which is the ``path-noscheme`` rule. A complete URL is read by
513 :meth:`Parse` instead of being appended to another.
515 :param reference: The right side of the ``/`` operator.
516 :raises URLError: When the reference names a scheme or an authority.
517 :raises URLError: When the reference holds a character :rfc:`3986` forbids. |br|
518 The note names the character and how to percent-encode it.
519 """
520 path = reference.partition("#")[0].partition("?")[0]
521 if path.startswith("//"):
522 error = URLError(f"Relative reference '{reference}' names an authority.")
523 error.add_note("A reference below a URL carries no host, user, password or port.")
524 error.add_note("Read a complete URL with 'URL.Parse' instead of appending it.")
525 raise error
526 elif ":" in path.split(Path.ELEMENT_DELIMITER, 1)[0]:
527 error = URLError(f"Relative reference '{reference}' names a scheme.")
528 error.add_note("The first element of a relative path carries no ':' - it would read as a scheme.")
529 error.add_note("Read a complete URL with 'URL.Parse' instead of appending it. Write a ':' further down "
530 "the path percent-encoded as '%3A'.")
531 raise error
533 for character in reference:
534 if character in FORBIDDEN_CHARACTERS or character.isspace() or not character.isprintable():
535 error = URLError(f"Relative reference '{reference}' holds a forbidden character.")
536 error.add_note(
537 f"Character {character!r} is not allowed in a URL. Write it percent-encoded as "
538 f"'{urlQuote(character, safe='')}'."
539 )
540 raise error
542 def __truediv__(self, other: Union[str, Path]) -> URL:
543 """
544 Return this URL with a resource below it.
546 The right side is a **relative reference**: its path is appended below this URL's, and it brings its own query
547 and fragment, which this URL's are not carried into - :rfc:`3986` resolves a reference the same way. A path
548 that starts with a slash names where it starts itself and replaces this URL's path.
550 .. code-block:: python
552 URL.Parse("https://example.org/api/v3") / "things/4711?fields=name"
553 # https://example.org/api/v3/things/4711?fields=name
555 :param other: The resource below this URL, as a string to parse or as a :class:`Path`.
556 :returns: A new URL naming that resource.
557 :raises TypeError: If parameter 'other' is neither of type :class:`str` nor of type :class:`Path`.
558 :raises URLError: When the right side names a scheme or an authority instead of a resource.
559 :raises URLError: When the right side holds a character :rfc:`3986` forbids. |br|
560 The note names the character and how to percent-encode it.
561 :raises URLError: When a parameter of the right side's query is not a ``key=value`` pair.
562 """
563 if isinstance(other, str):
564 self._CheckRelativeReference(other)
566 resource, _, fragment = other.partition("#")
567 resource, _, query = resource.partition("?")
568 parameters = None if query == "" else self._ParseQuery(query, other)
569 fragment = fragment if fragment != "" else None
570 elif isinstance(other, Path):
571 resource = other
572 parameters = None
573 fragment = None
574 else:
575 ex = TypeError("Second operand is not supported by / operator.")
576 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
577 ex.add_note("Supported types for second operand: 'str' or 'Path'.")
578 raise ex
580 return self.__class__(
581 scheme=self._scheme,
582 path=self._path / resource,
583 host=self._host,
584 user=self._user,
585 password=self._password,
586 query=parameters,
587 fragment=fragment
588 )
590 def __str__(self) -> str:
591 """
592 Formats the URL object as a string representation.
594 :returns: Formatted URL object.
595 """
596 result = str(self._path)
598 if self._host is not None: 598 ↛ 601line 598 didn't jump to line 601 because the condition on line 598 was always true
599 result = str(self._host) + result
601 if self._user is not None:
602 if self._password is not None:
603 result = f"{self._user}:{self._password}@{result}"
604 else:
605 result = f"{self._user}@{result}"
607 # 'Protocols' is a 'Flag', so a single member and a combination both have a name; only 'Protocols(0)' has
608 # none - and a scheme without any flag is no scheme, so it renders nothing either way.
609 if self._scheme is not None and (scheme := self._scheme.name) is not None:
610 result = scheme.lower() + "://" + result
612 if self._query is not None and len(self._query) > 0:
613 result = result + "?" + "&".join([f"{key}={value}" for key, value in self._query.items()])
615 if self._fragment is not None:
616 result = result + "#" + self._fragment
618 return result
620 def WithoutCredentials(self) -> URL:
621 """
622 Returns a URL object without credentials (username and password).
624 :returns: New URL object without credentials.
625 """
626 return self.__class__(
627 scheme=self._scheme,
628 path=self._path,
629 host=self._host,
630 query=self._query,
631 fragment=self._fragment
632 )
634 def WithoutTrailingSlash(self) -> URL:
635 """
636 Returns a URL object whose path doesn't end in a slash.
638 A URL's element delimiter is the slash, so this is :meth:`~pyTooling.GenericPath.PathMixin.WithoutTrailingDelimiter`
639 applied to the URL's path. A trailing slash is an empty last element, so ``https://example.org/api/v3/`` and
640 ``https://example.org/api/v3`` differ although they usually address the same resource. A URL that is composed
641 with a path below it wants the latter, or the composition yields a double slash.
643 :returns: New URL object without a trailing slash, or this URL, if its path has none.
645 .. seealso::
647 :meth:`~pyTooling.GenericPath.PathMixin.WithoutTrailingDelimiter`
648 |rarr| What it does to the path, and what it leaves alone.
649 """
650 if (path := self._path.WithoutTrailingDelimiter()) is self._path:
651 return self
653 return self.__class__(
654 scheme=self._scheme,
655 path=path,
656 host=self._host,
657 user=self._user,
658 password=self._password,
659 query=self._query,
660 fragment=self._fragment
661 )