Coverage for pyTooling/GenericPath/URL.py: 91%

261 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-25 12:33 +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 urllib.parse import quote as urlQuote 

43from typing import ClassVar, Optional as Nullable, Mapping, Union 

44 

45from pyTooling.Decorators import export, readonly 

46from pyTooling.Exceptions import ToolingException 

47from pyTooling.Common import getFullyQualifiedName 

48from pyTooling.GenericPath import RootMixin, ElementMixin, PathMixin 

49 

50 

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

52 

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. 

62 

63FORBIDDEN_CHARACTERS = " <>\"{}|\\^`" #: Characters :rfc:`3986` forbids in a URL unless they are percent-encoded. 

64 

65 

66@export 

67class URLError(ToolingException): 

68 """ 

69 Raised when a URL can't be parsed. 

70 

71 .. note:: 

72 

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

76 

77 

78@export 

79class Protocols(Flag): 

80 """ 

81 Enumeration of supported URL schemes. 

82 

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: 

86 

87 .. code-block:: Python 

88 

89 if Protocols.TLS in url.Scheme: 

90 print(f"'{url}' is secured by TLS.") 

91 

92 if url.Scheme.IsEncrypted: 

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

94 

95 .. attention:: 

96 

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. 

99 

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

102 

103 .. note:: 

104 

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. 

110 

111 .. caution:: 

112 

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. 

116 

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

121 

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 

138 

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

147 

148 POSTGRESQL = POSTGRES #: Alias of :attr:`POSTGRES`, the spelling ``libpq`` documents. 

149 

150 @readonly 

151 def IsEncrypted(self) -> bool: 

152 """ 

153 Read-only property to return whether the scheme is encrypted. 

154 

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. 

157 

158 :returns: ``True``, if the scheme is secured by TLS or carried by SSH. 

159 """ 

160 return bool(self & (Protocols.TLS | Protocols.SSH)) 

161 

162 

163@export 

164class Host(RootMixin): 

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

166 

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

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

169 

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. 

177 

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 :raises TypeError: If parameter 'hostname' is not of type :class:`str`. 

182 :raises TypeError: If parameter 'port' is not of type :class:`int`. 

183 :raises ValueError: If parameter 'port' is out of range 0..65535. 

184 """ 

185 super().__init__() 

186 

187 if hostname is None: 

188 raise ValueError("Parameter 'hostname' is None.") 

189 elif not isinstance(hostname, str): 

190 ex = TypeError("Parameter 'hostname' is not of type 'str'.") 

191 ex.add_note(f"Got type '{getFullyQualifiedName(hostname)}'.") 

192 raise ex 

193 elif hostname == "": 

194 raise ValueError("Parameter 'hostname' is empty.") 

195 

196 self._hostname = hostname 

197 

198 if port is None: 

199 pass 

200 elif not isinstance(port, int): 

201 ex = TypeError("Parameter 'port' is not of type 'int'.") 

202 ex.add_note(f"Got type '{getFullyQualifiedName(port)}'.") 

203 raise ex 

204 elif not (0 <= port < 65536): 

205 ex = ValueError("Parameter 'port' is out of range 0..65535.") 

206 ex.add_note(f"Got value '{port}'.") 

207 raise ex 

208 

209 self._port = port 

210 

211 @readonly 

212 def Hostname(self) -> str: 

213 """ 

214 Read-only property to access the hostname. 

215 

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

217 """ 

218 return self._hostname 

219 

220 @readonly 

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

222 """ 

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

224 

225 :returns: Optional port number. 

226 """ 

227 return self._port 

228 

229 def __str__(self) -> str: 

230 """ 

231 Return a string representation of this host. 

232 

233 :returns: Hostname, followed by ``:port`` if a port is specified. 

234 """ 

235 result = self._hostname 

236 if self._port is not None: 

237 result += f":{self._port}" 

238 

239 return result 

240 

241 def Copy(self) -> Host: 

242 """ 

243 Create a copy of this object. 

244 

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

