Coverage for pyTooling/REST/__init__.py: 98%

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

32A small client for JSON REST APIs, built on the standard library. 

33 

34Every REST API is read the same way: a request carrying a bearer token, an answer that has to be a JSON object, a 

35transient failure that is worth another attempt, and a collection that arrives one page at a time. None of that is 

36knowledge about a particular service, so it lives here rather than in each reader. 

37 

38.. hint:: 

39 

40 See :ref:`high-level help <REST>` for explanations and usage examples. 

41 

42.. seealso:: 

43 

44 :mod:`pyTooling.CI.GitHub` 

45 |rarr| A data model read from a REST API through this client. 

46""" 

47from http import HTTPMethod 

48from json import dumps as json_dumps, loads as json_loads 

49from re import compile as re_compile 

50from time import sleep 

51from typing import Any, Optional as Nullable, Union 

52from urllib.error import HTTPError, URLError 

53from urllib.request import Request, urlopen 

54 

55from pyTooling.Common import getFullyQualifiedName, StringEnum 

56from pyTooling.Decorators import export, readonly 

57from pyTooling.Exceptions import ToolingException 

58from pyTooling.GenericPath.URL import URL 

59from pyTooling.MetaClasses import ExtendedType 

60 

61 

62__all__ = ["JSONObject"] 

63 

64JSONObject = dict[str, Any] 

65"""A JSON object, as :func:`json.loads` returns it.""" 

66 

67TRANSIENT_HTTP_STATUS = (429, 500, 502, 503, 504) 

68"""HTTP status codes of a transient failure, after which a request is tried again.""" 

69 

70MAXIMUM_RETRY_AFTER = 60.0 

71"""The longest pause in seconds a ``Retry-After`` header can demand before a request is tried again.""" 

72 

73_NEXT_LINK = re_compile(r'<([^>]+)>;\s*rel="next"') 

74"""Pattern extracting the URL of the next page from a :rfc:`8288` ``Link`` header.""" 

75 

76 

77@export 

78class MediaType(StringEnum): 

79 """ 

80 Media types a REST API sends and receives, as :rfc:`9110` calls them. 

81 

82 A member is its own media type, so it is written where a header's value is expected. 

83 """ 

84 

85 JSON = "application/json" #: A JSON document. 

86 PlainText = "text/plain" #: Plain text. 

87 HTML = "text/html" #: An HTML document. 

88 Binary = "application/octet-stream" #: Bytes of an unnamed type. 

89 

90 def Matches(self, contentType: str) -> bool: 

91 """ 

92 Check whether a ``Content-Type`` header names this media type. 

93 

94 The header's parameters are ignored, the name is compared case-insensitively - :rfc:`9110` writes a media type 

95 that way - and the structured syntax suffix of :rfc:`6839` counts, so ``application/vnd.github+json`` is 

96 ``application/json``. 

97 

98 :param contentType: The value of a ``Content-Type`` header. 

99 :returns: ``True``, if the header names this media type. 

100 :raises ValueError: If parameter 'contentType' is ``None``. 

101 :raises TypeError: If parameter 'contentType' is not of type :class:`str`. 

102 """ 

103 if contentType is None: 

104 raise ValueError("Parameter 'contentType' is None.") 

105 elif not isinstance(contentType, str): 

106 ex = TypeError("Parameter 'contentType' is not of type 'str'.") 

107 ex.add_note(f"Got type '{getFullyQualifiedName(contentType)}'.") 

108 raise ex 

109 

110 mediaType = contentType.split(";", 1)[0].strip().lower() 

111 

112 return mediaType == self or mediaType.endswith(f"+{self.partition('/')[2]}") 

113 

114 

115@export 

116class RESTError(ToolingException): 

117 """A request to a REST API failed, or its answer wasn't the JSON the caller asked for.""" 

118 

119 

120@export 

121class RESTClient(metaclass=ExtendedType, slots=True): 

