Coverage for pyTooling/GenericPath/URL.py: 80%
173 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-28 13:37 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-28 13:37 +0000
1# ==================================================================================================================== #
2# _____ _ _ ____ _ ____ _ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___| ___ _ __ ___ _ __(_) ___| _ \ __ _| |_| |__ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` || | _ / _ \ '_ \ / _ \ '__| |/ __| |_) / _` | __| '_ \ #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| || |_| | __/ | | | __/ | | | (__| __/ (_| | |_| | | | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____|\___|_| |_|\___|_| |_|\___|_| \__,_|\__|_| |_| #
7# |_| |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2017-2025 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 sys import version_info
40from enum import IntFlag
41from re import compile as re_compile
42from typing import Dict, Optional as Nullable, Mapping
44try:
45 from pyTooling.Decorators import export, readonly
46 from pyTooling.Exceptions import ToolingException
47 from pyTooling.Common import getFullyQualifiedName
48 from pyTooling.GenericPath import RootMixIn, ElementMixIn, PathMixIn
49except (ImportError, ModuleNotFoundError): # pragma: no cover
50 print("[pyTooling.GenericPath.URL] Could not import from 'pyTooling.*'!")
52 try:
53 from Decorators import export, readonly
54 from Exceptions import ToolingException
55 from Common import getFullyQualifiedName
56 from GenericPath import RootMixIn, ElementMixIn, PathMixIn
57 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover
58 print("[pyTooling.GenericPath.URL] Could not import directly!")
59 raise ex
62__all__ = ["URL_PATTERN", "URL_REGEXP"]
64URL_PATTERN = (
65 r"""(?:(?P<scheme>\w+)://)?"""
66 r"""(?:(?P<user>[-a-zA-Z0-9_]+)(?::(?P<password>[-a-zA-Z0-9_]+))?@)?"""
67 r"""(?:(?P<host>(?:[-a-zA-Z0-9_]+)(?:\.[-a-zA-Z0-9_]+)*\.?)(?:\:(?P<port>\d+))?)?"""
68 r"""(?P<path>[^?#]*?)"""
69 r"""(?:\?(?P<query>[^#]+?))?"""
70 r"""(?:#(?P<fragment>.+?))?"""
71) #: Regular expression pattern for validating and splitting a URL.
72URL_REGEXP = re_compile("^" + URL_PATTERN + "$") #: Precompiled regular expression for URL validation.
75@export
76class Protocols(IntFlag):
77 """Enumeration of supported URL schemes."""
79 TLS = 1 #: Transport Layer Security
80 HTTP = 2 #: Hyper Text Transfer Protocol
81 HTTPS = 4 #: SSL/TLS secured HTTP
82 FTP = 8 #: File Transfer Protocol
83 FTPS = 16 #: SSL/TLS secured FTP
84 FILE = 32 #: Local files
87@export
88class Host(RootMixIn):
89 """Represents a host as either hostname, DNS or IP-address including the port number in a URL."""
91 _hostname: str
92 _port: Nullable[int]
94 def __init__(
95 self,
96 hostname: str,
97 port: Nullable[int] = None
98 ) -> None:
99 """
100 Initialize a host instance described by host name and port number.
102 :param hostname: Name of the host (either IP or DNS).
103 :param port: Port number.
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
111 self._hostname = hostname
113 if port is None:
114 pass
115 elif not isinstance(port, int): 115 ↛ 116line 115 didn't jump to line 116 because the condition on line 115 was never true
116 ex = TypeError("Parameter 'port' is not of type 'int'.")
117 ex.add_note(f"Got type '{getFullyQualifiedName(hostname)}'.")
118 raise ex
119 elif not (0 <= port < 65536): 119 ↛ 120line 119 didn't jump to line 120 because the condition on line 119 was never true
120 ex = ValueError("Parameter 'port' is out of range 0..65535.")
121 ex.add_note(f"Got value '{port}'.")
122 raise ex
123 self._port = port
125 @readonly
126 def Hostname(self) -> str:
127 """Hostname or IP address as string."""
128 return self._hostname
130 @readonly
131 def Port(self) -> Nullable[int]:
132 """Port number as integer."""
133 return self._port
135 def __str__(self) -> str:
136 result = self._hostname
137 if self._port is not None:
138 result += f":{self._port}"
140 return result
142 def Copy(self) -> "Host":
143 """
144 Create a copy of this object.
146 :return: A new Host instance.
147 """
148 return self.__class__(
149 self._hostname,
150 self._port
151 )
154@export
155class Element(ElementMixIn):
156 """Derived class for the URL context."""
159@export
160class Path(PathMixIn):
161 """Represents a path in a URL."""
163 ELEMENT_DELIMITER = "/" #: Delimiter symbol in URLs between path elements.
164 ROOT_DELIMITER = "/" #: Delimiter symbol in URLs between root element and first path element.
166 @classmethod
167 def Parse(cls, path: str, root: Nullable[Host] = None) -> "Path":
168 return super().Parse(path, root, cls, Element)
171@export
172class URL:
173 """
174 Represents a URL (Uniform Resource Locator) including scheme, host, credentials, path, query and fragment.
176 .. code-block::
178 [schema://][user[:password]@]domain.tld[:port]/path/to/file[?query][#fragment]
179 """
181 _scheme: Protocols
182 _user: Nullable[str]
183 _password: Nullable[str]
184 _host: Nullable[Host]
185 _path: Path
186 _query: Nullable[Dict[str, str]]
187 _fragment: Nullable[str]
189 def __init__(
190 self,
191 scheme: Protocols,
192 path: Path,
193 host: Nullable[Host] = None,
194 user: Nullable[str] = None,
195 password: Nullable[str] = None,
196 query: Nullable[Mapping[str, str]] = None,
197 fragment: Nullable[str] = None
198 ) -> None:
199 """
200 Initializes a Uniform Resource Locator (URL).
202 :param scheme: Transport scheme to be used for a specified resource.
203 :param path: Path to the resource.
204 :param host: Hostname where the resource is located.
205 :param user: Username for basic authentication.
206 :param password: Password for basic authentication.
207 :param query: An optional query string.
208 :param fragment: An optional fragment.
209 """
210 if scheme is not None and not isinstance(scheme, Protocols): 210 ↛ 211line 210 didn't jump to line 211 because the condition on line 210 was never true
211 ex = TypeError("Parameter 'scheme' is not of type 'Protocols'.")
212 ex.add_note(f"Got type '{getFullyQualifiedName(scheme)}'.")
213 raise ex
214 self._scheme = scheme
216 if user is not None and not isinstance(user, str): 216 ↛ 217line 216 didn't jump to line 217 because the condition on line 216 was never true
217 ex = TypeError("Parameter 'user' is not of type 'str'.")
218 ex.add_note(f"Got type '{getFullyQualifiedName(user)}'.")
219 raise ex
220 self._user = user
222 if password is not None and not isinstance(password, str): 222 ↛ 223line 222 didn't jump to line 223 because the condition on line 222 was never true
223 ex = TypeError(f"Parameter 'password' is not of type 'str'.")
224 ex.add_note(f"Got type '{getFullyQualifiedName(password)}'.")
225 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
238 self._path = path
240 if query is not None:
241 if not isinstance(query, Mapping): 241 ↛ 242line 241 didn't jump to line 242 because the condition on line 241 was never true
242 ex = TypeError(f"Parameter 'query' is not a mapping ('dict', ...).")
243 ex.add_note(f"Got type '{getFullyQualifiedName(query)}'.")
244 raise ex
246 self._query = {keyword: value for keyword, value in query.items()}
247 else:
248 self._query = None
250 if fragment is not None and not isinstance(fragment, str): 250 ↛ 251line 250 didn't jump to line 251 because the condition on line 250 was never true
251 ex = TypeError(f"Parameter 'fragment' is not of type 'str'.")
252 ex.add_note(f"Got type '{getFullyQualifiedName(fragment)}'.")
253 raise ex
254 self._fragment = fragment
256 @readonly
257 def Scheme(self) -> Protocols:
258 return self._scheme
260 @readonly
261 def User(self) -> Nullable[str]:
262 return self._user
264 @readonly
265 def Password(self) -> Nullable[str]:
266 return self._password
268 @readonly
269 def Host(self) -> Nullable[Host]:
270 """
271 Returns the host part (host name and port number) of the URL.
273 :return: The host part of the URL.
274 """
275 return self._host
277 @readonly
278 def Path(self) -> Path:
279 return self._path
281 @readonly
282 def Query(self) -> Nullable[Dict[str, str]]:
283 """
284 Returns a dictionary of key-value pairs representing the query part in a URL.
286 :returns: A dictionary representing the query.
287 """
288 return self._query
290 @readonly
291 def Fragment(self) -> Nullable[str]:
292 """
293 Returns the fragment part of the URL.
295 :return: The fragment part of the URL.
296 """
297 return self._fragment
299 # http://semaphore.plc2.de:5000/api/v1/semaphore?name=Riviera&foo=bar#page2
300 @classmethod
301 def Parse(cls, url: str) -> "URL":
302 """
303 Parse a URL string and returns a URL object.
305 :param url: URL as string to be parsed.
306 :returns: A URL object.
307 :raises ToolingException: When syntax does not match.
308 """
309 matches = URL_REGEXP.match(url)
310 if matches is not None: 310 ↛ 344line 310 didn't jump to line 344 because the condition on line 310 was always true
311 scheme = matches.group("scheme")
312 user = matches.group("user")
313 password = matches.group("password")
314 host = matches.group("host")
316 port = matches.group("port")
317 if port is not None:
318 port = int(port)
319 path = matches.group("path")
320 query = matches.group("query")
321 fragment = matches.group("fragment")
323 scheme = None if scheme is None else Protocols[scheme.upper()]
324 hostObj = None if host is None else Host(host, port)
326 pathObj = Path.Parse(path, hostObj)
328 parameters = {}
329 if query is not None:
330 for pair in query.split("&"):
331 key, value = pair.split("=")
332 parameters[key] = value
334 return cls(
335 scheme,
336 pathObj,
337 hostObj,
338 user,
339 password,
340 parameters if len(parameters) > 0 else None,
341 fragment
342 )
344 raise ToolingException(f"Syntax error when parsing URL '{url}'.")
346 def __str__(self) -> str:
347 """
348 Formats the URL object as a string representation.
350 :return: Formatted URL object.
351 """
352 result = str(self._path)
354 if self._host is not None: 354 ↛ 357line 354 didn't jump to line 357 because the condition on line 354 was always true
355 result = str(self._host) + result
357 if self._user is not None:
358 if self._password is not None:
359 result = f"{self._user}:{self._password}@{result}"
360 else:
361 result = f"{self._user}@{result}"
363 if self._scheme is not None:
364 result = self._scheme.name.lower() + "://" + result
366 if self._query is not None and len(self._query) > 0:
367 result = result + "?" + "&".join([f"{key}={value}" for key, value in self._query.items()])
369 if self._fragment is not None:
370 result = result + "#" + self._fragment
372 return result
374 def WithoutCredentials(self) -> "URL":
375 """
376 Returns a URL object without credentials (username and password).
378 :return: New URL object without credentials.
379 """
380 return self.__class__(
381 scheme=self._scheme,
382 path=self._path,
383 host=self._host,
384 query=self._query,
385 fragment=self._fragment
386 )