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

522 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-25 00:39 +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.GenericPath.URL 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' or 'contentType' is ``None``. 

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

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

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

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

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

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

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

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

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

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

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

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

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

158 """ 

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

160 if text is None: 

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

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

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

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

165 raise ex 

166 elif text == "": 

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

168 

169 if downloadURL is None: 

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

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

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

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

174 raise ex 

175 

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

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

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

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

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

181 raise ex 

182 elif size < 0: 

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

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

185 raise ex 

186 

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

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

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

190 raise ex 

191 elif label == "": 

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

193 

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

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

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

197 raise ex 

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

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

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

201 raise ex 

202 

203 self._parent = None 

204 self._name = name 

205 self._label = label 

206 self._digest = digest 

207 self._contentType = contentType 

208 self._size = size 

209 self._downloadURL = downloadURL 

210 

211 if parent is None: 

212 pass 

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

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

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

216 raise ex 

217 elif name in parent._assets: 

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

219 else: 

220 self._parent = parent 

221 parent._assets[name] = self 

222 

223 @readonly 

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

225 """ 

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

227 

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

229 """ 

230 return self._parent 

231 

232 @readonly 

233 def Name(self) -> str: 

234 """ 

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

236 

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

238 """ 

239 return self._name 

240 

241 @readonly 

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

243 """ 

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

245 

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

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

248 

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

250 """ 

251 return self._label 

252 

253 @readonly 

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

255 """ 

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

257 

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

259 GitHub computed checksums, there is none. 

260 

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

262 """ 

263 return self._digest 

264 

265 @readonly 

266 def ContentType(self) -> str: 

267 """ 

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

269 

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

271 

272 :returns: The media type. 

273 """ 

274 return self._contentType 

275 

276 @readonly 

277 def Size(self) -> int: 

278 """ 

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

280 

281 :returns: The size in bytes. 

282 """ 

283 return self._size 

284 

285 @readonly 

286 def DownloadURL(self) -> URL: 

287 """ 

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

289 

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

291 """ 

292 return self._downloadURL 

293 

294 def __str__(self) -> str: 

295 """ 

296 Return a string representation of the asset. 

297 

298 :returns: The asset's file name. 

299 """ 

300 return self._name 

301 

302 @classmethod 

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

304 """ 

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

306 

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

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

309 

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

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

312 :returns: The asset. 

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

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

315 """ 

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

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

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

319 raise ex 

320 

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

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

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

324 

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

326 

327 return cls( 

328 asset["name"], 

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

330 asset["contentType"], 

331 asset["size"], 

332 None if label == "" else label, 

333 asset.get("digest", None) 

334 ) 

335 

336 def ToJSON(self) -> JSONObject: 

337 """ 

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

339 

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

341 """ 

342 return { 

343 "name": self._name, 

344 "label": self._label, 

345 "contentType": self._contentType, 

346 "size": self._size, 

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

348 "digest": self._digest 

349 } 

350 

351 

352@export 

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

354 """ 

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

356 

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

358 """ 

359 

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

361 _tagName: str #: Name of the tag. 

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

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

364 

365 def __init__( 

366 self, 

367 tagName: str, 

368 commitSHA: Nullable[str] = None, 

369 commitURL: Nullable[URL] = None 

370 ) -> None: 

371 """ 

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

373 

374 :param tagName: Name of the tag. 

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

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

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

378 :raises TypeError: If parameter 'tagName' or 'commitSHA' is not of type :class:`str`. 

379 :raises ValueError: If parameter 'tagName' or 'commitSHA' is empty. 

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

381 """ 

382 if tagName is None: 

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

384 

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

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

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

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

389 raise ex 

390 elif text == "": 

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

392 

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

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

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

396 raise ex 

397 

398 self._parent = None 

399 self._tagName = tagName 

400 self._commitSHA = commitSHA 

401 self._commitURL = commitURL 

402 

403 @readonly 

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

405 """ 

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

407 

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

409 """ 

410 return self._parent 

411 

412 @readonly 

413 def TagName(self) -> str: 

414 """ 

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

416 

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

418 """ 

419 return self._tagName 

420 

421 @readonly 

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

423 """ 

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

425 

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

427 """ 

428 return self._commitSHA 

429 

430 @readonly 

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

432 """ 

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

434 

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

436 """ 

437 return self._commitURL 

438 

439 def __str__(self) -> str: 

440 """ 

441 Return a string representation of the element. 

442 

443 :returns: The tag's name. 

444 """ 

445 return self._tagName 

446 

447 

448@export 

449class Release(CommitReference): 

450 """ 

451 A release, published at a tag. 

452 

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

