Coverage for pyTooling/GitHub/Release.py: 87%

522 statements  

« prev     ^ index     » next       coverage.py v7.17.3, created at 2026-11-14 02:08 +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 data model of a GitHub repository's releases and tags. 

33 

34.. tree:: 

35 

36 - :class:`Repository` | the releases and tags of a repository 

37 - :class:`Release` | a release, published at a tag 

38 - :class:`Asset` | a file attached to the release 

39 - :class:`Tag` | a tag and the commit it points to 

40 

41:class:`~pyTooling.GitHub.ReleaseReader.ReleaseReader` reads the model through GitHub's GraphQL API. 

42:meth:`Repository.Parse` reads the API's ``Repository`` object, :meth:`Repository.ToJSON` writes the model in the 

43same shape, and :meth:`Repository.ToSnapshot` and :meth:`Repository.ParseSnapshot` add a format version and the time 

44the data was read, so a consumer can store the model and read it back without another request. 

45 

46.. hint:: 

47 

48 See :ref:`high-level help <DATA/Release>` for explanations and usage examples. 

49""" 

50from __future__ import annotations 

51 

52from datetime import datetime 

53from typing import Iterable, Iterator, Optional as Nullable, Self 

54from urllib.parse import quote 

55 

56from pyTooling.Common import getFullyQualifiedName, StringEnum 

57from pyTooling.Decorators import export, readonly 

58from pyTooling.URI import URL 

59from pyTooling.MetaClasses import ExtendedType 

60from pyTooling.REST import JSONObject 

61 

62from pyTooling.GitHub import GitHubError, _parseISO8601Timestamp, _parseURL 

63 

64 

65# A class with a property named like a class - ``URL`` - can't name that class in an annotation: the class body's 

66# namespace, where annotations are evaluated, binds the name to the property. 

67_URL = URL 

68 

69 

70@export 

71class SourceArchive(StringEnum): 

72 """The formats of the source code archive GitHub offers for a tag.""" 

73 

74 Zip = "zip" #: A ZIP file. 

75 Tarball = "tar.gz" #: A gzip-compressed TAR file. 

76 

77 

78@export 

79class SnapshotVersion(StringEnum): 

80 """Versions of the snapshot format written by :meth:`Repository.ToSnapshot`.""" 

81 

82 Version1 = "1" #: GitHub's GraphQL ``Repository`` object: pages merged, no drafts, labels added. 

83 Latest = Version1 #: The version :meth:`Repository.ToSnapshot` writes. 

84 

85 

86def _readNodes(json: JSONObject, field: str, path: str) -> list[JSONObject]: 

87 """ 

88 Read the nodes of a connection in a response of GitHub's GraphQL API. 

89 

90 :param json: The object holding the connection. 

91 :param field: Name of the connection's field. 

92 :param path: Position of the object, for the exception's message. 

93 :returns: The nodes. 

94 :raises GitHubError: If the field is missing. 

95 :raises GitHubError: If the connection has no list of nodes. 

96 :raises GitHubError: If the connection has a further page. |br| 

97 The note says to merge all pages into one list of nodes before parsing it. 

98 """ 

99 if (connection := json.get(field, None)) is None: 99 ↛ 100line 99 didn't jump to line 100 because the condition on line 99 was never true

100 raise GitHubError(f"Field '{path}.{field}' is missing.") 

101 elif not isinstance(connection, dict) or not isinstance(nodes := connection.get("nodes", None), list): 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true

102 raise GitHubError(f"Field '{path}.{field}' has no list of nodes.") 

103 elif isinstance(pageInfo := connection.get("pageInfo", None), dict) and pageInfo.get("hasNextPage", False): 

104 ex = GitHubError(f"Field '{path}.{field}' holds only the first page of nodes.") 

105 ex.add_note("Merge all pages into one list of nodes, before parsing it.") 

106 raise ex 

107 

108 return nodes 

109 

110 

111@export 

112class Asset(metaclass=ExtendedType, slots=True): 

113 """A file attached to a release.""" 

114 

115 _parent: Nullable[Release] #: Release the file is attached to. 

116 _name: str #: File name of the asset. 

117 _label: Nullable[str] #: Text shown instead of the file name. 

118 _digest: Nullable[str] #: Checksum of the asset as ``algorithm:value``. 

119 _contentType: str #: Media type of the asset. 

120 _size: int #: Size of the asset in bytes. 

121 _downloadURL: URL #: URL to download the asset from. 

122 

123 def __init__( 

124 self, 

125 name: str, 

126 downloadURL: URL, 

127 contentType: str, 

128 size: int, 

129 label: Nullable[str] = None, 

130 digest: Nullable[str] = None, 

131 *, 

132 parent: Nullable[Release] = None 

133 ) -> None: 

134 """ 

135 Initializes a file attached to a release. 

136 

137 :param name: File name of the asset. 

138 :param downloadURL: URL to download the asset from. 

139 :param contentType: Media type of the asset. 

140 :param size: Size of the asset in bytes. 

141 :param label: Optional, text shown instead of the file name. Default: ``None``. 

142 :param digest: Optional, checksum of the asset as ``algorithm:value``. Default: ``None``. 

143 :param parent: Optional, reference to the release the asset is attached to. Default: ``None``. 

144 :raises ValueError: If parameter 'name' is ``None``. 

145 :raises TypeError: If parameter 'name' is not of type :class:`str`. 

146 :raises ValueError: If parameter 'name' is empty. 

147 :raises ValueError: If parameter 'downloadURL' is ``None``. 

148 :raises TypeError: If parameter 'downloadURL' is not of type :class:`~pyTooling.URI.URL`. 

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

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

151 :raises ValueError: If parameter 'contentType' is empty. 

152 :raises ValueError: If parameter 'size' is ``None``. 

153 :raises TypeError: If parameter 'size' is not of type :class:`int`. 

154 :raises ValueError: If parameter 'size' is negative. 

155 :raises TypeError: If parameter 'label' is not of type :class:`str`. 

156 :raises ValueError: If parameter 'label' is empty. 

157 :raises TypeError: If parameter 'digest' is not of type :class:`str`. 

158 :raises ValueError: If parameter 'digest' isn't of the form ``algorithm:value``. 

159 :raises TypeError: If parameter 'parent' is not of type :class:`Release`. 

160 :raises ValueError: If parameter 'parent' has an asset of the same name. 