246 """ 

247 return self.__class__( 

248 self._hostname, 

249 self._port 

250 ) 

251 

252 

253@export 

254class Element(ElementMixin): 

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

256 

257 

258@export 

259class Path(PathMixin): 

260 """Represents a path in a URL.""" 

261 

262 ELEMENT_DELIMITER: ClassVar[str] = "/" #: Delimiter symbol in URLs between path elements. 

263 ROOT_DELIMITER: ClassVar[str] = "/" #: Delimiter symbol in URLs between root and first element. 

264 ELEMENT_TYPE: ClassVar[type[Element]] = Element #: Type an element of a URL's path has. 

265 

266 

267@export 

268class URL: 

269 """ 

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

271 

272 .. code-block:: 

273 

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

275 """ 

276 

277 _scheme: Nullable[Protocols] #: Protocol (scheme) of the URL, ``None`` if the URL carries none. 

278 _user: Nullable[str] #: User name of the URL's authority part. 

279 _password: Nullable[str] #: Password of the URL's authority part. 

280 _host: Nullable[Host] #: Host name and port of the URL's authority part. 

281 _path: Path #: Path part of the URL. 

282 _query: Nullable[dict[str, str]] #: Query parameters of the URL, by parameter name. 

283 _fragment: Nullable[str] #: Fragment (anchor) of the URL. 

284 

285 def __init__( 

286 self, 

287 scheme: Nullable[Protocols], 

288 path: Path, 

289 host: Nullable[Host] = None, 

290 user: Nullable[str] = None, 

291 password: Nullable[str] = None, 

292 query: Nullable[Mapping[str, str]] = None, 

293 fragment: Nullable[str] = None 

294 ) -> None: 

295 """ 

296 Initializes a Uniform Resource Locator (URL). 

297 

298 :param scheme: Transport scheme to be used for a specified resource. 

299 :param path: Path to the resource. 

300 :param host: Optional, hostname where the resource is located. 

301 :param user: Optional, username for basic authentication. 

302 :param password: Optional, password for basic authentication. 

303 :param query: Optional, query string. 

304 :param fragment: Optional, fragment. 

305 :raises TypeError: If parameter 'host' is not of type :class:`Host`. 

306 """ 

307 if scheme is not None and not isinstance(scheme, Protocols): 307 ↛ 308line 307 didn't jump to line 308 because the condition on line 307 was never true

308 ex = TypeError("Parameter 'scheme' is not of type 'Protocols'.") 

309 ex.add_note(f"Got type '{getFullyQualifiedName(scheme)}'.") 

310 raise ex 

311 

312 self._scheme = scheme 

313 

314 if user is not None and not isinstance(user, str): 314 ↛ 315line 314 didn't jump to line 315 because the condition on line 314 was never true

315 ex = TypeError("Parameter 'user' is not of type 'str'.") 

316 ex.add_note(f"Got type '{getFullyQualifiedName(user)}'.") 

317 raise ex 

318 

319 self._user = user 

320 

321 if password is not None and not isinstance(password, str): 321 ↛ 322line 321 didn't jump to line 322 because the condition on line 321 was never true

322 ex = TypeError("Parameter 'password' is not of type 'str'.") 

323 ex.add_note(f"Got type '{getFullyQualifiedName(password)}'.") 

324 raise ex 

325 

326 self._password = password 

327 

328 if host is not None and not isinstance(host, Host): 328 ↛ 329line 328 didn't jump to line 329 because the condition on line 328 was never true

329 ex = TypeError("Parameter 'host' is not of type 'Host'.") 

330 ex.add_note(f"Got type '{getFullyQualifiedName(host)}'.") 

331 raise ex 

332 self._host = host 

333 

334 if path is not None and not isinstance(path, Path): 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true

335 ex = TypeError("Parameter 'path' is not of type 'Path'.") 

336 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.") 

337 raise ex 

338 

339 self._path = path 

340 

341 if query is not None: 

342 if not isinstance(query, Mapping): 342 ↛ 343line 342 didn't jump to line 343 because the condition on line 342 was never true

343 ex = TypeError("Parameter 'query' is not a mapping ('dict', ...).") 

344 ex.add_note(f"Got type '{getFullyQualifiedName(query)}'.") 

345 raise ex 

346 

347 self._query = dict(query) 

348 else: 

349 self._query = None 

350 

351 if fragment is not None and not isinstance(fragment, str): 351 ↛ 352line 351 didn't jump to line 352 because the condition on line 351 was never true

352 ex = TypeError("Parameter 'fragment' is not of type 'str'.") 

353 ex.add_note(f"Got type '{getFullyQualifiedName(fragment)}'.") 

354 raise ex 

355 

356 self._fragment = fragment 

357 

358 @readonly 

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

360 """ 

