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

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

38 

39from enum import IntFlag 

40from re import compile as re_compile 

41from typing import Dict, Optional as Nullable, Mapping 

42 

43from pyTooling.Decorators import export, readonly 

44from pyTooling.Exceptions import ToolingException 

45from pyTooling.Common import getFullyQualifiedName 

46from pyTooling.GenericPath import RootMixIn, ElementMixIn, PathMixIn 

47 

48 

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

50 

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. 

60 

61 

62@export 

63class Protocols(IntFlag): 

64 """Enumeration of supported URL schemes.""" 

65 

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 

72 

73 

74@export 

75class Host(RootMixIn): 

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

77 

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

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

80 

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. 

88 

89 :param hostname: Name of the host (either IP address or DNS). 

90 :param port: Port number. 

91 """ 

92 super().__init__() 

93 

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 

98 

99 self._hostname = hostname 

100 

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 

111 

112 self._port = port 

113 

114 @readonly 

115 def Hostname(self) -> str: 

116 """ 

117 Read-only property to access the hostname. 

118 

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

120 """ 

121 return self._hostname 

122 

123 @readonly 

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

125 """ 

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

127 

128 :returns: Optional port number. 

129 """ 

130 return self._port 

131 

132 def __str__(self) -> str: 

133 result = self._hostname 

134 if self._port is not None: 

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

136 

137 return result 

138 

139 def Copy(self) -> "Host": 

140 """ 

141 Create a copy of this object. 

142 

143 :return: A new :class:`Host` instance. 

144 """ 

145 return self.__class__( 

146 self._hostname, 

147 self._port 

148 ) 

149 

150 

151@export 

152class Element(ElementMixIn): 

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

154 

155 

156@export 

157class Path(PathMixIn): 

158 """Represents a path in a URL.""" 

159 

160 ELEMENT_DELIMITER = "/" #: Delimiter symbol in URLs between path elements. 

161 ROOT_DELIMITER = "/" #: Delimiter symbol in URLs between root element and first path element. 

162 

163 @classmethod 

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

165 return super().Parse(path, root, cls, Element) 

166 

167 

168@export 

169class URL: 

170 """ 

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

172 

173 .. code-block:: 

174 

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

176 """ 

177 

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] 

185 

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

198 

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 

211 

212 self._scheme = scheme 

213 

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 

218 

219 self._user = user 

220 

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 

225 

226 self._password = password 

227 

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 

233 

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 

239 self._path = path 

240 

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 

246 

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

248 else: 

249 self._query = None 

250 

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 

255 

256 self._fragment = fragment 

257 

258 @readonly 

259 def Scheme(self) -> Protocols: 

260 """ 

261 Read-only property to access the URL scheme. 

262 

263 :returns: URL scheme of the URL. 

264 """ 

265 return self._scheme 

266 

267 @readonly 

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

269 """ 

270 Read-only property to access the optional username. 

271 

272 :returns: Optional username within the URL. 

273 """ 

274 return self._user 

275 

276 @readonly 

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

278 """ 

279 Read-only property to access the optional password. 

280 

281 :returns: Optional password within a URL. 

282 """ 

283 return self._password 

284 

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. 

289 

290 :returns: The host part of the URL. 

291 """ 

292 return self._host 

293 

294 @readonly 

295 def Path(self) -> Path: 

296 """ 

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

298 

299 :returns: Path part of the URL. 

300 """ 

301 return self._path 

302 

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. 

307 

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

309 """ 

310 return self._query 

311 

312 @readonly 

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

314 """ 

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

316 

317 :returns: The fragment part of the URL. 

318 """ 

319 return self._fragment 

320 

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. 

326 

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

336 

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

343 

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

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

346 

347 pathObj = Path.Parse(path, hostObj) 

348 

349 parameters = {} 

350 if query is not None: 

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

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

353 parameters[key] = value 

354 

355 return cls( 

356 scheme, 

357 pathObj, 

358 hostObj, 

359 user, 

360 password, 

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

362 fragment 

363 ) 

364 

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

366 

367 def __str__(self) -> str: 

368 """ 

369 Formats the URL object as a string representation. 

370 

371 :returns: Formatted URL object. 

372 """ 

373 result = str(self._path) 

374 

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 

377 

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

383 

384 if self._scheme is not None: 

385 result = self._scheme.name.lower() + "://" + result 

386 

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

389 

390 if self._fragment is not None: 

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

392 

393 return result 

394 

395 def WithoutCredentials(self) -> "URL": 

396 """ 

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

398 

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 )