161 """ 

162 for parameterName, text in (("name", name), ("contentType", contentType)): 

163 if text is None: 

164 raise ValueError(f"Parameter '{parameterName}' is None.") 

165 elif not isinstance(text, str): 165 ↛ 166line 165 didn't jump to line 166 because the condition on line 165 was never true

166 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.") 

167 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.") 

168 raise ex 

169 elif text == "": 

170 raise ValueError(f"Parameter '{parameterName}' is empty.") 

171 

172 if downloadURL is None: 

173 raise ValueError("Parameter 'downloadURL' is None.") 

174 elif not isinstance(downloadURL, URL): 174 ↛ 175line 174 didn't jump to line 175 because the condition on line 174 was never true

175 ex = TypeError("Parameter 'downloadURL' is not of type 'URL'.") 

176 ex.add_note(f"Got type '{getFullyQualifiedName(downloadURL)}'.") 

177 raise ex 

178 

179 if size is None: 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true

180 raise ValueError("Parameter 'size' is None.") 

181 elif isinstance(size, bool) or not isinstance(size, int): 

182 ex = TypeError("Parameter 'size' is not of type 'int'.") 

183 ex.add_note(f"Got type '{getFullyQualifiedName(size)}'.") 

184 raise ex 

185 elif size < 0: 

186 ex = ValueError("Parameter 'size' is negative.") 

187 ex.add_note(f"Got value '{size}'.") 

188 raise ex 

189 

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

191 ex = TypeError("Parameter 'label' is not of type 'str'.") 

192 ex.add_note(f"Got type '{getFullyQualifiedName(label)}'.") 

193 raise ex 

194 elif label == "": 

195 raise ValueError("Parameter 'label' is empty.") 

196 

197 if digest is not None and not isinstance(digest, str): 

198 ex = TypeError("Parameter 'digest' is not of type 'str'.") 

199 ex.add_note(f"Got type '{getFullyQualifiedName(digest)}'.") 

200 raise ex 

201 elif digest is not None and "" in digest.partition(":"): 

202 ex = ValueError("Parameter 'digest' isn't of the form 'algorithm:value'.") 

203 ex.add_note(f"Got value '{digest}'.") 

204 raise ex 

205 

206 self._parent = None 

207 self._name = name 

208 self._label = label 

209 self._digest = digest 

210 self._contentType = contentType 

211 self._size = size 

212 self._downloadURL = downloadURL 

213 

214 if parent is None: 

215 pass 

216 elif not isinstance(parent, Release): 216 ↛ 217line 216 didn't jump to line 217 because the condition on line 216 was never true

217 ex = TypeError("Parameter 'parent' is not of type 'Release'.") 

218 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.") 

219 raise ex 

220 elif name in parent._assets: 

221 raise ValueError(f"Release '{parent._tagName}' has an asset '{name}' already.") 

222 else: 

223 self._parent = parent 

224 parent._assets[name] = self 

225 

226 @readonly 

227 def Parent(self) -> Nullable[Release]: 

228 """ 

229 Read-only property to access the release the asset is attached to (:attr:`_parent`). 

230 

231 :returns: The release, or ``None`` if the asset isn't attached to one. 

232 """ 

233 return self._parent 

234 

235 @readonly 

236 def Name(self) -> str: 

237 """ 

238 Read-only property to access the asset's file name (:attr:`_name`). 

239 

240 :returns: The file name, unique within its release. 

241 """ 

242 return self._name 

243 

244 @readonly 

245 def Label(self) -> Nullable[str]: 

246 """ 

247 Read-only property to access the text shown instead of the file name (:attr:`_label`). 

248 

249 GitHub shows the label on the release page, when the asset has one, e.g. ``GHDL - v5.0.1 - Windows (x86-64, 

250 MinGW64, standalone) - mcode backend`` for ``ghdl-mcode-5.0.1-mingw64.zip``. 

251 

252 :returns: The label, or ``None`` if the asset has none. 

253 """ 

254 return self._label 

255 

256 @readonly 

257 def Digest(self) -> Nullable[str]: 

258 """ 

259 Read-only property to access the asset's checksum (:attr:`_digest`). 

260 

261 GitHub computes it on upload as ``algorithm:value``, e.g. ``sha256:8c7dd2e9...`` - for an asset uploaded before 

262 GitHub computed checksums, there is none. 

263 

264 :returns: The checksum as ``algorithm:value``, or ``None`` if GitHub has none. 

265 """ 

266 return self._digest 

267 

268 @readonly 

269 def ContentType(self) -> str: 

270 """ 

271 Read-only property to access the asset's media type (:attr:`_contentType`). 

272 

273 The uploader declares it, e.g. ``application/zip`` for a ZIP file. 

274 

275 :returns: The media type. 

276 """ 

277 return self._contentType 

278 

279 @readonly 

280 def Size(self) -> int: 

281 """ 

282 Read-only property to access the asset's size (:attr:`_size`). 

283 

284 :returns: The size in bytes. 

285 """ 

286 return self._size 

287 

288 @readonly 

289 def DownloadURL(self) -> URL: 

290 """ 

291 Read-only property to access the URL to download the asset from (:attr:`_downloadURL`). 

292 

293 :returns: The download URL on github.com. 

294 """ 

295 return self._downloadURL 

296 

297 def __str__(self) -> str: 

298 """ 

299 Return a string representation of the asset. 

300 

301 :returns: The asset's file name. 

302 """ 

303 return self._name 

304 

305 @classmethod 

306 def Parse(cls, asset: JSONObject, path: str = "asset") -> Self: 

307 """ 

308 Read an asset from a ``ReleaseAsset`` object of GitHub's GraphQL API. 

309 

310 GitHub's GraphQL API reports no label, so the field ``label`` is read when present, as 

311 :class:`~pyTooling.GitHub.ReleaseReader.ReleaseReader` adds it from the REST API. An empty label is no label. 

312 

313 :param asset: The ``ReleaseAsset`` object. 

314 :param path: Optional, position of the asset, for an exception's message. Default: ``'asset'``. 

315 :returns: The asset. 

316 :raises TypeError: If parameter 'asset' is not of type :class:`dict`. 

317 :raises GitHubError: If field ``name``, ``contentType``, ``size`` or ``downloadUrl`` is missing. 

318 """ 

319 if not isinstance(asset, dict): 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true

320 ex = TypeError("Parameter 'asset' is not of type 'dict'.") 

321 ex.add_note(f"Got type '{getFullyQualifiedName(asset)}'.") 

322 raise ex 

323 

324 for field in ("name", "contentType", "size", "downloadUrl"): 

325 if asset.get(field, None) is None: 325 ↛ 326line 325 didn't jump to line 326 because the condition on line 325 was never true

326 raise GitHubError(f"Field '{path}.{field}' is missing.") 

327 

328 label = asset.get("label", None) 

329 

330 return cls( 

331 asset["name"], 

332 URL.Parse(asset["downloadUrl"]), 

333 asset["contentType"], 

334 asset["size"], 

335 None if label == "" else label, 

336 asset.get("digest", None) 

337 ) 

338 

339 def ToJSON(self) -> JSONObject: 

340 """ 

341 Write the asset as a ``ReleaseAsset`` object of GitHub's GraphQL API, plus its label. 

342 

343 :returns: The JSON object, which :meth:`Parse` reads back. 

344 """ 

345 return { 

346 "name": self._name, 

347 "label": self._label, 

348 "contentType": self._contentType, 

349 "size": self._size, 

350 "downloadUrl": str(self._downloadURL), 

351 "digest": self._digest 

352 } 

353 

354 

355@export 

356class CommitReference(metaclass=ExtendedType, slots=True): 

357 """ 

358 Base-class of a tag and a release: an element of a repository named by a tag, which points to a commit. 

359 

360 An annotated tag is resolved to the commit it points to. 

361 """ 

362 

363 _parent: Nullable[Repository] #: Repository the element belongs to. 

364 _tagName: str #: Name of the tag. 

365 _commitSHA: Nullable[str] #: Commit the tag points to. 

366 _commitURL: Nullable[URL] #: URL of that commit. 

367 

368 def __init__( 

369 self, 

370 tagName: str, 

371 commitSHA: Nullable[str] = None, 

372 commitURL: Nullable[URL] = None 

373 ) -> None: 

374 """ 

375 Initializes the tag's name and the commit it points to. 

376 

377 :param tagName: Name of the tag. 

