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

257 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-03 23:02 +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 """ 

182 super().__init__() 

183 

184 if not isinstance(hostname, str): 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true

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

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

187 raise ex 

188 

189 self._hostname = hostname 

190 

191 if port is None: 

192 pass 

193 elif not isinstance(port, int): 193 ↛ 194line 193 didn't jump to line 194 because the condition on line 193 was never true

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

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

196 raise ex 

197 elif not (0 <= port < 65536): 197 ↛ 198line 197 didn't jump to line 198 because the condition on line 197 was never true

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

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

200 raise ex 

201 

202 self._port = port 

203 

204 @readonly 

205 def Hostname(self) -> str: 

206 """ 

207 Read-only property to access the hostname. 

208 

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

210 """ 

211 return self._hostname 

212 

213 @readonly 

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

215 """ 

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

217 

218 :returns: Optional port number. 

219 """ 

220 return self._port 

221 

222 def __str__(self) -> str: 

223 """ 

224 Return a string representation of this host. 

225 

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

227 """ 

228 result = self._hostname 

229 if self._port is not None: 

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

231 

232 return result 

233 

234 def Copy(self) -> Host: 

235 """ 

236 Create a copy of this object. 

237 

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

239 """ 

240 return self.__class__( 

241 self._hostname, 

242 self._port 

243 ) 

244 

245 

246@export 

247class Element(ElementMixin): 

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

249 

250 

251@export 

252class Path(PathMixin): 

253 """Represents a path in a URL.""" 

254 

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

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

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

258 

259 

260@export 

261class URL: 

262 """ 

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

264 

265 .. code-block:: 

266 

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

268 """ 

269 

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

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

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

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

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

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

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

277 

278 def __init__( 

279 self, 

280 scheme: Nullable[Protocols], 

281 path: Path, 

282 host: Nullable[Host] = None, 

283 user: Nullable[str] = None, 

284 password: Nullable[str] = None, 

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

286 fragment: Nullable[str] = None 

287 ) -> None: 

288 """ 

289 Initializes a Uniform Resource Locator (URL). 

290 

291 :param scheme: Optional, transport scheme to be used for a specified resource. 

292 :param path: Path to the resource. 

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

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

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

296 :param query: Optional, query string. 

297 :param fragment: Optional, fragment. 

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

299 """ 

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

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

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

303 raise ex 

304 

305 self._scheme = scheme 

306 

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

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

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

310 raise ex 

311 

312 self._user = user 

313 

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

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

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

317 raise ex 

318 

319 self._password = password 

320 

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

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

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

324 raise ex 

325 self._host = host 

326 

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

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

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

330 raise ex 

331 

332 self._path = path 

333 

334 if query is not None: 

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

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

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

338 raise ex 

339 

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

341 else: 

342 self._query = None 

343 

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

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

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

347 raise ex 

348 

349 self._fragment = fragment 

350 

351 @readonly 

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

353 """ 

354 Read-only property to access the URL scheme. 

355 

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

357 """ 

358 return self._scheme 

359 

360 @readonly 

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

362 """ 

363 Read-only property to access the optional username. 

364 

365 :returns: Optional username within the URL. 

366 """ 

367 return self._user 

368 

369 @readonly 

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

371 """ 

372 Read-only property to access the optional password. 

373 

374 :returns: Optional password within a URL. 

375 """ 

376 return self._password 

377 

378 @readonly 

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

380 """ 

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

382 

383 :returns: The host part of the URL. 

384 """ 

385 return self._host 

386 

387 @readonly 

388 def Path(self) -> Path: 

389 """ 

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

391 

392 :returns: Path part of the URL. 

393 """ 

394 return self._path 

395 

396 @readonly 

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

398 """ 

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

400 

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

402 """ 

403 return self._query 

404 

405 @readonly 

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

407 """ 

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

409 

410 :returns: The fragment part of the URL. 

411 """ 

412 return self._fragment 

413 

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

415 @classmethod 

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

417 """ 