122 """ 

123 Reads and writes JSON resources of a REST API. 

124 

125 The requests use only the standard library, so a package building on this client doesn't drag an HTTP stack into 

126 every consumer. A resource is addressed by its path below :attr:`APIURL`, not by a URL, so a client says 

127 ``repos/owner/name`` and the API it belongs to is stated once. 

128 

129 A token, when given, is sent as a bearer token - and only to the API it was given for, which is why a paginated 

130 answer pointing outside that API is rejected. An API authorizing differently overrides :meth:`_RequestHeaders`, 

131 which is where the header is built. 

132 

133 A request failing transiently - a status in :data:`TRANSIENT_HTTP_STATUS`, a timeout, or an unreachable API - is 

134 tried again after an exponentially growing pause, or one as long as a ``Retry-After`` header demands. A request 

135 failing with any other HTTP status, like 401, 403 or 404, isn't tried again, because another attempt can't 

136 succeed. **A request that isn't idempotent is never tried again**: repeating a ``POST`` that the API did carry 

137 out, but whose answer was lost, creates the resource twice. 

138 

139 An instance holds no state beyond what it was constructed with, and every request builds its own headers, so one 

140 client can serve several threads. The pause before a retry blocks only the thread waiting for that answer. 

141 """ 

142 _apiURL: URL #: Base URL of the REST API, without a trailing slash. 

143 _token: Nullable[str] #: Token authorizing the requests, or ``None`` for anonymous requests. 

144 _headers: dict[str, str] #: Headers sent with every request, beside the authorization. 

145 _timeout: float #: Timeout of a single request in seconds. 

146 _retries: int #: How often a transiently failing idempotent request is tried again. 

147 _retryDelay: float #: Pause in seconds before a request is tried again the first time. 

148 

149 def __init__( 

150 self, 

151 apiURL: Union[str, URL], 

152 token: Nullable[str] = None, 

153 *, 

154 headers: Nullable[dict[str, str]] = None, 

155 timeout: float = 30.0, 

156 retries: int = 3, 

157 retryDelay: float = 2.0 

158 ) -> None: 

159 """ 

160 Initializes a client for one REST API. 

161 

162 :param apiURL: Base URL of the REST API, as a string to parse or as a :class:`~pyTooling.GenericPath.URL.URL`. 

163 :param token: Optional, token authorizing the requests. Default: anonymous requests. 

164 :param headers: Optional, headers sent with every request. Default: no headers beside the authorization. 

165 :param timeout: Optional, timeout of a single request in seconds. Default: ``30.0``. 

166 :param retries: Optional, how often a transiently failing idempotent request is tried again. ``0`` tries 

167 once. Default: ``3``. 

168 :param retryDelay: Optional, pause in seconds before a request is tried again the first time. The pause doubles 

169 with every further attempt. Default: ``2.0``. 

170 :raises ValueError: If parameter 'apiURL' is ``None``. 

171 :raises TypeError: If parameter 'apiURL' is neither of type :class:`str` nor of type 

172 :class:`~pyTooling.GenericPath.URL.URL`. 

173 :raises ValueError: If parameter 'apiURL' names no scheme or no host. 

174 :raises ValueError: If parameter 'apiURL' ends in an empty path element, e.g. ``'https://example.org/api//'``. 

175 :raises TypeError: If parameter 'token' is not of type :class:`str`. 

176 :raises TypeError: If parameter 'headers' is not of type :class:`dict`. 

177 :raises ValueError: If parameter 'timeout' is ``None``. 

178 :raises TypeError: If parameter 'timeout' is not a number. 

179 :raises ValueError: If parameter 'timeout' isn't positive. 

180 :raises ValueError: If parameter 'retries' is ``None``. 

181 :raises TypeError: If parameter 'retries' is not of type :class:`int`. 

182 :raises ValueError: If parameter 'retries' is negative. 

183 :raises ValueError: If parameter 'retryDelay' is ``None``. 

184 :raises TypeError: If parameter 'retryDelay' is not a number. 

185 :raises ValueError: If parameter 'retryDelay' is negative. 

186 """ 

187 if apiURL is None: 

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

189 elif isinstance(apiURL, str): 