378 :param commitSHA: Optional, commit the tag points to. Default: ``None``. 

379 :param commitURL: Optional, URL of that commit. Default: ``None``. 

380 :raises ValueError: If parameter 'tagName' is ``None``. 

381 :raises TypeError: If parameter 'tagName' is not of type :class:`str`. 

382 :raises ValueError: If parameter 'tagName' is empty. 

383 :raises TypeError: If parameter 'commitSHA' is not of type :class:`str`. 

384 :raises ValueError: If parameter 'commitSHA' is empty. 

385 :raises TypeError: If parameter 'commitURL' is not of type :class:`~pyTooling.URI.URL`. 

386 """ 

387 if tagName is None: 

388 raise ValueError("Parameter 'tagName' is None.") 

389 

390 for parameterName, text in (("tagName", tagName), ("commitSHA", commitSHA)): 

391 if text is not None and not isinstance(text, str): 

392 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.") 

393 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.") 

394 raise ex 

395 elif text == "": 

396 raise ValueError(f"Parameter '{parameterName}' is empty.") 

397 

398 if commitURL is not None and not isinstance(commitURL, URL): 

399 ex = TypeError("Parameter 'commitURL' is not of type 'URL'.") 

400 ex.add_note(f"Got type '{getFullyQualifiedName(commitURL)}'.") 

401 raise ex 

402 

403 self._parent = None 

404 self._tagName = tagName 

405 self._commitSHA = commitSHA 

406 self._commitURL = commitURL 

407 

408 @readonly 

409 def Parent(self) -> Nullable[Repository]: 

410 """ 

411 Read-only property to access the repository the element belongs to (:attr:`_parent`). 

412 

413 :returns: The repository, or ``None`` if the element belongs to none. 

414 """ 

415 return self._parent 

416 

417 @readonly 

418 def TagName(self) -> str: 

419 """ 

420 Read-only property to access the tag's name (:attr:`_tagName`). 

421 

422 :returns: The tag's name, unique among the repository's tags and among its releases. 

423 """ 

424 return self._tagName 

425 

426 @readonly 

427 def CommitSHA(self) -> Nullable[str]: 

428 """ 

429 Read-only property to access the commit the tag points to (:attr:`_commitSHA`). 

430 

431 :returns: The commit's hash, or ``None`` if the tag doesn't exist or points to no commit, e.g. to a tree. 

432 """ 

433 return self._commitSHA 

434 

435 @readonly 

436 def CommitURL(self) -> Nullable[URL]: 

437 """ 

438 Read-only property to access the URL of the commit the tag points to (:attr:`_commitURL`). 

439 

440 :returns: The commit's URL on github.com, or ``None`` if the tag doesn't exist or points to no commit. 

441 """ 

442 return self._commitURL 

443 

444 def __str__(self) -> str: 

445 """ 

446 Return a string representation of the element. 

447 

448 :returns: The tag's name. 

449 """ 

450 return self._tagName 

451 

452 

453@export 

454class Release(CommitReference): 

455 """ 

456 A release, published at a tag. 

457 

458 A draft is no release: it is unpublished and may change, so the model has none. 

459 

460 The release's commit is the one its **tag** points to - not GitHub's ``target_commitish``, which is usually a 

461 branch's name. The time is when the release was **published**, not when it was created, which is the commit's time. 

462 

463 The release is a collection of its assets: ``name in release`` asks whether a file of that name is attached, 

464 ``release[name]`` returns it, and iterating yields the assets in the order GitHub reported them. 

465 """ 

466 

467 _url: _URL #: URL of the release page. 

468 _name: Nullable[str] #: Title of the release. 

469 _description: str #: Release notes as Markdown. 

470 _publishedAt: Nullable[datetime] #: Time the release was published. 

471 _isPrerelease: bool #: True, if the release is a pre-release. 

472 _isLatest: bool #: True, if the release is the repository's latest release. 

473 _assets: dict[str, Asset] #: Files attached to the release, by file name. 

474 

475 def __init__( 

476 self, 

477 tagName: str, 

478 url: _URL, 

479 name: Nullable[str] = None, 

480 description: str = "", 

481 publishedAt: Nullable[datetime] = None, 

482 isPrerelease: bool = False, 

483 isLatest: bool = False, 

484 commitSHA: Nullable[str] = None, 

485 commitURL: Nullable[_URL] = None, 

486 assets: Nullable[Iterable[Asset]] = None, 

487 *, 

488 parent: Nullable[Repository] = None 

489 ) -> None: 

490 """ 

491 Initializes a release. 

492 

493 :param tagName: Name of the tag the release is published at. 

494 :param url: URL of the release page. 

495 :param name: Optional, title of the release. Default: ``None``. 

496 :param description: Optional, release notes as Markdown. Default: ``""``. 

497 :param publishedAt: Optional, time the release was published, with a time zone. Default: ``None``. 

498 :param isPrerelease: Optional, ``True``, if the release is a pre-release. Default: ``False``. 

499 :param isLatest: Optional, ``True``, if the release is the repository's latest release. Default: ``False``. 

500 :param commitSHA: Optional, commit the tag points to. Default: ``None``. 

501 :param commitURL: Optional, URL of that commit. Default: ``None``. 

502 :param assets: Optional, the release's assets, which are attached to it. Default: ``None``. 

503 :param parent: Optional, reference to the repository the release belongs to. Default: ``None``. 

504 :raises ValueError: If parameter 'tagName' is ``None``. 

505 :raises TypeError: If parameter 'tagName' is not of type :class:`str`. 

506 :raises ValueError: If parameter 'tagName' is empty. 

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

508 :raises TypeError: If parameter 'url' is not of type :class:`~pyTooling.URI.URL`. 

509 :raises TypeError: If parameter 'name' is not of type :class:`str`. 

510 :raises ValueError: If parameter 'name' is empty. 

511 :raises ValueError: If parameter 'description' is ``None``. 

512 :raises TypeError: If parameter 'description' is not of type :class:`str`. 

513 :raises TypeError: If parameter 'publishedAt' is not of type :class:`~datetime.datetime`. 

514 :raises ValueError: If parameter 'publishedAt' has no time zone. 

515 :raises ValueError: If parameter 'isPrerelease' is ``None``. 

516 :raises TypeError: If parameter 'isPrerelease' is not of type :class:`bool`. 

517 :raises ValueError: If parameter 'isLatest' is ``None``. 

518 :raises TypeError: If parameter 'isLatest' is not of type :class:`bool`. 

519 :raises TypeError: If parameter 'commitSHA' is not of type :class:`str`. 

520 :raises ValueError: If parameter 'commitSHA' is empty. 

521 :raises TypeError: If parameter 'commitURL' is not of type :class:`~pyTooling.URI.URL`. 

522 :raises TypeError: If an element of parameter 'assets' is not of type :class:`Asset`. 

523 :raises ValueError: If two elements of parameter 'assets' have the same name. 

524 :raises ValueError: If an element of parameter 'assets' is attached to a release already. 

525 :raises TypeError: If parameter 'parent' is not of type :class:`Repository`. 

526 :raises ValueError: If parameter 'parent' has a release of the same tag. 