361 Read-only property to access the URL scheme. 

362 

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

364 """ 

365 return self._scheme 

366 

367 @readonly 

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

369 """ 

370 Read-only property to access the optional username. 

371 

372 :returns: Optional username within the URL. 

373 """ 

374 return self._user 

375 

376 @readonly 

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

378 """ 

379 Read-only property to access the optional password. 

380 

381 :returns: Optional password within a URL. 

382 """ 

383 return self._password 

384 

385 @readonly 

386 def Host(self) -> Nullable[Host]: 

387 """ 

388 Read-only property to access the host part (hostname and port number) of the URL. 

389 

390 :returns: The host part of the URL. 

391 """ 

392 return self._host 

393 

394 @readonly 

395 def Path(self) -> Path: 

396 """ 

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

398 

399 :returns: Path part of the URL. 

400 """ 

401 return self._path 

402 

403 @readonly 

404 def Query(self) -> Nullable[dict[str, str]]: 

405 """ 

406 Read-only property to access the dictionary of key-value pairs representing the query part in the URL. 

407 

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

409 """ 

410 return self._query 

411 

412 @readonly 

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

414 """ 

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

416 

417 :returns: The fragment part of the URL. 

418 """ 

419 return self._fragment 

420 

421 # http://semaphore.plc2.de:5000/api/v1/semaphore?name=Riviera&foo=bar#page2 

422 @classmethod 

423 def Parse(cls, url: str) -> URL: 

424 """ 

425 Parse a URL string and returns the URL object. 

426 

427 :param url: URL as string to be parsed. 

428 :returns: A URL object. 

429 :raises ValueError: If parameter 'url' is ``None``. 

430 :raises TypeError: If parameter 'url' is not of type :class:`str`. 

431 :raises URLError: When syntax does not match. |br| 

432 A note names the first character :rfc:`3986` forbids, if the URL holds one, and how 

433 to percent-encode it. 

434 :raises URLError: When the URL names a scheme that is not in :class:`Protocols`. |br| 

435 The note lists the known schemes. 

436 :raises URLError: When a parameter of the query is not a ``key=value`` pair. 

437 """ 

438 if url is None: 

439 raise ValueError("Parameter 'url' is None.") 

440 elif not isinstance(url, str): 

441 ex = TypeError("Parameter 'url' is not of type 'str'.") 

442 ex.add_note(f"Got type '{getFullyQualifiedName(url)}'.") 

443 raise ex 

444 

445 if (matches := URL_REGEXP.match(url)) is not None: 

446 scheme = matches.group("scheme") 

447 user = matches.group("user") 

448 password = matches.group("password") 

449 host = matches.group("host") 

450 

451 port = matches.group("port") 

452 if port is not None: 

453 port = int(port) 

454 path = matches.group("path") 

455 query = matches.group("query") 

456 fragment = matches.group("fragment") 

457 

458 if scheme is not None: 

459 try: 

460 scheme = Protocols[scheme.upper()] 

461 except KeyError as ex: 

462 error = URLError(f"Unknown scheme '{scheme}' when parsing URL '{url}'.") 

463 error.add_note(f"Known schemes: {', '.join(name.lower() for name in Protocols.__members__)}.") 

464 raise error from ex 

465 

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

467 pathObj = Path.Parse(path, hostObj) 

468 parameters = None if query is None else cls._ParseQuery(query, url) 

469 

470 return cls( 

471 scheme, 

472 pathObj, 

473 hostObj, 

474 user, 

475 password, 

476 parameters, 

477 fragment 

478 ) 

479 

480 error = URLError(f"Syntax error when parsing URL {url!r}.") 

481 for character in url: 

482 if character in FORBIDDEN_CHARACTERS or character.isspace() or not character.isprintable(): 

483 error.add_note( 

484 f"Character {character!r} is not allowed in a URL. Write it percent-encoded as " 

485 f"'{urlQuote(character, safe='')}'." 

486 ) 

487 break 

488 

489 raise error 

490 

491 @classmethod 

492 def _ParseQuery(cls, query: str, url: str) -> dict[str, str]: 

493 """ 