454 

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

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

457 

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

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

460 """ 

461 

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

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

464 _description: str #: Release notes as Markdown. 

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

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

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

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

469 

470 def __init__( 

471 self, 

472 tagName: str, 

473 url: _URL, 

474 name: Nullable[str] = None, 

475 description: str = "", 

476 publishedAt: Nullable[datetime] = None, 

477 isPrerelease: bool = False, 

478 isLatest: bool = False, 

479 commitSHA: Nullable[str] = None, 

480 commitURL: Nullable[_URL] = None, 

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

482 *, 

483 parent: Nullable[Repository] = None 

484 ) -> None: 

485 """ 

486 Initializes a release. 

487 

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

489 :param url: URL of the release page. 

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

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

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

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

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

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

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

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

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

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

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

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

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

503 :raises TypeError: If parameter 'url' or 'commitURL' is not of type :class:`~pyTooling.GenericPath.URL.URL`. 

504 :raises TypeError: If parameter 'name' or 'commitSHA' is not of type :class:`str`. 

505 :raises ValueError: If parameter 'name' or 'commitSHA' is empty. 

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

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

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

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

510 :raises ValueError: If parameter 'isPrerelease' or 'isLatest' is ``None``. 

511 :raises TypeError: If parameter 'isPrerelease' or 'isLatest' is not of type :class:`bool`. 

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

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

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

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

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

517 """ 

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

519 

520 if url is None: 

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

522 elif not isinstance(url, URL): 

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

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

525 raise ex 

526 

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

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

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

530 raise ex 

531 elif name == "": 

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

533 

534 if description is None: 

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

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

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

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

539 raise ex 

540 

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

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

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

544 raise ex 

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

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

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

548 raise ex 

549 

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

551 if flag is None: 

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

553 elif not isinstance(flag, bool): 

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

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

556 raise ex 

557 

558 self._url = url 

559 self._name = name 

560 self._description = description 

561 self._publishedAt = publishedAt 

562 self._isPrerelease = isPrerelease 

563 self._isLatest = isLatest 

564 self._assets = {} 

565 

566 if assets is not None: 

567 for asset in assets: 

568 if not isinstance(asset, Asset): 

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

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

571 raise ex 

572 elif asset._parent is not None: 

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

574 elif asset._name in self._assets: 

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

576 

577 asset._parent = self 

578 self._assets[asset._name] = asset 

579 

580 if parent is None: 

581 pass 

582 elif not isinstance(parent, Repository): 

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

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

585 raise ex 

586 elif tagName in parent._releases: 

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

588 else: 

589 self._parent = parent 

590 parent._releases[tagName] = self 

591 

592 @readonly 

593 def URL(self) -> _URL: 

594 """ 

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

596 

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

598 """ 

599 return self._url 

600 

601 @readonly 

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

603 """ 

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

605 

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

607 

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

609 """ 

610 return self._name 

611 

612 @readonly 

613 def Description(self) -> str: 

614 """ 

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

616 

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

618 """ 

619 return self._description 

620 

621 @readonly 

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

623 """ 

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

625 

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

627 """ 

628 return self._publishedAt 

629 

630 @readonly 

631 def IsPrerelease(self) -> bool: 

632 """ 

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

634 

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

636 """ 

637 return self._isPrerelease 

638 

639 @readonly 

640 def IsLatest(self) -> bool: 

641 """ 

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

643 

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

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

646 

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

648 """ 

649 return self._isLatest 

650 

651 @readonly 

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

653 """ 

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

655 

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

657 """ 

658 return self._assets 

659 

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

661 """ 

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

663 

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

665 

666 :param archive: The archive's format. 

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

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

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

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

671 """ 

672 if archive is None: 

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

674 elif not isinstance(archive, SourceArchive): 

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

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

677 raise ex 

678 

679 if self._parent is None: 

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

681 

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

683 

684 def __len__(self) -> int: 

685 """ 

686 Return the number of assets. 

687 

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

689 """ 

690 return len(self._assets) 

691 

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

693 """ 

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

695 

696 :returns: An iterator of the assets. 

697 """ 

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

699 

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

701 """ 

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

703 

704 :param name: The file name. 

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

706 """ 

707 return name in self._assets 

708 

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

710 """ 

711 Return the asset of a file name. 

712 

713 :param name: The file name. 

714 :returns: The asset. 

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

716 """ 

717 return self._assets[name] 

718 

719 @classmethod 

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

721 """ 

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

723 

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

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

726 

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

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

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

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

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

732 ``releaseAssets`` is missing. 

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

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

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

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