190 parsedURL = URL.Parse(apiURL).WithoutTrailingSlash() 

191 elif isinstance(apiURL, URL): 

192 parsedURL = apiURL.WithoutTrailingSlash() 

193 else: 

194 ex = TypeError("Parameter 'apiURL' is neither of type 'str' nor of type 'URL'.") 

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

196 raise ex 

197 

198 if parsedURL.Scheme is None or parsedURL.Host is None: 

199 ex = ValueError("Parameter 'apiURL' names no scheme or no host.") 

200 ex.add_note(f"Got value '{apiURL}'.") 

201 raise ex 

202 

203 # One trailing slash is what a base URL is usually written with, and it was just removed. A path still ending 

204 # in one named an empty element, which every request would carry as a double slash. 

205 if str(parsedURL.Path).endswith("/"): 

206 ex = ValueError("Parameter 'apiURL' ends in an empty path element.") 

207 ex.add_note(f"Got value '{apiURL}'.") 

208 raise ex 

209 

210 if token is not None and not isinstance(token, str): 

211 ex = TypeError("Parameter 'token' is not of type 'str'.") 

212 ex.add_note(f"Got type '{getFullyQualifiedName(token)}'.") 

213 raise ex 

214 

215 if headers is not None and not isinstance(headers, dict): 

216 ex = TypeError("Parameter 'headers' is not of type 'dict'.") 

217 ex.add_note(f"Got type '{getFullyQualifiedName(headers)}'.") 

218 raise ex 

219 

220 if timeout is None: 

221 raise ValueError("Parameter 'timeout' is None.") 

222 elif isinstance(timeout, bool) or not isinstance(timeout, (int, float)): 

223 ex = TypeError("Parameter 'timeout' is not a number.") 

224 ex.add_note(f"Got type '{getFullyQualifiedName(timeout)}'.") 

225 raise ex 

226 elif timeout <= 0: 

227 ex = ValueError("Parameter 'timeout' isn't positive.") 

228 ex.add_note(f"Got value '{timeout}'.") 

229 raise ex 

230 

231 if retries is None: 

232 raise ValueError("Parameter 'retries' is None.") 

233 elif isinstance(retries, bool) or not isinstance(retries, int): 

234 ex = TypeError("Parameter 'retries' is not of type 'int'.") 

235 ex.add_note(f"Got type '{getFullyQualifiedName(retries)}'.") 

236 raise ex 

237 elif retries < 0: 

238 ex = ValueError("Parameter 'retries' is negative.") 

239 ex.add_note(f"Got value '{retries}'.") 

240 raise ex 

241 

242 if retryDelay is None: 

243 raise ValueError("Parameter 'retryDelay' is None.") 

244 elif isinstance(retryDelay, bool) or not isinstance(retryDelay, (int, float)): 

245 ex = TypeError("Parameter 'retryDelay' is not a number.") 

246 ex.add_note(f"Got type '{getFullyQualifiedName(retryDelay)}'.") 

247 raise ex 

248 elif retryDelay < 0: 

249 ex = ValueError("Parameter 'retryDelay' is negative.") 

250 ex.add_note(f"Got value '{retryDelay}'.") 

251 raise ex 

252 

253 self._apiURL = parsedURL 

254 self._token = token 

255 self._headers = {} if headers is None else dict(headers) 

256 self._timeout = float(timeout) 

257 self._retries = retries 

258 self._retryDelay = float(retryDelay) 

259 

260 @readonly 

261 def APIURL(self) -> URL: 

262 """ 

263 Read-only property to access the base URL of the REST API (:attr:`_apiURL`). 

264 

265 :returns: The base URL, without a trailing slash. 

266 """ 

267 return self._apiURL 

268 

269 @readonly 

270 def Headers(self) -> dict[str, str]: 

271 """ 

272 Read-only property to return the headers sent with every request (:attr:`_headers`). 

273 

274 :returns: A copy of the headers, so changing it doesn't change what the client sends. 

275 """ 

276 return dict(self._headers) 

277 

278 @readonly 