418 Parse a URL string and returns the URL object. 

419 

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

421 :returns: A URL object. 

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

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

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

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

426 to percent-encode it. 

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

428 The note lists the known schemes. 

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

430 """ 

431 if url is None: 

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

433 elif not isinstance(url, str): 

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

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

436 raise ex 

437 

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

439 scheme = matches.group("scheme") 

440 user = matches.group("user") 

441 password = matches.group("password") 

442 host = matches.group("host") 

443 

444 port = matches.group("port") 

445 if port is not None: 

446 port = int(port) 

447 path = matches.group("path") 

448 query = matches.group("query") 

449 fragment = matches.group("fragment") 

450 

451 if scheme is not None: 

452 try: 

453 scheme = Protocols[scheme.upper()] 

454 except KeyError as ex: 

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

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

457 raise error from ex 

458 

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

460 pathObj = Path.Parse(path, hostObj) 

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

462 

463 return cls( 

464 scheme, 

465 pathObj, 

466 hostObj, 

467 user, 

468 password, 

469 parameters, 

470 fragment 

471 ) 

472 

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

474 for character in url: 

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

476 error.add_note( 

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

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

479 ) 

480 break 

481 

482 raise error 

483 

484 @classmethod 

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

486 """ 

487 Parse a URL's query into its parameters. 

488 

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

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

491 :returns: The parameters by name. 

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

493 """ 

494 parameters = {} 

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

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

497 if separator == "": 

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

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

500 raise error 

501 

502 parameters[key] = value 

503 

504 return parameters 

505 

506 @classmethod 

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

508 """ 

509 Check a string names a resource below a URL. 

510 

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

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

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

514 

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

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

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

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

519 """ 

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

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

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

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

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

525 raise error 

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

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

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

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

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

531 raise error 

532 

533 for character in reference: 

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

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

536 error.add_note( 

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

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

539 ) 

540 raise error 

541 

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

543 """ 

544 Return this URL with a resource below it. 

545 

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

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

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

549 

550 .. code-block:: python 

551 

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

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

554 

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

556 :returns: A new URL naming that resource. 

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

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

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

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

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

562 """ 

563 if isinstance(other, str): 

564 self._CheckRelativeReference(other) 

565 

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

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

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

569 fragment = fragment if fragment != "" else None 

570 elif isinstance(other, Path): 

571 resource = other 

572 parameters = None 

573 fragment = None 

574 else: 

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

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

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

578 raise ex 

579 

580 return self.__class__( 

581 scheme=self._scheme, 

582 path=self._path / resource, 

583 host=self._host, 

584 user=self._user, 

585 password=self._password, 

586 query=parameters, 

587 fragment=fragment 

588 ) 

589 

590 def __str__(self) -> str: 

591 """ 

592 Formats the URL object as a string representation. 

593 

594 :returns: Formatted URL object. 

595 """ 

596 result = str(self._path) 

597 

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

599 result = str(self._host) + result 

600 

601 if self._user is not None: 

602 if self._password is not None: 

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

604 else: 

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

606 

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

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

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

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

611 

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

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

614 

615 if self._fragment is not None: 

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

617 

618 return result 

619 

620 def WithoutCredentials(self) -> URL: 

621 """ 

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

623 

624 :returns: New URL object without credentials. 

625 """ 

626 return self.__class__( 

627 scheme=self._scheme, 

628 path=self._path, 

629 host=self._host, 

630 query=self._query, 

631 fragment=self._fragment 

632 ) 

633 

634 def WithoutTrailingSlash(self) -> URL: 

635 """ 

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

637 

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

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

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

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

642 

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

644 

645 .. seealso:: 

646 

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

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

649 """ 

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

651 return self 

652 

653 return self.__class__( 

654 scheme=self._scheme, 

655 path=path, 

656 host=self._host, 

657 user=self._user, 

658 password=self._password, 

659 query=self._query, 

660 fragment=self._fragment 

661 )