527 """ 

528 super().__init__(tagName, commitSHA, commitURL) 

529 

530 if url is None: 

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

532 elif not isinstance(url, URL): 

533 ex = TypeError("Parameter 'url' is not of type 'URL'.") 

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

535 raise ex 

536 

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

538 ex = TypeError("Parameter 'name' is not of type 'str'.") 

539 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.") 

540 raise ex 

541 elif name == "": 

542 raise ValueError("Parameter 'name' is empty.") 

543 

544 if description is None: 

545 raise ValueError("Parameter 'description' is None.") 

546 elif not isinstance(description, str): 546 ↛ 547line 546 didn't jump to line 547 because the condition on line 546 was never true

547 ex = TypeError("Parameter 'description' is not of type 'str'.") 

548 ex.add_note(f"Got type '{getFullyQualifiedName(description)}'.") 

549 raise ex 

550 

551 if publishedAt is not None and not isinstance(publishedAt, datetime): 

552 ex = TypeError("Parameter 'publishedAt' is not of type 'datetime'.") 

553 ex.add_note(f"Got type '{getFullyQualifiedName(publishedAt)}'.") 

554 raise ex 

555 elif publishedAt is not None and publishedAt.utcoffset() is None: 

556 ex = ValueError("Parameter 'publishedAt' has no time zone.") 

557 ex.add_note(f"Got value '{publishedAt}'.") 

558 raise ex 

559 

560 for parameterName, flag in (("isPrerelease", isPrerelease), ("isLatest", isLatest)): 

561 if flag is None: 

562 raise ValueError(f"Parameter '{parameterName}' is None.") 

563 elif not isinstance(flag, bool): 

564 ex = TypeError(f"Parameter '{parameterName}' is not of type 'bool'.") 

565 ex.add_note(f"Got type '{getFullyQualifiedName(flag)}'.") 

566 raise ex 

567 

568 self._url = url 

569 self._name = name 

570 self._description = description 

571 self._publishedAt = publishedAt 

572 self._isPrerelease = isPrerelease 

573 self._isLatest = isLatest 

574 self._assets = {} 

575 

576 if assets is not None: 

577 for asset in assets: 

578 if not isinstance(asset, Asset): 

579 ex = TypeError("Element of parameter 'assets' is not of type 'Asset'.") 

580 ex.add_note(f"Got type '{getFullyQualifiedName(asset)}'.") 

581 raise ex 

582 elif asset._parent is not None: 

583 raise ValueError(f"Asset '{asset._name}' is attached to release '{asset._parent._tagName}' already.") 

584 elif asset._name in self._assets: 

585 raise ValueError(f"Release '{tagName}' has an asset '{asset._name}' already.") 

586 

587 asset._parent = self 

588 self._assets[asset._name] = asset 

589 

590 if parent is None: 

591 pass 

592 elif not isinstance(parent, Repository): 

593 ex = TypeError("Parameter 'parent' is not of type 'Repository'.") 

594 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.") 

595 raise ex 

596 elif tagName in parent._releases: 

597 raise ValueError(f"Repository '{parent}' has a release of tag '{tagName}' already.") 

598 else: 

599 self._parent = parent 

600 parent._releases[tagName] = self 

601 

602 @readonly 

603 def URL(self) -> _URL: 

604 """ 

605 Read-only property to access the URL of the release page (:attr:`_url`). 

606 

607 :returns: The release page's URL on github.com. 

608 """ 

609 return self._url 

610 

611 @readonly 

612 def Name(self) -> Nullable[str]: 

613 """ 

614 Read-only property to access the release's title (:attr:`_name`). 

615 

616 GitHub's release page shows the tag's name instead, when a release has no title. 

617 

618 :returns: The title, or ``None`` if the release has none. 

619 """ 

620 return self._name 

621 

622 @readonly 

623 def Description(self) -> str: 

624 """ 

625 Read-only property to access the release notes (:attr:`_description`). 

626 

627 :returns: The release notes as GitHub Flavored Markdown, empty if the release has none. 

628 """ 

629 return self._description 

630 

631 @readonly 

632 def PublishedAt(self) -> Nullable[datetime]: 

633 """ 

634 Read-only property to access the time the release was published (:attr:`_publishedAt`). 

635 

636 :returns: The time with its time zone - UTC, as GitHub reports it -, or ``None`` if unknown. 

637 """ 

638 return self._publishedAt 

639 

640 @readonly 

641 def IsPrerelease(self) -> bool: 

642 """ 

643 Read-only property to access whether the release is a pre-release (:attr:`_isPrerelease`). 

644 

645 :returns: ``True``, if the release is a pre-release. 

646 """ 

647 return self._isPrerelease 

648 

649 @readonly 

650 def IsLatest(self) -> bool: 

651 """ 

652 Read-only property to access whether the release is the repository's latest release (:attr:`_isLatest`). 

653 

654 GitHub decides which release is the latest, or the maintainer marks it by hand - it isn't necessarily the 

655 newest one, and never a draft or a pre-release. 

656 

657 :returns: ``True``, if the release is the latest release. 

658 """ 

659 return self._isLatest 

660 

661 @readonly 

662 def Assets(self) -> dict[str, Asset]: 

663 """ 

664 Read-only property to access the files attached to the release (:attr:`_assets`). 

665 

666 :returns: Dictionary of a file name to its asset, in the order GitHub reported them. 

667 """ 

668 return self._assets 

669 

670 def SourceArchiveURL(self, archive: SourceArchive) -> _URL: 

671 """ 

672 Return the URL of a source code archive of the release's tag, as GitHub's release page links it. 

673 

674 GitHub creates the archive on request, so it is no asset of the release and has no size. 

675 

676 :param archive: The archive's format. 

677 :returns: The archive's URL on github.com. 

678 :raises ValueError: If parameter 'archive' is ``None``. 

679 :raises TypeError: If parameter 'archive' is not of type :class:`SourceArchive`. 

680 :raises GitHubError: If the release belongs to no repository. 

681 """ 

682 if archive is None: 

683 raise ValueError("Parameter 'archive' is None.") 

684 elif not isinstance(archive, SourceArchive): 

685 ex = TypeError("Parameter 'archive' is not of type 'SourceArchive'.") 

686 ex.add_note(f"Got type '{getFullyQualifiedName(archive)}'.") 

687 raise ex 

688 

689 if self._parent is None: 

690 raise GitHubError(f"Release '{self._tagName}' belongs to no repository.") 

691 

692 return self._parent._url / f"archive/refs/tags/{quote(self._tagName)}.{archive}" 

693 

694 def __len__(self) -> int: 

695 """ 

696 Return the number of assets. 

697 

698 :returns: The number of files attached to the release. 

699 """ 

700 return len(self._assets) 

701 

702 def __iter__(self) -> Iterator[Asset]: 

703 """ 

704 Iterate the assets, in the order GitHub reported them. 

705 

706 :returns: An iterator of the assets. 

707 """ 

708 return iter(self._assets.values()) 

709 

710 def __contains__(self, name: str) -> bool: 

711 """ 

712 Check whether a file of a name is attached to the release. 

713 

714 :param name: The file name. 

715 :returns: ``True``, if the release has an asset of that name. 

716 """ 

717 return name in self._assets 

718 

719 def __getitem__(self, name: str) -> Asset: 

720 """ 

721 Return the asset of a file name. 

722 

723 :param name: The file name. 

724 :returns: The asset. 

725 :raises KeyError: If the release has no asset of that name. 

726 """ 

727 return self._assets[name] 

728 

729 @classmethod 

730 def Parse(cls, release: JSONObject, path: str = "release") -> Self: 

731 """ 

732 Read a release and its assets from a ``Release`` object of GitHub's GraphQL API. 