279 def Timeout(self) -> float: 

280 """ 

281 Read-only property to access the timeout of a single request (:attr:`_timeout`). 

282 

283 :returns: The timeout in seconds. 

284 """ 

285 return self._timeout 

286 

287 @readonly 

288 def Retries(self) -> int: 

289 """ 

290 Read-only property to access how often a transiently failing request is tried again (:attr:`_retries`). 

291 

292 :returns: The number of further attempts. 

293 """ 

294 return self._retries 

295 

296 @readonly 

297 def RetryDelay(self) -> float: 

298 """ 

299 Read-only property to access the pause before a request is tried again the first time (:attr:`_retryDelay`). 

300 

301 :returns: The pause in seconds. 

302 """ 

303 return self._retryDelay 

304 

305 def GetJSONObject( 

306 self, 

307 resourcePath: str, 

308 headers: Nullable[dict[str, str]] = None 

309 ) -> tuple[JSONObject, Nullable[str]]: 

310 """ 

311 Read a resource as a JSON object. 

312 

313 :param resourcePath: Path of the resource below :attr:`APIURL`, e.g. ``'repos/owner/name'``. 

314 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`. 

315 :returns: The JSON object, and the path of the next page, or ``None`` if the answer names none. 

316 :raises ValueError: If parameter 'resourcePath' is ``None``. 

317 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`. 

318 :raises TypeError: If parameter 'headers' is not of type :class:`dict`. 

319 :raises RESTError: If the request fails, or the answer isn't a JSON object. 

320 """ 

321 document, nextResourcePath = self._Request(HTTPMethod.GET, resourcePath, headers=headers) 

322 if document is None: 

323 raise RESTError(f"API answered with an empty body: {self._apiURL / resourcePath.lstrip('/')}") 

324 

325 return document, nextResourcePath 

326 

327 def PostJSONObject( 

328 self, 

329 resourcePath: str, 

330 document: JSONObject, 

331 headers: Nullable[dict[str, str]] = None 

332 ) -> Nullable[JSONObject]: 

333 """ 

334 Create a resource from a JSON object. 

335 

336 A ``POST`` isn't idempotent, so a transient failure isn't tried again - the API may have created the resource 

337 and lost only the answer. 

338 

339 :param resourcePath: Path of the collection below :attr:`APIURL`, e.g. ``'repos/owner/name/issues'``. 

340 :param document: The JSON object to send. 

341 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`. 

342 :returns: The answer's JSON object, or ``None`` if the API answered with no body. 

343 :raises ValueError: If parameter 'resourcePath' is ``None``. 

344 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`. 

345 :raises ValueError: If parameter 'document' is ``None``. 

346 :raises TypeError: If parameter 'document' is not of type :class:`dict`. 

347 :raises TypeError: If parameter 'headers' is not of type :class:`dict`. 

348 :raises RESTError: If the request fails, or the answer is neither empty nor a JSON object. 

349 """ 

350 if document is None: 

351 raise ValueError("Parameter 'document' is None.") 

352 

353 return self._Request(HTTPMethod.POST, resourcePath, document, headers, idempotent=False)[0] 

354 

355 def PutJSONObject( 

356 self, 

357 resourcePath: str, 

358 document: JSONObject, 

359 headers: Nullable[dict[str, str]] = None 

360 ) -> Nullable[JSONObject]: 

361 """ 

362 Replace a resource by a JSON object. 

363 

364 :param resourcePath: Path of the resource below :attr:`APIURL`, e.g. ``'repos/owner/name/issues/1'``. 

365 :param document: The JSON object to send. 

366 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`. 

367 :returns: The answer's JSON object, or ``None`` if the API answered with no body. 

368 :raises ValueError: If parameter 'resourcePath' is ``None``. 

369 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`. 

370 :raises ValueError: If parameter 'document' is ``None``. 

371 :raises TypeError: If parameter 'document' is not of type :class:`dict`. 

372 :raises TypeError: If parameter 'headers' is not of type :class:`dict`. 

373 :raises RESTError: If the request fails, or the answer is neither empty nor a JSON object. 

374 """ 

