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

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). 

33 

34.. code-block:: 

35 

36 [schema://][user[:password]@]domain.tld[:port]/path/to/file[?query][#fragment] 

37""" 

38from __future__ import annotations 

39 

40from enum import Flag 

41from re import compile as re_compile 

42from typing import ClassVar, Optional as Nullable, Mapping 

43 

44from pyTooling.Decorators import export, readonly 

45from pyTooling.Exceptions import ToolingException 

46from pyTooling.Common import getFullyQualifiedName 

47from pyTooling.GenericPath import RootMixIn, ElementMixIn, PathMixIn 

48 

49 

50__all__ = ["URL_PATTERN", "URL_REGEXP"] 

51 

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. 

61 

62 

63@export 

64class Protocols(Flag): 

65 """ 

66 Enumeration of supported URL schemes. 

67 

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: 

70 

71 .. code-block:: Python 

72 

73 if Protocols.TLS in url.Scheme: 

74 print(f"'{url}' is encrypted.") 

75 """ 

76 

77 TLS = 1 #: Transport Layer Security 

78 FILE = 2 #: Local files 

79 HTTP = 4 #: Hyper Text Transfer Protocol 

80 FTP = 8 #: File Transfer Protocol 

81 

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`. 

84 

85 

86@export 

87class Host(RootMixIn): 

88 """Represents a host as either hostname, DNS or IP-address including the port number in a URL.""" 

89 

90 _hostname: str #: Name of the host (DNS name or IP address). 

91 _port: Nullable[int] #: Optional port number. 

92 

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. 

100 

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__() 

106 

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 

112 self._hostname = hostname 

113 

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 

124 

125 self._port = port 

126 

127 @readonly 

128 def Hostname(self) -> str: 

129 """ 

130 Read-only property to access the hostname. 

131 

132 :returns: Hostname as DNS name or IP address. 

133 """ 

134 return self._hostname 

135 

136 @readonly 

137 def Port(self) -> Nullable[int]: 

138 """ 

139 Read-only property to access the optional port number. 

140 

141 :returns: Optional port number. 

142 """ 

143 return self._port 

144 

145 def __str__(self) -> str: 

146 """ 

147 Return a string representation of this host. 

148 

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}" 

154 

155 return result 

156 

157 def Copy(self) -> Host: 

158 """ 

159 Create a copy of this object. 

160 

161 :returns: A new :class:`Host` instance. 

162 """ 

163 return self.__class__( 

164 self._hostname, 

165 self._port 

166 ) 

167 

168 

169@export 

170class Element(ElementMixIn): 

171 """Derived class for the URL context.""" 

172 

173 

174@export 

175class Path(PathMixIn): 

176 """Represents a path in a URL.""" 

177 

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. 

180 

181 @classmethod 

182 def Parse(cls, path: str, root: Nullable[Host] = None) -> Path: 

183 """ 

184 Parse a string into a URL path. 

185 

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) 

191 

192 

193@export 

194class URL: 

195 """ 

196 Represents a URL (Uniform Resource Locator) including scheme, host, credentials, path, query and fragment. 

197 

198 .. code-block:: 

199 

200 [schema://][user[:password]@]domain.tld[:port]/path/to/file[?query][#fragment] 

201 """ 

202 

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. 

210 

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). 

223 

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 

237 

238 self._scheme = scheme 

239 

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 

244 

245 self._user = user 

246 

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 

251 

252 self._password = password 

253 

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 

259 

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 

264 

265 self._path = path 

266 

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 

272 

273 self._query = {keyword: value for keyword, value in query.items()} 

274 else: 

275 self._query = None 

276 

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 

281 

282 self._fragment = fragment 

283 

284 @readonly 

285 def Scheme(self) -> Nullable[Protocols]: 

286 """ 

287 Read-only property to access the URL scheme. 

288 

289 :returns: URL scheme of the URL, ``None`` if it carries none. 

290 """ 

291 return self._scheme 

292 

293 @readonly 

294 def User(self) -> Nullable[str]: 

295 """ 

296 Read-only property to access the optional username. 

297 

298 :returns: Optional username within the URL. 

299 """ 

300 return self._user 

301 

302 @readonly 

303 def Password(self) -> Nullable[str]: 

304 """ 

305 Read-only property to access the optional password. 

306 

307 :returns: Optional password within a URL. 

308 """ 

309 return self._password 

310 

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. 

315 

316 :returns: The host part of the URL. 

317 """ 

318 return self._host 

319 

320 @readonly 

321 def Path(self) -> Path: 

322 """ 

323 Read-only property to access the path part of the URL. 

324 

325 :returns: Path part of the URL. 

326 """ 

327 return self._path 

328 

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. 

333 

334 :returns: A dictionary representing the query as key-value pairs. 

335 """ 

336 return self._query 

337 

338 @readonly 

339 def Fragment(self) -> Nullable[str]: 

340 """ 

341 Read-only property to access the fragment part of the URL. 

342 

343 :returns: The fragment part of the URL. 

344 """ 

345 return self._fragment 

346 

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. 

352 

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") 

362 

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") 

369 

370 scheme = None if scheme is None else Protocols[scheme.upper()] 

371 hostObj = None if host is None else Host(host, port) 

372 

373 pathObj = Path.Parse(path, hostObj) 

374 

375 parameters = {} 

376 if query is not None: 

377 for pair in query.split("&"): 

378 key, value = pair.split("=") 

379 parameters[key] = value 

380 

381 return cls( 

382 scheme, 

383 pathObj, 

384 hostObj, 

385 user, 

386 password, 

387 parameters if len(parameters) > 0 else None, 

388 fragment 

389 ) 

390 

391 raise ToolingException(f"Syntax error when parsing URL '{url}'.") 

392 

393 def __str__(self) -> str: 

394 """ 

395 Formats the URL object as a string representation. 

396 

397 :returns: Formatted URL object. 

398 """ 

399 result = str(self._path) 

400 

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 

403 

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}" 

409 

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 

414 

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()]) 

417 

418 if self._fragment is not None: 

419 result = result + "#" + self._fragment 

420 

421 return result 

422 

423 def WithoutCredentials(self) -> URL: 

424 """ 

425 Returns a URL object without credentials (username and password). 

426 

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 )