733 

734 An empty title is no title, and no release notes are empty release notes. A draft is rejected, as it is no 

735 release - :meth:`Repository.Parse` skips drafts. 

736 

737 :param release: The ``Release`` object. 

738 :param path: Optional, position of the release, for an exception's message. Default: ``'release'``. 

739 :returns: The release, with its assets attached. 

740 :raises TypeError: If parameter 'release' is not of type :class:`dict`. 

741 :raises GitHubError: If field ``tagName``, ``url``, ``isDraft``, ``isPrerelease``, ``isLatest`` or 

742 ``releaseAssets`` is missing. 

743 :raises GitHubError: If the release is a draft. 

744 :raises GitHubError: If field ``tagCommit`` has no ``oid``. 

745 :raises GitHubError: If field ``releaseAssets`` holds no complete list of nodes. 

746 :raises GitHubError: If field ``publishedAt`` isn't an ISO 8601 timestamp. 

747 """ 

748 if not isinstance(release, dict): 748 ↛ 749line 748 didn't jump to line 749 because the condition on line 748 was never true

749 ex = TypeError("Parameter 'release' is not of type 'dict'.") 

750 ex.add_note(f"Got type '{getFullyQualifiedName(release)}'.") 

751 raise ex 

752 

753 for field in ("tagName", "url", "isDraft", "isPrerelease", "isLatest"): 

754 if release.get(field, None) is None: 

755 raise GitHubError(f"Field '{path}.{field}' is missing.") 

756 

757 if release["isDraft"] is True: 

758 raise GitHubError(f"Release '{release['tagName']}' is a draft.") 

759 

760 if (tagCommit := release.get("tagCommit", None)) is None: 760 ↛ 761line 760 didn't jump to line 761 because the condition on line 760 was never true

761 commitSHA = None 

762 commitURL = None 

763 elif (commitSHA := tagCommit.get("oid", None)) is None: 763 ↛ 764line 763 didn't jump to line 764 because the condition on line 763 was never true

764 raise GitHubError(f"Field '{path}.tagCommit.oid' is missing.") 

765 else: 

766 commitURL = _parseURL(tagCommit.get("url", None), f"{path}.tagCommit.url") 

767 

768 name = release.get("name", None) 

769 description = release.get("description", None) 

770 assets = [ 

771 Asset.Parse(asset, f"{path}.releaseAssets.nodes[{position}]") 

772 for position, asset in enumerate(_readNodes(release, "releaseAssets", path)) 

773 ] 

774 

775 return cls( 

776 release["tagName"], 

777 URL.Parse(release["url"]), 

778 None if name == "" else name, 

779 "" if description is None else description, 

780 _parseISO8601Timestamp(release.get("publishedAt", None), f"{path}.publishedAt"), 

781 release["isPrerelease"], 

782 release["isLatest"], 

783 commitSHA, 

784 commitURL, 

785 assets 

786 ) 

787 

788 def ToJSON(self) -> JSONObject: 

789 """ 

790 Write the release as a ``Release`` object of GitHub's GraphQL API. 

791 

792 :returns: The JSON object, which :meth:`Parse` reads back. 

793 """ 

794 if self._commitSHA is None: 794 ↛ 795line 794 didn't jump to line 795 because the condition on line 794 was never true

795 tagCommit = None 

796 else: 

797 tagCommit = {"oid": self._commitSHA, "url": None if self._commitURL is None else str(self._commitURL)} 

798 

799 return { 

800 "tagName": self._tagName, 

801 "name": self._name, 

802 "description": self._description, 

803 "publishedAt": None if self._publishedAt is None else self._publishedAt.isoformat(), 

804 "url": str(self._url), 

805 "isDraft": False, 

806 "isPrerelease": self._isPrerelease, 

807 "isLatest": self._isLatest, 

808 "tagCommit": tagCommit, 

809 "releaseAssets": {"nodes": [asset.ToJSON() for asset in self._assets.values()]} 

810 } 

811 

812 

813@export 

814class Tag(CommitReference): 

815 """ 

816 A tag of a repository and the commit it points to. 

817 

818 A tag exists without a release - a repository may tag every version and publish a release for a few of them. 

819 """ 

820 

821 _committedAt: Nullable[datetime] #: Time of the commit the tag points to. 

822 _isAnnotated: bool #: True, if the tag is a tag object of its own. 

823 

824 def __init__( 

825 self, 

826 tagName: str, 

827 commitSHA: Nullable[str] = None, 

828 commitURL: Nullable[URL] = None, 

829 committedAt: Nullable[datetime] = None, 

830 isAnnotated: bool = False, 

831 *, 

832 parent: Nullable[Repository] = None 

833 ) -> None: 

834 """ 

835 Initializes a tag. 

836 

837 :param tagName: Name of the tag. 

838 :param commitSHA: Optional, commit the tag points to. Default: ``None``. 

839 :param commitURL: Optional, URL of that commit. Default: ``None``. 

840 :param committedAt: Optional, time of that commit, with a time zone. Default: ``None``. 

841 :param isAnnotated: Optional, ``True``, if the tag is a tag object of its own. Default: ``False``. 

842 :param parent: Optional, reference to the repository the tag belongs to. Default: ``None``. 

843 :raises ValueError: If parameter 'tagName' is ``None``. 

844 :raises TypeError: If parameter 'tagName' is not of type :class:`str`. 

845 :raises ValueError: If parameter 'tagName' is empty. 

846 :raises TypeError: If parameter 'commitSHA' is not of type :class:`str`. 

847 :raises ValueError: If parameter 'commitSHA' is empty. 

848 :raises TypeError: If parameter 'commitURL' is not of type :class:`~pyTooling.URI.URL`. 

849 :raises TypeError: If parameter 'committedAt' is not of type :class:`~datetime.datetime`. 

850 :raises ValueError: If parameter 'committedAt' has no time zone. 

851 :raises ValueError: If parameter 'isAnnotated' is ``None``. 

852 :raises TypeError: If parameter 'isAnnotated' is not of type :class:`bool`. 

853 :raises TypeError: If parameter 'parent' is not of type :class:`Repository`. 

854 :raises ValueError: If parameter 'parent' has a tag of the same name. 

855 """ 

856 super().__init__(tagName, commitSHA, commitURL) 

857 

858 if committedAt is not None and not isinstance(committedAt, datetime): 858 ↛ 859line 858 didn't jump to line 859 because the condition on line 858 was never true

859 ex = TypeError("Parameter 'committedAt' is not of type 'datetime'.") 

860 ex.add_note(f"Got type '{getFullyQualifiedName(committedAt)}'.") 

861 raise ex 

862 elif committedAt is not None and committedAt.utcoffset() is None: 

863 ex = ValueError("Parameter 'committedAt' has no time zone.") 

864 ex.add_note(f"Got value '{committedAt}'.") 

865 raise ex 

866 

867 if isAnnotated is None: 

868 raise ValueError("Parameter 'isAnnotated' is None.") 

869 elif not isinstance(isAnnotated, bool): 869 ↛ 870line 869 didn't jump to line 870 because the condition on line 869 was never true

870 ex = TypeError("Parameter 'isAnnotated' is not of type 'bool'.") 

871 ex.add_note(f"Got type '{getFullyQualifiedName(isAnnotated)}'.") 

872 raise ex 

873 

874 self._committedAt = committedAt 

875 self._isAnnotated = isAnnotated 

876 

877 if parent is None: 

878 pass 

879 elif not isinstance(parent, Repository): 879 ↛ 880line 879 didn't jump to line 880 because the condition on line 879 was never true

880 ex = TypeError("Parameter 'parent' is not of type 'Repository'.") 

881 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.") 

882 raise ex 

883 elif tagName in parent._tags: 

884 raise ValueError(f"Repository '{parent}' has a tag '{tagName}' already.") 

885 else: 

886 self._parent = parent 

887 parent._tags[tagName] = self 

888 

889 @readonly 

890 def CommittedAt(self) -> Nullable[datetime]: 

891 """ 