494 Parse a URL's query into its parameters. 

495 

496 :param query: The query, without the leading ``?``. It is not empty - a URL carrying no query has none. 

497 :param url: The URL the query came from, for the exception's message. 

498 :returns: The parameters by name. 

499 :raises URLError: When a parameter of the query is not a ``key=value`` pair. 

500 """ 

501 parameters = {} 

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

503 key, separator, value = pair.partition("=") 

504 if separator == "": 

505 error = URLError(f"Query parameter '{pair}' is no 'key=value' pair in URL '{url}'.") 

506 error.add_note("Every parameter of a query needs a '=', even when its value is empty.") 

507 raise error 

508 

509 parameters[key] = value 

510 

511 return parameters 

512 

513 @classmethod 

514 def _CheckRelativeReference(cls, reference: str) -> None: 

515 """ 

516 Check a string names a resource below a URL. 

517 

518 A relative reference of :rfc:`3986` names no scheme and no authority: it starts with no ``//``, and the first 

519 segment of its path holds no ``:``, which is the ``path-noscheme`` rule. A complete URL is read by 

520 :meth:`Parse` instead of being appended to another. 

521 

522 :param reference: The right side of the ``/`` operator. 

523 :raises URLError: When the reference names a scheme or an authority. 

524 :raises URLError: When the reference holds a character :rfc:`3986` forbids. |br| 

525 The note names the character and how to percent-encode it. 

526 """ 

527 path = reference.partition("#")[0].partition("?")[0] 

528 if path.startswith("//"): 

529 error = URLError(f"Relative reference '{reference}' names an authority.") 

530 error.add_note("A reference below a URL carries no host, user, password or port.") 

531 error.add_note("Read a complete URL with 'URL.Parse' instead of appending it.") 

532 raise error 

533 elif ":" in path.split(Path.ELEMENT_DELIMITER, 1)[0]: 

534 error = URLError(f"Relative reference '{reference}' names a scheme.") 

535 error.add_note("The first element of a relative path carries no ':' - it would read as a scheme.") 

536 error.add_note("Read a complete URL with 'URL.Parse' instead of appending it. Write a ':' further down " 

537 "the path percent-encoded as '%3A'.") 

538 raise error 

539 

540 for character in reference: 

541 if character in FORBIDDEN_CHARACTERS or character.isspace() or not character.isprintable(): 

542 error = URLError(f"Relative reference '{reference}' holds a forbidden character.") 

543 error.add_note( 

544 f"Character {character!r} is not allowed in a URL. Write it percent-encoded as " 

545 f"'{urlQuote(character, safe='')}'." 

546 ) 

547 raise error 

548 

549 def __truediv__(self, other: Union[str, Path]) -> URL: 

550 """ 

551 Return this URL with a resource below it. 

552 

553 The right side is a **relative reference**: its path is appended below this URL's, and it brings its own query 

554 and fragment, which this URL's are not carried into - :rfc:`3986` resolves a reference the same way. A path 

555 that starts with a slash names where it starts itself and replaces this URL's path. 

556 

557 .. code-block:: python 

558 

559 URL.Parse("https://example.org/api/v3") / "things/4711?fields=name" 

560 # https://example.org/api/v3/things/4711?fields=name 

561 