375 if document is None: 

376 raise ValueError("Parameter 'document' is None.") 

377 

378 return self._Request(HTTPMethod.PUT, resourcePath, document, headers)[0] 

379 

380 def PatchJSONObject( 

381 self, 

382 resourcePath: str, 

383 document: JSONObject, 

384 headers: Nullable[dict[str, str]] = None 

385 ) -> Nullable[JSONObject]: 

386 """ 

387 Alter a resource by a JSON object holding the fields to change. 

388 

389 :rfc:`9110` doesn't call ``PATCH`` idempotent - whether applying the same change twice is the same as applying 

390 it once depends on the change - so a transient failure isn't tried again. 

391 

392 :param resourcePath: Path of the resource below :attr:`APIURL`, e.g. ``'repos/owner/name/issues/1'``. 

393 :param document: The JSON object holding the fields to change. 

394 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`. 

395 :returns: The answer's JSON object, or ``None`` if the API answered with no body. 

396 :raises ValueError: If parameter 'resourcePath' is ``None``. 

397 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`. 

398 :raises ValueError: If parameter 'document' is ``None``. 

399 :raises TypeError: If parameter 'document' is not of type :class:`dict`. 

400 :raises TypeError: If parameter 'headers' is not of type :class:`dict`. 

401 :raises RESTError: If the request fails, or the answer is neither empty nor a JSON object. 

402 """ 

403 if document is None: 

404 raise ValueError("Parameter 'document' is None.") 

405 

406 return self._Request(HTTPMethod.PATCH, resourcePath, document, headers, idempotent=False)[0] 

407 

408 def DeleteResource(self, resourcePath: str, headers: Nullable[dict[str, str]] = None) -> Nullable[JSONObject]: 

409 """ 

410 Delete a resource. 

411 

412 :param resourcePath: Path of the resource below :attr:`APIURL`, e.g. ``'repos/owner/name/issues/1'``. 

413 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`. 

414 :returns: The answer's JSON object, or ``None`` if the API answered with no body, which is what a 

415 deletion usually answers with. 

416 :raises ValueError: If parameter 'resourcePath' is ``None``. 

417 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`. 

418 :raises TypeError: If parameter 'headers' is not of type :class:`dict`. 

419 :raises RESTError: If the request fails, or the answer is neither empty nor a JSON object. 

420 """ 

421 return self._Request(HTTPMethod.DELETE, resourcePath, headers=headers)[0] 

422 

423 def _Request( 

424 self, 

425 method: HTTPMethod, 

426 resourcePath: str, 

427 document: Nullable[JSONObject] = None, 

428 headers: Nullable[dict[str, str]] = None, 

429 idempotent: bool = True 

430 ) -> tuple[Nullable[JSONObject], Nullable[str]]: 

431 """ 

432 Send one request to the REST API and read its answer. 

433 

434 :param method: The HTTP method. 

435 :param resourcePath: Path of the resource below :attr:`APIURL`. 

436 :param document: Optional, the JSON object to send as the request's body. Default: no body. 

437 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`. 

438 :param idempotent: Optional, ``True``, if repeating the request has the same effect as sending it once, which 

439 is what makes trying it again safe. Default: ``True``. 

440 :returns: The answer's JSON object or ``None`` if it had no body, and the path of the next page or 

441 ``None`` if the answer names none. 

442 :raises ValueError: If parameter 'resourcePath' is ``None``. 

443 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`. 

444 :raises TypeError: If parameter 'document' is not of type :class:`dict`. 

445 :raises TypeError: If parameter 'headers' is not of type :class:`dict`. 

446 :raises RESTError: If the request fails with an HTTP error, or the API can't be reached. |br| 

447 The notes report what the API said and how often the request was tried. 

448 :raises RESTError: If the answer isn't JSON, isn't valid JSON, or isn't a JSON object. 

449 :raises RESTError: If the next page's URL doesn't belong to this API. |br| 

450 The note says that the token is only sent to the API itself. 

451 """ 

452 if resourcePath is None: 