892 Read-only property to access the time of the commit the tag points to (:attr:`_committedAt`). 

893 

894 This is the only time a tag without a release has. 

895 

896 :returns: The time with its time zone - UTC, as GitHub reports it -, or ``None`` if the tag points to no commit. 

897 """ 

898 return self._committedAt 

899 

900 @readonly 

901 def IsAnnotated(self) -> bool: 

902 """ 

903 Read-only property to access whether the tag is an annotated tag (:attr:`_isAnnotated`). 

904 

905 An annotated tag is a tag object of its own, carrying a message; a lightweight tag is a name for a commit. 

906 

907 :returns: ``True``, if the tag is an annotated tag. 

908 """ 

909 return self._isAnnotated 

910 

911 @readonly 

912 def Release(self) -> Nullable[Release]: 

913 """ 

914 Read-only property to return the release published at the tag. 

915 

916 :returns: The release, or ``None`` if the tag belongs to no repository or the repository has no release of it. 

917 """ 

918 if self._parent is None: 918 ↛ 919line 918 didn't jump to line 919 because the condition on line 918 was never true

919 return None 

920 

921 return self._parent._releases.get(self._tagName, None) 

922 

923 @readonly 

924 def HasRelease(self) -> bool: 

925 """ 

926 Read-only property to return whether a release is published at the tag. 

927 

928 :returns: ``True``, if the repository the tag belongs to has a release of it. 

929 """ 

930 return self._parent is not None and self._tagName in self._parent._releases 

931 

932 @classmethod 

933 def Parse(cls, ref: JSONObject, path: str = "ref") -> Self: 

934 """ 

935 Read a tag from a ``Ref`` object of GitHub's GraphQL API. 

936 

937 The ref's target is a ``Commit`` for a lightweight tag, and a ``Tag`` object for an annotated tag, whose own target 

938 is the commit - or another ``Tag`` object, which is followed as far as the object holds the targets. 

939 

940 :param ref: The ``Ref`` object below ``refs/tags/``. 

941 :param path: Optional, position of the tag, for an exception's message. Default: ``'ref'``. 

942 :returns: The tag. 

943 :raises TypeError: If parameter 'ref' is not of type :class:`dict`. 

944 :raises GitHubError: If field ``name`` is missing. 

945 :raises GitHubError: If a target has no field ``__typename``. 

946 :raises GitHubError: If a ``Tag`` object has no field ``target``, i.e. tag objects are nested deeper than read. 

947 :raises GitHubError: If a ``Commit`` object has no field ``oid``. 

948 :raises GitHubError: If field ``committedDate`` isn't an ISO 8601 timestamp. 

949 """ 

950 if not isinstance(ref, dict): 950 ↛ 951line 950 didn't jump to line 951 because the condition on line 950 was never true

951 ex = TypeError("Parameter 'ref' is not of type 'dict'.") 

952 ex.add_note(f"Got type '{getFullyQualifiedName(ref)}'.") 

953 raise ex 

954 

955 if (name := ref.get("name", None)) is None: 955 ↛ 956line 955 didn't jump to line 956 because the condition on line 955 was never true

956 raise GitHubError(f"Field '{path}.name' is missing.") 

957 

958 targetPath = f"{path}.target" 

959 target = ref.get("target", None) 

960 isAnnotated = False 

961 while target is not None: 961 ↛ 975line 961 didn't jump to line 975 because the condition on line 961 was always true

962 if (typeName := target.get("__typename", None)) is None: 

963 raise GitHubError(f"Field '{targetPath}.__typename' is missing.") 

964 elif typeName != "Tag": 

965 break 

966 elif "target" not in target: 

967 ex = GitHubError(f"Field '{targetPath}.target' is missing.") 

968 ex.add_note(f"Tag '{name}' points to tag objects nested deeper than read.") 

969 raise ex 

970 

971 isAnnotated = True 

972 targetPath = f"{targetPath}.target" 

973 target = target["target"] 

974 

975 if target is None or target["__typename"] != "Commit": 

976 return cls(name, isAnnotated=isAnnotated) 

977 elif (commitSHA := target.get("oid", None)) is None: 977 ↛ 978line 977 didn't jump to line 978 because the condition on line 977 was never true

978 raise GitHubError(f"Field '{targetPath}.oid' is missing.") 

979 

980 return cls( 

981 name, 

982 commitSHA, 

983 _parseURL(target.get("url", None), f"{targetPath}.url"), 

984 _parseISO8601Timestamp(target.get("committedDate", None), f"{targetPath}.committedDate"), 

985 isAnnotated 

986 ) 

987 

988 def ToJSON(self) -> JSONObject: 

989 """ 

990 Write the tag as a ``Ref`` object of GitHub's GraphQL API. 

991 

992 A target that is no commit is written as ``None``, and an annotated tag's ``Tag`` object without its own hash. 

993 

994 :returns: The JSON object, which :meth:`Parse` reads back. 

995 """ 

996 target: Nullable[JSONObject] 

997 if self._commitSHA is None: 997 ↛ 998line 997 didn't jump to line 998 because the condition on line 997 was never true

998 target = None 

999 else: 

1000 target = { 

1001 "__typename": "Commit", 

1002 "oid": self._commitSHA, 

1003 "url": None if self._commitURL is None else str(self._commitURL), 

1004 "committedDate": None if self._committedAt is None else self._committedAt.isoformat() 

1005 } 

1006 

1007 if self._isAnnotated: 

1008 target = {"__typename": "Tag", "target": target} 

1009 

1010 return {"name": self._tagName, "target": target} 

1011 

1012 

1013@export 

1014class Repository(metaclass=ExtendedType, slots=True): 

1015 """ 

1016 The releases and tags of a GitHub repository, and the top of the tree. 

1017 

1018 The repository is a collection of its releases: ``tagName in repository`` asks whether a release is published at a 

1019 tag, ``repository[tagName]`` returns it, and iterating yields the releases in the order GitHub reported them, the 

1020 newest first. The tags - with or without a release - are in :attr:`Tags`. 

1021 """ 

1022 

1023 _owner: str #: Owner of the repository, a user or an organization. 

1024 _name: str #: Name of the repository. 

1025 _url: _URL #: URL of the repository on github.com. 

1026 _releases: dict[str, Release] #: Releases, by the name of their tag. 

1027 _tags: dict[str, Tag] #: Tags, by their name. 

1028 _fetchedAt: Nullable[datetime] #: Time the data was read from GitHub. 

1029 

1030 def __init__( 

1031 self, 

1032 owner: str, 

1033 name: str, 

1034 url: _URL, 

1035 releases: Nullable[Iterable[Release]] = None, 

1036 tags: Nullable[Iterable[Tag]] = None, 

1037 *, 

1038 fetchedAt: Nullable[datetime] = None 

1039 ) -> None: 

1040 """ 

