Coverage for pyTooling / GenericPath / URL.py: 80%
170 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-07 17:18 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-07 17:18 +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"""
39from enum import IntFlag
40from re import compile as re_compile
41from typing import Dict, Optional as Nullable, Mapping
43from pyTooling.Decorators import export, readonly
44from pyTooling.Exceptions import ToolingException
45from pyTooling.Common import getFullyQualifiedName
46from pyTooling.GenericPath import RootMixIn, ElementMixIn, PathMixIn
49__all__ = ["URL_PATTERN", "URL_REGEXP"]
51URL_PATTERN = (
52 r"""(?:(?P<scheme>\w+)://)?"""
53 r"""(?:(?P<user>[-a-zA-Z0-9_]+)(?::(?P<password>[-a-zA-Z0-9_]+))?@)?"""
54 r"""(?:(?P<host>(?:[-a-zA-Z0-9_]+)(?:\.[-a-zA-Z0-9_]+)*\.?)(?:\:(?P<port>\d+))?)?"""
55 r"""(?P<path>[^?#]*?)"""
56 r"""(?:\?(?P<query>[^#]+?))?"""
57 r"""(?:#(?P<fragment>.+?))?"""
58) #: Regular expression pattern for validating and splitting a URL.
59URL_REGEXP = re_compile("^" + URL_PATTERN + "$") #: Precompiled regular expression for URL validation.
62@export
63class Protocols(IntFlag):
64 """Enumeration of supported URL schemes."""
66 TLS = 1 #: Transport Layer Security
67 HTTP = 2 #: Hyper Text Transfer Protocol
68 HTTPS = 4 #: SSL/TLS secured HTTP
69 FTP = 8 #: File Transfer Protocol
70 FTPS = 16 #: SSL/TLS secured FTP
71 FILE = 32 #: Local files
74@export
75class Host(RootMixIn):
76 """Represents a host as either hostname, DNS or IP-address including the port number in a URL."""
78 _hostname: str #: Name of the host (DNS name or IP address).
79 _port: Nullable[int] #: Optional port number.
81 def __init__(
82 self,
83 hostname: str,
84 port: Nullable[int] = None
85 ) -> None:
86 """
87 Initialize a host instance described by host name and port number.
89 :param hostname: Name of the host (either IP address or DNS).
90 :param port: Port number.
91 """
92 super().__init__()
94 if not isinstance(hostname, str): 94 ↛ 95line 94 didn't jump to line 95 because the condition on line 94 was never true
95 ex = TypeError("Parameter 'hostname' is not of type 'str'.")
96 ex.add_note(f"Got type '{getFullyQualifiedName(hostname)}'.")
97 raise ex
99 self._hostname = hostname
101 if port is None:
102 pass
103 elif not isinstance(port, int): 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true
104 ex = TypeError("Parameter 'port' is not of type 'int'.")
105 ex.add_note(f"Got type '{getFullyQualifiedName(port)}'.")
106 raise ex
107 elif not (0 <= port < 65536): 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true
108 ex = ValueError("Parameter 'port' is out of range 0..65535.")
109 ex.add_note(f"Got value '{port}'.")
110 raise ex
112 self._port = port
114 @readonly
115 def Hostname(self) -> str:
116 """
117 Read-only property to access the hostname.
119 :returns: Hostname as DNS name or IP address.
120 """
121 return self._hostname
123 @readonly
124 def Port(self) -> Nullable[int]:
125 """
126 Read-only property to access the optional port number.
128 :returns: Optional port number.
129 """
130 return self._port
132 def __str__(self) -> str:
133 result = self._hostname
134 if self._port is not None:
135 result += f":{self._port}"
137 return result
139 def Copy(self) -> "Host":
140 """
141 Create a copy of this object.
143 :return: A new :class:`Host` instance.
144 """
145 return self.__class__(
146 self._hostname,
147 self._port
148 )
151@export
152class Element(ElementMixIn):
153 """Derived class for the URL context."""
156@export
157class Path(PathMixIn):
158 """Represents a path in a URL."""
160 ELEMENT_DELIMITER = "/" #: Delimiter symbol in URLs between path elements.
161 ROOT_DELIMITER = "/" #: Delimiter symbol in URLs between root element and first path element.
163 @classmethod
164 def Parse(cls, path: str, root: Nullable[Host] = None) -> "Path":
165 return super().Parse(path, root, cls, Element)
168@export
169class URL:
170 """
171 Represents a URL (Uniform Resource Locator) including scheme, host, credentials, path, query and fragment.
173 .. code-block::
175 [schema://][user[:password]@]domain.tld[:port]/path/to/file[?query][#fragment]
176 """
178 _scheme: Protocols
179 _user: Nullable[str]
180 _password: Nullable[str]
181 _host: Nullable[Host]
182 _path: Path
183 _query: Nullable[Dict[str, str]]
184 _fragment: Nullable[str]
186 def __init__(
187 self,
188 scheme: Protocols,
189 path: Path,
190 host: Nullable[Host] = None,
191 user: Nullable[str] = None,
192 password: Nullable[str] = None,
193 query: Nullable[Mapping[str, str]] = None,
194 fragment: Nullable[str] = None
195 ) -> None:
196 """
197 Initializes a Uniform Resource Locator (URL).
199 :param scheme: Transport scheme to be used for a specified resource.
200 :param path: Path to the resource.
201 :param host: Hostname where the resource is located.
202 :param user: Username for basic authentication.
203 :param password: Password for basic authentication.
204 :param query: An optional query string.
205 :param fragment: An optional fragment.
206 """
207 if scheme is not None and not isinstance(scheme, Protocols): 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true
208 ex = TypeError("Parameter 'scheme' is not of type 'Protocols'.")
209 ex.add_note(f"Got type '{getFullyQualifiedName(scheme)}'.")
210 raise ex
212 self._scheme = scheme
214 if user is not None and not isinstance(user, str): 214 ↛ 215line 214 didn't jump to line 215 because the condition on line 214 was never true
215 ex = TypeError("Parameter 'user' is not of type 'str'.")
216 ex.add_note(f"Got type '{getFullyQualifiedName(user)}'.")
217 raise ex
219 self._user = user
221 if password is not None and not isinstance(password, str): 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true
222 ex = TypeError(f"Parameter 'password' is not of type 'str'.")
223 ex.add_note(f"Got type '{getFullyQualifiedName(password)}'.")
224 raise ex
226 self._password = password
228 if host is not None and not isinstance(host, Host): 228 ↛ 229line 228 didn't jump to line 229 because the condition on line 228 was never true
229 ex = TypeError(f"Parameter 'host' is not of type 'Host'.")
230 ex.add_note(f"Got type '{getFullyQualifiedName(host)}'.")
231 raise ex
232 self._host = host
234 if path is not None and not isinstance(path, Path): 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true
235 ex = TypeError(f"Parameter 'path' is not of type 'Path'.")
236 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
237 raise ex
239 self._path = path
241 if query is not None:
242 if not isinstance(query, Mapping): 242 ↛ 243line 242 didn't jump to line 243 because the condition on line 242 was never true
243 ex = TypeError(f"Parameter 'query' is not a mapping ('dict', ...).")
244 ex.add_note(f"Got type '{getFullyQualifiedName(query)}'.")
245 raise ex
247 self._query = {keyword: value for keyword, value in query.items()}
248 else:
249 self._query = None
251 if fragment is not None and not isinstance(fragment, str): 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true
252 ex = TypeError(f"Parameter 'fragment' is not of type 'str'.")
253 ex.add_note(f"Got type '{getFullyQualifiedName(fragment)}'.")
254 raise ex
256 self._fragment = fragment
258 @readonly
259 def Scheme(self) -> Protocols:
260 """
261 Read-only property to access the URL scheme.
263 :returns: URL scheme of the URL.
264 """
265 return self._scheme
267 @readonly
268 def User(self) -> Nullable[str]:
269 """
270 Read-only property to access the optional username.
272 :returns: Optional username within the URL.
273 """
274 return self._user
276 @readonly
277 def Password(self) -> Nullable[str]:
278 """
279 Read-only property to access the optional password.
281 :returns: Optional password within a URL.
282 """
283 return self._password
285 @readonly
286 def Host(self) -> Nullable[Host]:
287 """
288 Read-only property to access the host part (hostname and port number) of the URL.
290 :returns: The host part of the URL.
291 """
292 return self._host
294 @readonly
295 def Path(self) -> Path:
296 """
297 Read-only property to access the path part of the URL.
299 :returns: Path part of the URL.
300 """
301 return self._path
303 @readonly
304 def Query(self) -> Nullable[Dict[str, str]]:
305 """
306 Read-only property to access the dictionary of key-value pairs representing the query part in the URL.
308 :returns: A dictionary representing the query as key-value pairs.
309 """
310 return self._query
312 @readonly
313 def Fragment(self) -> Nullable[str]:
314 """
315 Read-only property to access the fragment part of the URL.
317 :returns: The fragment part of the URL.
318 """
319 return self._fragment
321 # http://semaphore.plc2.de:5000/api/v1/semaphore?name=Riviera&foo=bar#page2
322 @classmethod
323 def Parse(cls, url: str) -> "URL":
324 """
325 Parse a URL string and returns the URL object.
327 :param url: URL as string to be parsed.
328 :returns: A URL object.
329 :raises ToolingException: When syntax does not match.
330 """
331 if (matches := URL_REGEXP.match(url)) is not None: 331 ↛ 365line 331 didn't jump to line 365 because the condition on line 331 was always true
332 scheme = matches.group("scheme")
333 user = matches.group("user")
334 password = matches.group("password")
335 host = matches.group("host")
337 port = matches.group("port")
338 if port is not None:
339 port = int(port)
340 path = matches.group("path")
341 query = matches.group("query")
342 fragment = matches.group("fragment")
344 scheme = None if scheme is None else Protocols[scheme.upper()]
345 hostObj = None if host is None else Host(host, port)
347 pathObj = Path.Parse(path, hostObj)
349 parameters = {}
350 if query is not None:
351 for pair in query.split("&"):
352 key, value = pair.split("=")
353 parameters[key] = value
355 return cls(
356 scheme,
357 pathObj,
358 hostObj,
359 user,
360 password,
361 parameters if len(parameters) > 0 else None,
362 fragment
363 )
365 raise ToolingException(f"Syntax error when parsing URL '{url}'.")
367 def __str__(self) -> str:
368 """
369 Formats the URL object as a string representation.
371 :returns: Formatted URL object.
372 """
373 result = str(self._path)
375 if self._host is not None: 375 ↛ 378line 375 didn't jump to line 378 because the condition on line 375 was always true
376 result = str(self._host) + result
378 if self._user is not None:
379 if self._password is not None:
380 result = f"{self._user}:{self._password}@{result}"
381 else:
382 result = f"{self._user}@{result}"
384 if self._scheme is not None:
385 result = self._scheme.name.lower() + "://" + result
387 if self._query is not None and len(self._query) > 0:
388 result = result + "?" + "&".join([f"{key}={value}" for key, value in self._query.items()])
390 if self._fragment is not None:
391 result = result + "#" + self._fragment
393 return result
395 def WithoutCredentials(self) -> "URL":
396 """
397 Returns a URL object without credentials (username and password).
399 :returns: New URL object without credentials.
400 """
401 return self.__class__(
402 scheme=self._scheme,
403 path=self._path,
404 host=self._host,
405 query=self._query,
406 fragment=self._fragment
407 )