453 raise ValueError("Parameter 'resourcePath' is None.") 

454 elif not isinstance(resourcePath, str): 

455 ex = TypeError("Parameter 'resourcePath' is not of type 'str'.") 

456 ex.add_note(f"Got type '{getFullyQualifiedName(resourcePath)}'.") 

457 raise ex 

458 

459 if document is not None and not isinstance(document, dict): 

460 ex = TypeError("Parameter 'document' is not of type 'dict'.") 

461 ex.add_note(f"Got type '{getFullyQualifiedName(document)}'.") 

462 raise ex 

463 

464 if headers is not None and not isinstance(headers, dict): 

465 ex = TypeError("Parameter 'headers' is not of type 'dict'.") 

466 ex.add_note(f"Got type '{getFullyQualifiedName(headers)}'.") 

467 raise ex 

468 

469 if document is None: 

470 data = None 

471 mediaType = None 

472 else: 

473 data = json_dumps(document).encode("utf-8") 

474 mediaType = MediaType.JSON 

475 

476 # A resource path is always **below** the API, so a leading delimiter is removed - :meth:`URL.__truediv__ 

477 # <pyTooling.GenericPath.URL.URL.__truediv__>` would otherwise read it as naming its own root. 

478 url = self._apiURL / resourcePath.lstrip("/") 

479 request = Request(str(url), data=data, method=method, headers=self._RequestHeaders(mediaType, headers)) 

480 retries = self._retries if idempotent else 0 

481 

482 for attempt in range(1, retries + 2): 482 ↛ 528line 482 didn't jump to line 528 because the loop on line 482 didn't complete

483 try: 

484 with urlopen(request, timeout=self._timeout) as response: 

485 body = response.read() 

486 contentType = response.headers.get("Content-Type", None) 

487 link = response.headers.get("Link", None) 

488 break 

489 except HTTPError as ex: 

490 if ex.code in TRANSIENT_HTTP_STATUS and attempt <= retries: 

491 delay = self._RetryDelay(attempt) 

492 # :rfc:`9110` writes 'Retry-After' as a non-negative number of seconds or as an HTTP-date. Only the 

493 # first is honored, and only while it asks for longer than the backoff already waits. 

494 if ex.headers is not None and (pause := ex.headers.get("Retry-After", "")).strip().isdigit(): 

495 delay = max(delay, min(float(pause), MAXIMUM_RETRY_AFTER)) 

496 

497 sleep(delay) 

498 continue 

499 

500 error = RESTError(f"Request failed with HTTP {ex.code}: {url}") 

501 try: 

502 answer = json_loads(ex.read()) 

503 except (OSError, ValueError): 

504 answer = None 

505 

506 # A REST API reports the reason in a 'message' field. An answer that has none, or isn't JSON at all, 

507 # is not a second failure - it just says nothing. 

508 if isinstance(answer, dict) and isinstance(message := answer.get("message", None), str): 508 ↛ 511line 508 didn't jump to line 511 because the condition on line 508 was always true

509 error.add_note(f"Answer: {message}") 

510 

511 self._AddErrorNotes(error, ex.code) 

512 if attempt > 1: 

513 error.add_note(f"Tried {attempt} times.") 

514 

515 raise error from ex 

516 except OSError as ex: 

517 if attempt <= retries: 

518 sleep(self._RetryDelay(attempt)) 

519 continue 

520 

521 error = RESTError(f"API couldn't be reached: {url}") 

522 error.add_note(f"Reason: {ex.reason if isinstance(ex, URLError) else ex}") 

523 if attempt > 1: 523 ↛ 526line 523 didn't jump to line 526 because the condition on line 523 was always true

524 error.add_note(f"Tried {attempt} times.") 

525 

526 raise error from ex 

527 

528 return self._ProcessAnswer(body, contentType, url), self._NextResourcePath(link) 

529 

530 def _RequestHeaders(self, mediaType: Nullable[MediaType], headers: Nullable[dict[str, str]]) -> dict[str, str]: 