1041 Initializes the releases and tags of a repository. 

1042 

1043 :param owner: Owner of the repository, a user or an organization. 

1044 :param name: Name of the repository. 

1045 :param url: URL of the repository on github.com. 

1046 :param releases: Optional, the releases, which are attached to the repository. Default: ``None``. 

1047 :param tags: Optional, the tags, which are attached to the repository. Default: ``None``. 

1048 :param fetchedAt: Optional, time the data was read from GitHub, with a time zone. Default: ``None``. 

1049 :raises ValueError: If parameter 'owner' is ``None``. 

1050 :raises TypeError: If parameter 'owner' is not of type :class:`str`. 

1051 :raises ValueError: If parameter 'owner' is empty or contains a ``/``. 

1052 :raises ValueError: If parameter 'name' is ``None``. 

1053 :raises TypeError: If parameter 'name' is not of type :class:`str`. 

1054 :raises ValueError: If parameter 'name' is empty or contains a ``/``. 

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

1056 :raises TypeError: If parameter 'url' is not of type :class:`~pyTooling.URI.URL`. 

1057 :raises TypeError: If an element of parameter 'releases' is not of type :class:`Release`. 

1058 :raises ValueError: If two elements of parameter 'releases' have the same tag. 

1059 :raises ValueError: If an element of parameter 'releases' belongs to a repository already. 

1060 :raises TypeError: If an element of parameter 'tags' is not of type :class:`Tag`. 

1061 :raises ValueError: If two elements of parameter 'tags' have the same name. 

1062 :raises ValueError: If an element of parameter 'tags' belongs to a repository already. 

1063 :raises TypeError: If parameter 'fetchedAt' is not of type :class:`~datetime.datetime`. 

1064 :raises ValueError: If parameter 'fetchedAt' has no time zone. 

1065 """ 

1066 for parameterName, text in (("owner", owner), ("name", name)): 

1067 if text is None: 

1068 raise ValueError(f"Parameter '{parameterName}' is None.") 

1069 elif not isinstance(text, str): 

1070 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.") 

1071 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.") 

1072 raise ex 

1073 elif text == "" or "/" in text: 

1074 ex = ValueError(f"Parameter '{parameterName}' is empty or contains a '/'.") 

1075 ex.add_note(f"Got value '{text}'.") 

1076 raise ex 

1077 

1078 if url is None: 

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

1080 elif not isinstance(url, URL): 1080 ↛ 1081line 1080 didn't jump to line 1081 because the condition on line 1080 was never true

1081 ex = TypeError("Parameter 'url' is not of type 'URL'.") 

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

1083 raise ex 

1084 

1085 if fetchedAt is not None and not isinstance(fetchedAt, datetime): 1085 ↛ 1086line 1085 didn't jump to line 1086 because the condition on line 1085 was never true

1086 ex = TypeError("Parameter 'fetchedAt' is not of type 'datetime'.") 

1087 ex.add_note(f"Got type '{getFullyQualifiedName(fetchedAt)}'.") 

1088 raise ex 

1089 elif fetchedAt is not None and fetchedAt.utcoffset() is None: 

1090 ex = ValueError("Parameter 'fetchedAt' has no time zone.") 

1091 ex.add_note(f"Got value '{fetchedAt}'.") 

1092 raise ex 

1093 

1094 self._owner = owner 

1095 self._name = name 

1096 self._url = url 

1097 self._releases = {} 

1098 self._tags = {} 

1099 self._fetchedAt = fetchedAt 

1100 

1101 if releases is not None: 

1102 for release in releases: 

1103 if not isinstance(release, Release): 

1104 ex = TypeError("Element of parameter 'releases' is not of type 'Release'.") 

1105 ex.add_note(f"Got type '{getFullyQualifiedName(release)}'.") 

1106 raise ex 

1107 elif release._parent is not None: 

1108 raise ValueError(f"Release '{release._tagName}' belongs to repository '{release._parent}' already.") 

1109 elif release._tagName in self._releases: 

1110 raise ValueError(f"Repository '{self}' has a release of tag '{release._tagName}' already.") 

1111 

1112 release._parent = self 

1113 self._releases[release._tagName] = release 

1114 

1115 if tags is not None: 

1116 for tag in tags: 

1117 if not isinstance(tag, Tag): 

1118 ex = TypeError("Element of parameter 'tags' is not of type 'Tag'.") 

1119 ex.add_note(f"Got type '{getFullyQualifiedName(tag)}'.") 

1120 raise ex 

1121 elif tag._parent is not None: 

1122 raise ValueError(f"Tag '{tag._tagName}' belongs to repository '{tag._parent}' already.") 

1123 elif tag._tagName in self._tags: 1123 ↛ 1124line 1123 didn't jump to line 1124 because the condition on line 1123 was never true

1124 raise ValueError(f"Repository '{self}' has a tag '{tag._tagName}' already.") 

1125 

1126 tag._parent = self 

1127 self._tags[tag._tagName] = tag 

1128 

1129 @readonly 

1130 def Owner(self) -> str: 

1131 """ 

1132 Read-only property to access the repository's owner (:attr:`_owner`). 

1133 

1134 :returns: The name of the user or organization owning the repository. 

1135 """ 

1136 return self._owner 

1137 

1138 @readonly 

1139 def Name(self) -> str: 

1140 """ 

1141 Read-only property to access the repository's name (:attr:`_name`). 

1142 

1143 :returns: The name, without the owner. 

1144 """ 

1145 return self._name 

1146 

1147 @readonly 

1148 def URL(self) -> _URL: 

1149 """ 

1150 Read-only property to access the repository's URL (:attr:`_url`). 

1151 

1152 :returns: The URL on github.com. 

1153 """ 

1154 return self._url 

1155 

1156 @readonly 

1157 def Releases(self) -> dict[str, Release]: 

1158 """ 

1159 Read-only property to access the releases (:attr:`_releases`). 

1160 

1161 :returns: Dictionary of a tag's name to the release published at it, the newest first. 

1162 """ 

1163 return self._releases 

1164 

1165 @readonly 

1166 def Tags(self) -> dict[str, Tag]: 

1167 """ 

1168 Read-only property to access the tags (:attr:`_tags`). 

1169 

1170 :returns: Dictionary of a tag's name to the tag, the tag of the newest commit first. 

1171 """ 

1172 return self._tags 

1173 

1174 @readonly 

1175 def FetchedAt(self) -> Nullable[datetime]: 

1176 """ 

1177 Read-only property to access the time the data was read from GitHub (:attr:`_fetchedAt`). 

1178 

1179 A consumer storing the model tells the age of what it stored by this time. 

1180 

1181 :returns: The time with its time zone, or ``None`` if unknown. 

1182 """ 

1183 return self._fetchedAt 

1184 

1185 @readonly 

1186 def LatestRelease(self) -> Nullable[Release]: 

1187 """ 

1188 Read-only property to return the release GitHub marks as the latest (:attr:`Release.IsLatest`). 

1189 

1190 :returns: The latest release, or ``None`` if the repository has no published release. 

1191 """ 

1192 for release in self._releases.values(): 1192 ↛ 1196line 1192 didn't jump to line 1196 because the loop on line 1192 didn't complete

1193 if release._isLatest: 

1194 return release 

1195 

1196 return None 

1197 

1198 def __len__(self) -> int: 

1199 """ 