737 """ 

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

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

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

741 raise ex 

742 

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

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

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

746 

747 if release["isDraft"] is True: 

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

749 

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

751 commitSHA = None 

752 commitURL = None 

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

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

755 else: 

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

757 

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

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

760 assets = [ 

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

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

763 ] 

764 

765 return cls( 

766 release["tagName"], 

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

768 None if name == "" else name, 

769 "" if description is None else description, 

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

771 release["isPrerelease"], 

772 release["isLatest"], 

773 commitSHA, 

774 commitURL, 

775 assets 

776 ) 

777 

778 def ToJSON(self) -> JSONObject: 

779 """ 

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

781 

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

783 """ 

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

785 tagCommit = None 

786 else: 

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

788 

789 return { 

790 "tagName": self._tagName, 

791 "name": self._name, 

792 "description": self._description, 

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

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

795 "isDraft": False, 

796 "isPrerelease": self._isPrerelease, 

797 "isLatest": self._isLatest, 

798 "tagCommit": tagCommit, 

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

800 } 

801 

802 

803@export 

804class Tag(CommitReference): 

805 """ 

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

807 

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

809 """ 

810 

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

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

813 

814 def __init__( 

815 self, 

816 tagName: str, 

817 commitSHA: Nullable[str] = None, 

818 commitURL: Nullable[URL] = None, 

819 committedAt: Nullable[datetime] = None, 

820 isAnnotated: bool = False, 

821 *, 

822 parent: Nullable[Repository] = None 

823 ) -> None: 

824 """ 

825 Initializes a tag. 

826 

827 :param tagName: Name of the tag. 

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

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

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

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

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

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

834 :raises TypeError: If parameter 'tagName' or 'commitSHA' is not of type :class:`str`. 

835 :raises ValueError: If parameter 'tagName' or 'commitSHA' is empty. 

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

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

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

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

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

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

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

843 """ 

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

845 

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

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

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

849 raise ex 

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

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

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

853 raise ex 

854 

855 if isAnnotated is None: 

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

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

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

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

860 raise ex 

861 

862 self._committedAt = committedAt 

863 self._isAnnotated = isAnnotated 

864 

865 if parent is None: 

866 pass 

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

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

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

870 raise ex 

871 elif tagName in parent._tags: 

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

873 else: 

874 self._parent = parent 

875 parent._tags[tagName] = self 

876 

877 @readonly 

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

879 """ 

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

881 

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

883 

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

885 """ 

886 return self._committedAt 

887 

888 @readonly 

889 def IsAnnotated(self) -> bool: 

890 """ 

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

892 

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

894 

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

896 """ 

897 return self._isAnnotated 

898 

899 @readonly 

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

901 """ 

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

903 

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

905 """ 

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

907 return None 

908 

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

910 

911 @readonly 

912 def HasRelease(self) -> bool: 

913 """ 

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

915 

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

917 """ 

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

919 

920 @classmethod 

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

922 """ 

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

924 

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

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

927 

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

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

930 :returns: The tag. 

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

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

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

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

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

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

937 """ 

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

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

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

941 raise ex 

942 

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

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

945 

946 targetPath = f"{path}.target" 

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

948 isAnnotated = False 

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

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

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

952 elif typeName != "Tag": 

953 break 

954 elif "target" not in target: 

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

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

957 raise ex 

958 

959 isAnnotated = True 

960 targetPath = f"{targetPath}.target" 

961 target = target["target"] 

962 

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

964 return cls(name, isAnnotated=isAnnotated) 

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

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

967 

968 return cls( 

969 name, 

970 commitSHA, 

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

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

973 isAnnotated 

974 ) 

975 

976 def ToJSON(self) -> JSONObject: 

977 """ 

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

979 

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

981 

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

983 """ 

984 target: Nullable[JSONObject] 

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

986 target = None 

987 else: 

988 target = { 

989 "__typename": "Commit", 

990 "oid": self._commitSHA, 

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

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

993 } 

994 

995 if self._isAnnotated: 

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

997 

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

999 

1000 

1001@export 

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

1003 """ 

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

1005 

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

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

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

1009 """ 

1010 

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

1012 _name: str #: Name of the repository. 

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

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

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

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

1017 

1018 def __init__( 

1019 self, 

1020 owner: str, 

1021 name: str, 

1022 url: _URL, 

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

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

1025 *, 

1026 fetchedAt: Nullable[datetime] = None 

1027 ) -> None: 

1028 """ 

1029 Initializes the releases and tags of a repository. 

1030 

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

1032 :param name: Name of the repository. 

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

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

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

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

1037 :raises ValueError: If parameter 'owner' or 'name' is ``None``. 