562 :param other: The resource below this URL, as a string to parse or as a :class:`Path`. 

563 :returns: A new URL naming that resource. 

564 :raises TypeError: If parameter 'other' is neither of type :class:`str` nor of type :class:`Path`. 

565 :raises URLError: When the right side names a scheme or an authority instead of a resource. 

566 :raises URLError: When the right side holds a character :rfc:`3986` forbids. |br| 

567 The note names the character and how to percent-encode it. 

568 :raises URLError: When a parameter of the right side's query is not a ``key=value`` pair. 

569 """ 

570 if isinstance(other, str): 

571 self._CheckRelativeReference(other) 

572 

573 resource, _, fragment = other.partition("#") 

574 resource, _, query = resource.partition("?") 

575 parameters = None if query == "" else self._ParseQuery(query, other) 

576 fragment = fragment if fragment != "" else None 

577 elif isinstance(other, Path): 

578 resource = other 

579 parameters = None 

580 fragment = None 

581 else: 

582 ex = TypeError("Second operand is not supported by / operator.") 

583 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.") 

584 ex.add_note("Supported types for second operand: 'str' or 'Path'.") 

585 raise ex 

586 

587 return self.__class__( 

588 scheme=self._scheme, 

589 path=self._path / resource, 

590 host=self._host, 

591 user=self._user, 

592 password=self._password, 

593 query=parameters, 

594 fragment=fragment 

595 ) 

596 

597 def __str__(self) -> str: 

598 """ 

599 Formats the URL object as a string representation. 

600 

601 :returns: Formatted URL object. 

602 """ 

603 result = str(self._path) 

604 

605 if self._host is not None: 605 ↛ 608line 605 didn't jump to line 608 because the condition on line 605 was always true

606 result = str(self._host) + result 

607 

608 if self._user is not None: 

609 if self._password is not None: 

610 result = f"{self._user}:{self._password}@{result}" 

611 else: 

612 result = f"{self._user}@{result}" 

613 

614 # 'Protocols' is a 'Flag', so a single member and a combination both have a name; only 'Protocols(0)' has 

615 # none - and a scheme without any flag is no scheme, so it renders nothing either way. 

616 if self._scheme is not None and (scheme := self._scheme.name) is not None: 

617 result = scheme.lower() + "://" + result 

618 

619 if self._query is not None and len(self._query) > 0: 

620 result = result + "?" + "&".join([f"{key}={value}" for key, value in self._query.items()]) 

621 

622 if self._fragment is not None: 

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

624 

625 return result 

626 

627 def WithoutCredentials(self) -> URL: 

628 """ 

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

630 

631 :returns: New URL object without credentials. 

632 """ 

633 return self.__class__( 

634 scheme=self._scheme, 

635 path=self._path, 

636 host=self._host, 

637 query=self._query, 

638 fragment=self._fragment 

639 ) 

640 

641 def WithoutTrailingSlash(self) -> URL: 

642 """ 

643 Returns a URL object whose path doesn't end in a slash. 

644 

645 A URL's element delimiter is the slash, so this is :meth:`~pyTooling.GenericPath.PathMixin.WithoutTrailingDelimiter` 

646 applied to the URL's path. A trailing slash is an empty last element, so ``https://example.org/api/v3/`` and 

647 ``https://example.org/api/v3`` differ although they usually address the same resource. A URL that is composed 

648 with a path below it wants the latter, or the composition yields a double slash. 

649 

650 :returns: New URL object without a trailing slash, or this URL, if its path has none. 

651 

652 .. seealso:: 

653 

654 :meth:`~pyTooling.GenericPath.PathMixin.WithoutTrailingDelimiter` 

655 |rarr| What it does to the path, and what it leaves alone. 

656 """ 

657 if (path := self._path.WithoutTrailingDelimiter()) is self._path: 

658 return self 

659 

660 return self.__class__( 

661 scheme=self._scheme, 

662 path=path, 

663 host=self._host, 

664 user=self._user, 

665 password=self._password, 

666 query=self._query, 

667 fragment=self._fragment 

668 )