1200 Return the number of releases. 

1201 

1202 :returns: The number of releases. 

1203 """ 

1204 return len(self._releases) 

1205 

1206 def __iter__(self) -> Iterator[Release]: 

1207 """ 

1208 Iterate the releases, the newest first. 

1209 

1210 :returns: An iterator of the releases. 

1211 """ 

1212 return iter(self._releases.values()) 

1213 

1214 def __contains__(self, tagName: str) -> bool: 

1215 """ 

1216 Check whether a release is published at a tag. 

1217 

1218 :param tagName: The tag's name. 

1219 :returns: ``True``, if the repository has a release of that tag. 

1220 """ 

1221 return tagName in self._releases 

1222 

1223 def __getitem__(self, tagName: str) -> Release: 

1224 """ 

1225 Return the release published at a tag. 

1226 

1227 :param tagName: The tag's name. 

1228 :returns: The release. 

1229 :raises KeyError: If the repository has no release of that tag. 

1230 """ 

1231 return self._releases[tagName] 

1232 

1233 def __str__(self) -> str: 

1234 """ 

1235 Return a string representation of the repository. 

1236 

1237 :returns: The repository as ``owner/name``. 

1238 """ 

1239 return f"{self._owner}/{self._name}" 

1240 

1241 @classmethod 

1242 def Parse(cls, repository: JSONObject, fetchedAt: Nullable[datetime] = None) -> Self: 

1243 """ 

1244 Read the releases and tags of a repository from a ``Repository`` object of GitHub's GraphQL API. 

1245 

1246 The object holds the fields ``nameWithOwner``, ``url``, ``releases`` and ``refs`` (the tags) as 

1247 :class:`~pyTooling.GitHub.ReleaseReader.ReleaseReader` queries them, each connection with all its nodes. Draft 

1248 releases are skipped: a draft is unpublished and may change, so it is no part of a repository's releases. 

1249 

1250 :param repository: The ``Repository`` object. 

1251 :param fetchedAt: Optional, time the data was read from GitHub, with a time zone. Default: ``None``. 

1252 :returns: The repository, with its releases and tags attached. 

1253 :raises TypeError: If parameter 'repository' is not of type :class:`dict`. 

1254 :raises GitHubError: If field ``nameWithOwner`` or ``url`` is missing. 

1255 :raises GitHubError: If field ``nameWithOwner`` isn't of the form ``owner/name``. 

1256 :raises GitHubError: If field ``releases`` or ``refs`` holds no complete list of nodes. 

1257 :raises GitHubError: If a release or a tag can't be read. 

1258 """ 

1259 if not isinstance(repository, dict): 1259 ↛ 1260line 1259 didn't jump to line 1260 because the condition on line 1259 was never true

1260 ex = TypeError("Parameter 'repository' is not of type 'dict'.") 

1261 ex.add_note(f"Got type '{getFullyQualifiedName(repository)}'.") 

1262 raise ex 

1263 

1264 for field in ("nameWithOwner", "url"): 

1265 if repository.get(field, None) is None: 1265 ↛ 1266line 1265 didn't jump to line 1266 because the condition on line 1265 was never true

1266 raise GitHubError(f"Field 'repository.{field}' is missing.") 

1267 

1268 if len(parts := repository["nameWithOwner"].split("/")) != 2: 1268 ↛ 1269line 1268 didn't jump to line 1269 because the condition on line 1268 was never true

1269 ex = GitHubError("Field 'repository.nameWithOwner' isn't of the form 'owner/name'.") 

1270 ex.add_note(f"Got '{repository['nameWithOwner']}'.") 

1271 raise ex 

1272 

1273 releases = [ 

1274 Release.Parse(release, f"repository.releases.nodes[{position}]") 

1275 for position, release in enumerate(_readNodes(repository, "releases", "repository")) 

1276 if not isinstance(release, dict) or release.get("isDraft", None) is not True 

1277 ] 

1278 tags = [ 

1279 Tag.Parse(ref, f"repository.refs.nodes[{position}]") 

1280 for position, ref in enumerate(_readNodes(repository, "refs", "repository")) 

1281 ] 

1282 

1283 return cls(parts[0], parts[1], URL.Parse(repository["url"]), releases, tags, fetchedAt=fetchedAt) 

1284 

1285 def ToJSON(self) -> JSONObject: 

1286 """ 

1287 Write the releases and tags as a ``Repository`` object of GitHub's GraphQL API. 

1288 

1289 Each connection holds all its nodes, and an asset its label. 

1290 

1291 :returns: The JSON object, which :meth:`Parse` reads back. 

1292 """ 

1293 return { 

1294 "nameWithOwner": str(self), 

1295 "url": str(self._url), 

1296 "releases": {"nodes": [release.ToJSON() for release in self._releases.values()]}, 

1297 "refs": {"nodes": [tag.ToJSON() for tag in self._tags.values()]} 

1298 } 

1299 

1300 @classmethod 

1301 def ParseSnapshot(cls, snapshot: JSONObject) -> Self: 

1302 """ 

1303 Read the releases and tags of a repository from a snapshot written by :meth:`ToSnapshot`. 

1304 

1305 :param snapshot: The snapshot. 

1306 :returns: The repository, with its releases and tags attached. 

1307 :raises TypeError: If parameter 'snapshot' is not of type :class:`dict`. 

1308 :raises GitHubError: If field ``version`` or ``repository`` is missing. 

1309 :raises GitHubError: If field ``version`` names an unknown version. |br| 

1310 The note lists the known versions. 

1311 :raises GitHubError: If field ``fetchedAt`` isn't an ISO 8601 timestamp. 

1312 :raises GitHubError: If field ``repository`` can't be read (see :meth:`Parse`). 

1313 """ 

1314 if not isinstance(snapshot, dict): 

1315 ex = TypeError("Parameter 'snapshot' is not of type 'dict'.") 

1316 ex.add_note(f"Got type '{getFullyQualifiedName(snapshot)}'.") 

1317 raise ex 

1318 

1319 for field in ("version", "repository"): 

1320 if snapshot.get(field, None) is None: 

1321 raise GitHubError(f"Field '{field}' is missing.") 

1322 

1323 try: 

1324 SnapshotVersion.Parse(snapshot["version"]) 

1325 except (TypeError, ValueError) as cause: 

1326 ex = GitHubError(f"Snapshot version '{snapshot['version']}' is unknown.") 

1327 ex.add_note(f"Known: {', '.join(member.value for member in SnapshotVersion)}.") 

1328 raise ex from cause 

1329 

1330 return cls.Parse(snapshot["repository"], _parseISO8601Timestamp(snapshot.get("fetchedAt", None), "fetchedAt")) 

1331 

1332 def ToSnapshot(self) -> JSONObject: 

1333 """ 

1334 Write the releases and tags as a snapshot, which :meth:`ParseSnapshot` reads back. 

1335 

1336 The snapshot holds the format's version (:attr:`SnapshotVersion.Latest`), the time the data was read 

1337 (:attr:`FetchedAt`) and the repository as :meth:`ToJSON` writes it. 

1338 

1339 :returns: The snapshot as a JSON object, to be written with :func:`json.dump`. 

1340 """ 

1341 return { 

1342 "version": SnapshotVersion.Latest.value, 

1343 "fetchedAt": None if self._fetchedAt is None else self._fetchedAt.isoformat(), 

1344 "repository": self.ToJSON() 

1345 }