531 """ 

532 Return the headers one request is sent with. 

533 

534 The client's headers are the base, the authorization is added, and this request's own headers win, so a caller 

535 can state an ``Accept`` or a conditional header for a single request. 

536 

537 :param mediaType: The media type of the request's body, or ``None`` for a request without one. 

538 :param headers: This request's headers, or ``None``. 

539 :returns: The headers of this request. 

540 """ 

541 requestHeaders = dict(self._headers) 

542 # The bearer scheme of :rfc:`6750` is what a token-based REST API expects, and what an OAuth 2.0 flow's access 

543 # token is used with once the flow handed one out. An API authorizing differently overrides this method. 

544 if self._token is not None: 

545 requestHeaders["Authorization"] = f"Bearer {self._token}" 

546 

547 if mediaType is not None: 

548 requestHeaders["Content-Type"] = mediaType 

549 

550 if headers is not None: 

551 requestHeaders.update(headers) 

552 

553 return requestHeaders 

554 

555 def _AddErrorNotes(self, error: RESTError, status: int) -> None: 

556 """ 

557 Add notes explaining what an HTTP status means for this API. 

558 

559 A derived class overrides this to say what a caller can do about a status its API answers with. The default 

560 adds nothing, because a status alone says the same for every API. 

561 

562 :param error: The error the notes are added to. 

563 :param status: The HTTP status the request failed with. 

564 """ 

565 

566 def _RetryDelay(self, attempt: int) -> float: 

567 """ 

568 Return the pause before a request is tried again. 

569 

570 The pause grows exponentially: :attr:`RetryDelay` doubled for every earlier attempt. 

571 

572 :param attempt: The attempt that failed, starting at 1. 

573 :returns: The pause in seconds. 

574 """ 

575 return self._retryDelay * 2 ** (attempt - 1) 

576 

577 def _ProcessAnswer(self, body: bytes, contentType: Nullable[str], url: URL) -> Nullable[JSONObject]: 

578 """ 

579 Read an answer's body as a JSON object. 

580 

581 :param body: The answer's body. 

582 :param contentType: The answer's ``Content-Type`` header, or ``None``. 

583 :param url: The URL that was requested. 

584 :returns: The JSON object, or ``None`` if the answer had no body. 

585 :raises RESTError: If the answer's media type isn't JSON. |br| 

586 The note reports the media type that was announced. 

587 :raises RESTError: If the answer isn't valid JSON. 

588 :raises RESTError: If the answer is JSON, but not a JSON object. 

589 """ 

590 if len(body) == 0: 

591 return None 

592 

593 if contentType is not None and not MediaType.JSON.Matches(contentType): 

594 error = RESTError(f"API didn't answer with JSON: {url}") 

595 error.add_note(f"Got 'Content-Type: {contentType}'.") 

596 raise error 

597 

598 try: 

599 document = json_loads(body) 

600 except ValueError as ex: 

601 raise RESTError(f"API answered with invalid JSON: {url}") from ex 

602 

603 if not isinstance(document, dict): 

604 raise RESTError(f"API didn't answer with a JSON object: {url}") 

605 

606 return document 

607 

608 def _NextResourcePath(self, link: Nullable[str]) -> Nullable[str]: 

609 """ 

610 Return where a paginated collection continues. 

611 

612 :param link: The answer's :rfc:`8288` ``Link`` header, or ``None``. 

613 :returns: Path of the next page below :attr:`APIURL`, or ``None`` if the answer names none. 

614 :raises RESTError: If the next page's URL doesn't belong to this API. |br| 

615 The note says that the token is only sent to the API itself. 

616 """ 

617 if link is None: 

618 return None 

619 elif (match := _NEXT_LINK.search(link)) is None: 

620 return None 

621 

622 nextURL = match.group(1) 

623 prefix = f"{self._apiURL}/" 

624 if not nextURL.startswith(prefix): 

625 error = RESTError(f"The next page is outside the API: {nextURL}") 

626 error.add_note("The request's token is only sent to the API itself.") 

627 raise error 

628 

629 return nextURL[len(prefix):]