1038 :raises TypeError: If parameter 'owner' or 'name' is not of type :class:`str`. 

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

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

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

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

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

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

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

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

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

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

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

1050 """ 

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

1052 if text is None: 

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

1054 elif not isinstance(text, str): 

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

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

1057 raise ex 

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

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

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

1061 raise ex 

1062 

1063 if url is None: 

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

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

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

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

1068 raise ex 

1069 

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

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

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

1073 raise ex 

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

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

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

1077 raise ex 

1078 

1079 self._owner = owner 

1080 self._name = name 

1081 self._url = url 

1082 self._releases = {} 

1083 self._tags = {} 

1084 self._fetchedAt = fetchedAt 

1085 

1086 if releases is not None: 

1087 for release in releases: 

1088 if not isinstance(release, Release): 

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

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

1091 raise ex 

1092 elif release._parent is not None: 

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

1094 elif release._tagName in self._releases: 

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

1096 

1097 release._parent = self 

1098 self._releases[release._tagName] = release 

1099 

1100 if tags is not None: 

1101 for tag in tags: 

1102 if not isinstance(tag, Tag): 

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

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

1105 raise ex 

1106 elif tag._parent is not None: 

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

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

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

1110 

1111 tag._parent = self 

1112 self._tags[tag._tagName] = tag 

1113 

1114 @readonly 

1115 def Owner(self) -> str: 

1116 """ 

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

1118 

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

1120 """ 

1121 return self._owner 

1122 

1123 @readonly 

1124 def Name(self) -> str: 

1125 """ 

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

1127 

1128 :returns: The name, without the owner. 

1129 """ 

1130 return self._name 

1131 

1132 @readonly 

1133 def URL(self) -> _URL: 

1134 """ 

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

1136 

1137 :returns: The URL on github.com. 

1138 """ 

1139 return self._url 

1140 

1141 @readonly 

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

1143 """ 

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

1145 

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

1147 """ 

1148 return self._releases 

1149 

1150 @readonly 

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

1152 """ 

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

1154 

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

1156 """ 

1157 return self._tags 

1158 

1159 @readonly 

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

1161 """ 

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

1163 

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

1165 

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

1167 """ 

1168 return self._fetchedAt 

1169 

1170 @readonly 

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

1172 """ 

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

1174 

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

1176 """ 

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

1178 if release._isLatest: 

1179 return release 

1180 

1181 return None 

1182 

1183 def __len__(self) -> int: 

1184 """ 

1185 Return the number of releases. 

1186 

1187 :returns: The number of releases. 

1188 """ 

1189 return len(self._releases) 

1190 

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

1192 """ 

1193 Iterate the releases, the newest first. 

1194 

1195 :returns: An iterator of the releases. 

1196 """ 

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

1198 

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

1200 """ 

1201 Check whether a release is published at a tag. 

1202 

1203 :param tagName: The tag's name. 

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

1205 """ 

1206 return tagName in self._releases 

1207 

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

1209 """ 

1210 Return the release published at a tag. 

1211 

1212 :param tagName: The tag's name. 

1213 :returns: The release. 

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

1215 """ 

1216 return self._releases[tagName] 

1217 

1218 def __str__(self) -> str: 

1219 """ 

1220 Return a string representation of the repository. 

1221 

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

1223 """ 

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

1225 

1226 @classmethod 

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

1228 """ 

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

1230 

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

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

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

1234 

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

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

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

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

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

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

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

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

1243 """ 

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

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

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

1247 raise ex 

1248 

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

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

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

1252 

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

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

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

1256 raise ex 

1257 

1258 releases = [ 

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

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

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

1262 ] 

1263 tags = [ 

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

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

1266 ] 

1267 

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

1269 

1270 def ToJSON(self) -> JSONObject: 

1271 """ 

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

1273 

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

1275 

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

1277 """ 

1278 return { 

1279 "nameWithOwner": str(self), 

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

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

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

1283 } 

1284 

1285 @classmethod 

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

1287 """ 

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

1289 

1290 :param snapshot: The snapshot. 

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

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

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

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

1295 The note lists the known versions. 

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

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

1298 """ 

1299 if not isinstance(snapshot, dict): 

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

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

1302 raise ex 

1303 

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

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

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

1307 

1308 try: 

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

1310 except (TypeError, ValueError) as cause: 

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

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

1313 raise ex from cause 

1314 

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

1316 

1317 def ToSnapshot(self) -> JSONObject: 

1318 """ 

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

1320 

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

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

1323 

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

1325 """ 

1326 return { 

1327 "version": SnapshotVersion.Latest.value, 

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

1329 "repository": self.ToJSON() 

1330 }