Coverage for pyTooling/GenericPath/URL.py: 80%
171 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-11 23:59 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-11 23:59 +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 typing import ClassVar, Optional as Nullable, Mapping
44from pyTooling.Decorators import export, readonly
45from pyTooling.Exceptions import ToolingException
46from pyTooling.Common import getFullyQualifiedName
47from pyTooling.GenericPath import RootMixIn, ElementMixIn, PathMixIn
50__all__ = ["URL_PATTERN", "URL_REGEXP"]
52URL_PATTERN = (
53 r"""(?:(?P<scheme>\w+)://)?"""
54 r"""(?:(?P<user>[-a-zA-Z0-9_]+)(?::(?P<password>[-a-zA-Z0-9_]+))?@)?"""
55 r"""(?:(?P<host>(?:[-a-zA-Z0-9_]+)(?:\.[-a-zA-Z0-9_]+)*\.?)(?:\:(?P<port>\d+))?)?"""
56 r"""(?P<path>[^?#]*?)"""
57 r"""(?:\?(?P<query>[^#]+?))?"""
58 r"""(?:#(?P<fragment>.+?))?"""
59) #: Regular expression pattern for validating and splitting a URL.
60URL_REGEXP = re_compile("^" + URL_PATTERN + "$") #: Precompiled regular expression for URL validation.
63@export
64class Protocols(Flag):
65 """
66 Enumeration of supported URL schemes.
68 The members are flags, so a TLS secured scheme is the combination of :attr:`TLS` and the plain protocol. Thus, a
69 scheme can be checked for encryption without enumerating every secured variant:
71 .. code-block:: Python
73 if Protocols.TLS in url.Scheme:
74 print(f"'{url}' is encrypted.")
75 """
77 TLS = 1 #: Transport Layer Security
78 FILE = 2 #: Local files
79 HTTP = 4 #: Hyper Text Transfer Protocol
80 FTP = 8 #: File Transfer Protocol
82 HTTPS = TLS | HTTP #: SSL/TLS secured HTTP: combination of :attr:`TLS` and :attr:`HTTP`.
83 FTPS = TLS | FTP #: SSL/TLS secured FTP: combination of :attr:`TLS` and :attr:`FTP`.
86@export
87class Host(RootMixIn):
88 """Represents a host as either hostname, DNS or IP-address including the port number in a URL."""
90 _hostname: str #: Name of the host (DNS name or IP address).
91 _port: Nullable[int] #: Optional port number.
93 def __init__(
94 self,
95 hostname: str,
96 port: Nullable[int] = None
97 ) -> None:
98 """
99 Initialize a host instance described by host name and port number.
101 :param hostname: Name of the host (either IP address or DNS).
102 :param port: Optional, port number.
103 :raises ValueError: If parameter 'hostname' is None or empty.
104 """
105 super().__init__()
107 if not isinstance(hostname, str): 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true
108 ex = TypeError("Parameter 'hostname' is not of type 'str'.")
109 ex.add_note(f"Got type '{getFullyQualifiedName(hostname)}'.")
110 raise ex
112 self._hostname = hostname
114 if port is None:
115 pass
116 elif not isinstance(port, int): 116 ↛ 117line 116 didn't jump to line 117 because the condition on line 116 was never true
117 ex = TypeError("Parameter 'port' is not of type 'int'.")
118 ex.add_note(f"Got type '{getFullyQualifiedName(port)}'.")
119 raise ex
120 elif not (0 <= port < 65536): 120 ↛ 121line 120 didn't jump to line 121 because the condition on line 120 was never true
121 ex = ValueError("Parameter 'port' is out of range 0..65535.")
122 ex.add_note(f"Got value '{port}'.")
123 raise ex
125 self._port = port
127 @readonly
128 def Hostname(self) -> str:
129 """
130 Read-only property to access the hostname.
132 :returns: Hostname as DNS name or IP address.
133 """
134 return self._hostname
136 @readonly
137 def Port(self) -> Nullable[int]:
138 """
139 Read-only property to access the optional port number.
141 :returns: Optional port number.
142 """
143 return self._port
145 def __str__(self) -> str:
146 """
147 Return a string representation of this host.
149 :returns: Hostname, followed by ``:port`` if a port is specified.
150 """
151 result = self._hostname
152 if self._port is not None:
153 result += f":{self._port}"
155 return result
157 def Copy(self) -> Host:
158 """
159 Create a copy of this object.
161 :returns: A new :class:`Host` instance.
162 """
163 return self.__class__(
164 self._hostname,
165 self._port
166 )
169@export
170class Element(ElementMixIn):
171 """Derived class for the URL context."""
174@export
175class Path(PathMixIn):
176 """Represents a path in a URL."""
178 ELEMENT_DELIMITER: ClassVar[str] = "/" #: Delimiter symbol in URLs between path elements.
179 ROOT_DELIMITER: ClassVar[str] = "/" #: Delimiter symbol in URLs between root element and first path element.
181 @classmethod
182 def Parse(cls, path: str, root: Nullable[Host] = None) -> Path:
183 """
184 Parse a string into a URL path.
186 :param path: The path portion of a URL.
187 :param root: Optional, host the path is relative to.
188 :returns: The parsed path.
189 """
190 return super().Parse(path, root, cls, Element)
193@export
194class URL:
195 """
196 Represents a URL (Uniform Resource Locator) including scheme, host, credentials, path, query and fragment.
198 .. code-block::
200 [schema://][user[:password]@]domain.tld[:port]/path/to/file[?query][#fragment]
201 """
203 _scheme: Nullable[Protocols] #: Protocol (scheme) of the URL, ``None`` if the URL carries none.
204 _user: Nullable[str] #: User name of the URL's authority part.
205 _password: Nullable[str] #: Password of the URL's authority part.
206 _host: Nullable[Host] #: Host name and port of the URL's authority part.
207 _path: Path #: Path part of the URL.
208 _query: Nullable[dict[str, str]] #: Query parameters of the URL, by parameter name.
209 _fragment: Nullable[str] #: Fragment (anchor) of the URL.
211 def __init__(
212 self,
213 scheme: Nullable[Protocols],
214 path: Path,
215 host: Nullable[Host] = None,
216 user: Nullable[str] = None,
217 password: Nullable[str] = None,
218 query: Nullable[Mapping[str, str]] = None,
219 fragment: Nullable[str] = None
220 ) -> None:
221 """
222 Initializes a Uniform Resource Locator (URL).
224 :param scheme: Optional, transport scheme to be used for a specified resource.
225 :param path: Path to the resource.
226 :param host: Optional, hostname where the resource is located.
227 :param user: Optional, username for basic authentication.
228 :param password: Optional, password for basic authentication.
229 :param query: Optional, query string.
230 :param fragment: Optional, fragment.
231 :raises TypeError: If parameter 'host' is not of type :class:`Host`.
232 """
233 if scheme is not None and not isinstance(scheme, Protocols): 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true
234 ex = TypeError("Parameter 'scheme' is not of type 'Protocols'.")
235 ex.add_note(f"Got type '{getFullyQualifiedName(scheme)}'.")
236 raise ex
238 self._scheme = scheme
240 if user is not None and not isinstance(user, str): 240 ↛ 241line 240 didn't jump to line 241 because the condition on line 240 was never true
241 ex = TypeError("Parameter 'user' is not of type 'str'.")
242 ex.add_note(f"Got type '{getFullyQualifiedName(user)}'.")
243 raise ex
245 self._user = user
247 if password is not None and not isinstance(password, str): 247 ↛ 248line 247 didn't jump to line 248 because the condition on line 247 was never true
248 ex = TypeError(f"Parameter 'password' is not of type 'str'.")
249 ex.add_note(f"Got type '{getFullyQualifiedName(password)}'.")
250 raise ex
252 self._password = password
254 if host is not None and not isinstance(host, Host): 254 ↛ 255line 254 didn't jump to line 255 because the condition on line 254 was never true
255 ex = TypeError(f"Parameter 'host' is not of type 'Host'.")
256 ex.add_note(f"Got type '{getFullyQualifiedName(host)}'.")
257 raise ex
258 self._host = host
260 if path is not None and not isinstance(path, Path): 260 ↛ 261line 260 didn't jump to line 261 because the condition on line 260 was never true
261 ex = TypeError(f"Parameter 'path' is not of type 'Path'.")
262 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
263 raise ex
265 self._path = path
267 if query is not None:
268 if not isinstance(query, Mapping): 268 ↛ 269line 268 didn't jump to line 269 because the condition on line 268 was never true
269 ex = TypeError(f"Parameter 'query' is not a mapping ('dict', ...).")
270 ex.add_note(f"Got type '{getFullyQualifiedName(query)}'.")
271 raise ex
273 self._query = {keyword: value for keyword, value in query.items()}
274 else:
275 self._query = None
277 if fragment is not None and not isinstance(fragment, str): 277 ↛ 278line 277 didn't jump to line 278 because the condition on line 277 was never true
278 ex = TypeError(f"Parameter 'fragment' is not of type 'str'.")
279 ex.add_note(f"Got type '{getFullyQualifiedName(fragment)}'.")
280 raise ex
282 self._fragment = fragment
284 @readonly
285 def Scheme(self) -> Nullable[Protocols]:
286 """
287 Read-only property to access the URL scheme.
289 :returns: URL scheme of the URL, ``None`` if it carries none.
290 """
291 return self._scheme
293 @readonly
294 def User(self) -> Nullable[str]:
295 """
296 Read-only property to access the optional username.
298 :returns: Optional username within the URL.
299 """
300 return self._user
302 @readonly
303 def Password(self) -> Nullable[str]:
304 """
305 Read-only property to access the optional password.
307 :returns: Optional password within a URL.
308 """
309 return self._password
311 @readonly
312 def Host(self) -> Nullable[Host]:
313 """
314 Read-only property to access the host part (hostname and port number) of the URL.
316 :returns: The host part of the URL.
317 """
318 return self._host
320 @readonly
321 def Path(self) -> Path:
322 """
323 Read-only property to access the path part of the URL.
325 :returns: Path part of the URL.
326 """
327 return self._path
329 @readonly
330 def Query(self) -> Nullable[dict[str, str]]:
331 """
332 Read-only property to access the dictionary of key-value pairs representing the query part in the URL.
334 :returns: A dictionary representing the query as key-value pairs.
335 """
336 return self._query
338 @readonly
339 def Fragment(self) -> Nullable[str]:
340 """
341 Read-only property to access the fragment part of the URL.
343 :returns: The fragment part of the URL.
344 """
345 return self._fragment
347 # http://semaphore.plc2.de:5000/api/v1/semaphore?name=Riviera&foo=bar#page2
348 @classmethod
349 def Parse(cls, url: str) -> URL:
350 """
351 Parse a URL string and returns the URL object.
353 :param url: URL as string to be parsed.
354 :returns: A URL object.
355 :raises ToolingException: When syntax does not match.
356 """
357 if (matches := URL_REGEXP.match(url)) is not None: 357 ↛ 391line 357 didn't jump to line 391 because the condition on line 357 was always true
358 scheme = matches.group("scheme")
359 user = matches.group("user")
360 password = matches.group("password")
361 host = matches.group("host")
363 port = matches.group("port")
364 if port is not None:
365 port = int(port)
366 path = matches.group("path")
367 query = matches.group("query")
368 fragment = matches.group("fragment")
370 scheme = None if scheme is None else Protocols[scheme.upper()]
371 hostObj = None if host is None else Host(host, port)
373 pathObj = Path.Parse(path, hostObj)
375 parameters = {}
376 if query is not None:
377 for pair in query.split("&"):
378 key, value = pair.split("=")
379 parameters[key] = value
381 return cls(
382 scheme,
383 pathObj,
384 hostObj,
385 user,
386 password,
387 parameters if len(parameters) > 0 else None,
388 fragment
389 )
391 raise ToolingException(f"Syntax error when parsing URL '{url}'.")
393 def __str__(self) -> str:
394 """
395 Formats the URL object as a string representation.
397 :returns: Formatted URL object.
398 """
399 result = str(self._path)
401 if self._host is not None: 401 ↛ 404line 401 didn't jump to line 404 because the condition on line 401 was always true
402 result = str(self._host) + result
404 if self._user is not None:
405 if self._password is not None:
406 result = f"{self._user}:{self._password}@{result}"
407 else:
408 result = f"{self._user}@{result}"
410 # 'Protocols' is a 'Flag', so a single member and a combination both have a name; only 'Protocols(0)' has
411 # none - and a scheme without any flag is no scheme, so it renders nothing either way.
412 if self._scheme is not None and (scheme := self._scheme.name) is not None:
413 result = scheme.lower() + "://" + result
415 if self._query is not None and len(self._query) > 0:
416 result = result + "?" + "&".join([f"{key}={value}" for key, value in self._query.items()])
418 if self._fragment is not None:
419 result = result + "#" + self._fragment
421 return result
423 def WithoutCredentials(self) -> URL:
424 """
425 Returns a URL object without credentials (username and password).
427 :returns: New URL object without credentials.
428 """
429 return self.__class__(
430 scheme=self._scheme,
431 path=self._path,
432 host=self._host,
433 query=self._query,
434 fragment=self._fragment
435 )