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
« 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.
34.. tree::
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
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.
46.. hint::
48 See :ref:`high-level help <DATA/Release>` for explanations and usage examples.
49"""
50from __future__ import annotations
52from datetime import datetime
53from typing import Iterable, Iterator, Optional as Nullable, Self
54from urllib.parse import quote
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
62from pyTooling.GitHub import GitHubError, _parseISO8601Timestamp, _parseURL
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
70@export
71class SourceArchive(StringEnum):
72 """The formats of the source code archive GitHub offers for a tag."""
74 Zip = "zip" #: A ZIP file.
75 Tarball = "tar.gz" #: A gzip-compressed TAR file.
78@export
79class SnapshotVersion(StringEnum):
80 """Versions of the snapshot format written by :meth:`Repository.ToSnapshot`."""
82 Version1 = "1" #: GitHub's GraphQL ``Repository`` object: pages merged, no drafts, labels added.
83 Latest = Version1 #: The version :meth:`Repository.ToSnapshot` writes.
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.
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
108 return nodes
111@export
112class Asset(metaclass=ExtendedType, slots=True):
113 """A file attached to a release."""
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.
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.
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.")
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
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
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.")
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
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
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
226 @readonly
227 def Parent(self) -> Nullable[Release]:
228 """
229 Read-only property to access the release the asset is attached to (:attr:`_parent`).
231 :returns: The release, or ``None`` if the asset isn't attached to one.
232 """
233 return self._parent
235 @readonly
236 def Name(self) -> str:
237 """
238 Read-only property to access the asset's file name (:attr:`_name`).
240 :returns: The file name, unique within its release.
241 """
242 return self._name
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`).
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``.
252 :returns: The label, or ``None`` if the asset has none.
253 """
254 return self._label
256 @readonly
257 def Digest(self) -> Nullable[str]:
258 """
259 Read-only property to access the asset's checksum (:attr:`_digest`).
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.
264 :returns: The checksum as ``algorithm:value``, or ``None`` if GitHub has none.
265 """
266 return self._digest
268 @readonly
269 def ContentType(self) -> str:
270 """
271 Read-only property to access the asset's media type (:attr:`_contentType`).
273 The uploader declares it, e.g. ``application/zip`` for a ZIP file.
275 :returns: The media type.
276 """
277 return self._contentType
279 @readonly
280 def Size(self) -> int:
281 """
282 Read-only property to access the asset's size (:attr:`_size`).
284 :returns: The size in bytes.
285 """
286 return self._size
288 @readonly
289 def DownloadURL(self) -> URL:
290 """
291 Read-only property to access the URL to download the asset from (:attr:`_downloadURL`).
293 :returns: The download URL on github.com.
294 """
295 return self._downloadURL
297 def __str__(self) -> str:
298 """
299 Return a string representation of the asset.
301 :returns: The asset's file name.
302 """
303 return self._name
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.
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.
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
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.")
328 label = asset.get("label", None)
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 )
339 def ToJSON(self) -> JSONObject:
340 """
341 Write the asset as a ``ReleaseAsset`` object of GitHub's GraphQL API, plus its label.
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 }
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.
360 An annotated tag is resolved to the commit it points to.
361 """
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.
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.
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.")
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.")
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
403 self._parent = None
404 self._tagName = tagName
405 self._commitSHA = commitSHA
406 self._commitURL = commitURL
408 @readonly
409 def Parent(self) -> Nullable[Repository]:
410 """
411 Read-only property to access the repository the element belongs to (:attr:`_parent`).
413 :returns: The repository, or ``None`` if the element belongs to none.
414 """
415 return self._parent
417 @readonly
418 def TagName(self) -> str:
419 """
420 Read-only property to access the tag's name (:attr:`_tagName`).
422 :returns: The tag's name, unique among the repository's tags and among its releases.
423 """
424 return self._tagName
426 @readonly
427 def CommitSHA(self) -> Nullable[str]:
428 """
429 Read-only property to access the commit the tag points to (:attr:`_commitSHA`).
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
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`).
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
444 def __str__(self) -> str:
445 """
446 Return a string representation of the element.
448 :returns: The tag's name.
449 """
450 return self._tagName
453@export
454class Release(CommitReference):
455 """
456 A release, published at a tag.
458 A draft is no release: it is unpublished and may change, so the model has none.
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.
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 """
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.
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.
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)
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
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.")
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
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
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
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 = {}
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.")
587 asset._parent = self
588 self._assets[asset._name] = asset
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
602 @readonly
603 def URL(self) -> _URL:
604 """
605 Read-only property to access the URL of the release page (:attr:`_url`).
607 :returns: The release page's URL on github.com.
608 """
609 return self._url
611 @readonly
612 def Name(self) -> Nullable[str]:
613 """
614 Read-only property to access the release's title (:attr:`_name`).
616 GitHub's release page shows the tag's name instead, when a release has no title.
618 :returns: The title, or ``None`` if the release has none.
619 """
620 return self._name
622 @readonly
623 def Description(self) -> str:
624 """
625 Read-only property to access the release notes (:attr:`_description`).
627 :returns: The release notes as GitHub Flavored Markdown, empty if the release has none.
628 """
629 return self._description
631 @readonly
632 def PublishedAt(self) -> Nullable[datetime]:
633 """
634 Read-only property to access the time the release was published (:attr:`_publishedAt`).
636 :returns: The time with its time zone - UTC, as GitHub reports it -, or ``None`` if unknown.
637 """
638 return self._publishedAt
640 @readonly
641 def IsPrerelease(self) -> bool:
642 """
643 Read-only property to access whether the release is a pre-release (:attr:`_isPrerelease`).
645 :returns: ``True``, if the release is a pre-release.
646 """
647 return self._isPrerelease
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`).
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.
657 :returns: ``True``, if the release is the latest release.
658 """
659 return self._isLatest
661 @readonly
662 def Assets(self) -> dict[str, Asset]:
663 """
664 Read-only property to access the files attached to the release (:attr:`_assets`).
666 :returns: Dictionary of a file name to its asset, in the order GitHub reported them.
667 """
668 return self._assets
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.
674 GitHub creates the archive on request, so it is no asset of the release and has no size.
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
689 if self._parent is None:
690 raise GitHubError(f"Release '{self._tagName}' belongs to no repository.")
692 return self._parent._url / f"archive/refs/tags/{quote(self._tagName)}.{archive}"
694 def __len__(self) -> int:
695 """
696 Return the number of assets.
698 :returns: The number of files attached to the release.
699 """
700 return len(self._assets)
702 def __iter__(self) -> Iterator[Asset]:
703 """
704 Iterate the assets, in the order GitHub reported them.
706 :returns: An iterator of the assets.
707 """
708 return iter(self._assets.values())
710 def __contains__(self, name: str) -> bool:
711 """
712 Check whether a file of a name is attached to the release.
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
719 def __getitem__(self, name: str) -> Asset:
720 """
721 Return the asset of a file name.
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]
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.
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.
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
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.")
757 if release["isDraft"] is True:
758 raise GitHubError(f"Release '{release['tagName']}' is a draft.")
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")
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 ]
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 )
788 def ToJSON(self) -> JSONObject:
789 """
790 Write the release as a ``Release`` object of GitHub's GraphQL API.
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)}
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 }
813@export
814class Tag(CommitReference):
815 """
816 A tag of a repository and the commit it points to.
818 A tag exists without a release - a repository may tag every version and publish a release for a few of them.
819 """
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.
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.
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)
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
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
874 self._committedAt = committedAt
875 self._isAnnotated = isAnnotated
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
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`).
894 This is the only time a tag without a release has.
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
900 @readonly
901 def IsAnnotated(self) -> bool:
902 """
903 Read-only property to access whether the tag is an annotated tag (:attr:`_isAnnotated`).
905 An annotated tag is a tag object of its own, carrying a message; a lightweight tag is a name for a commit.
907 :returns: ``True``, if the tag is an annotated tag.
908 """
909 return self._isAnnotated
911 @readonly
912 def Release(self) -> Nullable[Release]:
913 """
914 Read-only property to return the release published at the tag.
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
921 return self._parent._releases.get(self._tagName, None)
923 @readonly
924 def HasRelease(self) -> bool:
925 """
926 Read-only property to return whether a release is published at the tag.
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
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.
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.
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
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.")
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
971 isAnnotated = True
972 targetPath = f"{targetPath}.target"
973 target = target["target"]
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.")
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 )
988 def ToJSON(self) -> JSONObject:
989 """
990 Write the tag as a ``Ref`` object of GitHub's GraphQL API.
992 A target that is no commit is written as ``None``, and an annotated tag's ``Tag`` object without its own hash.
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 }
1007 if self._isAnnotated:
1008 target = {"__typename": "Tag", "target": target}
1010 return {"name": self._tagName, "target": target}
1013@export
1014class Repository(metaclass=ExtendedType, slots=True):
1015 """
1016 The releases and tags of a GitHub repository, and the top of the tree.
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 """
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.
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.
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
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
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
1094 self._owner = owner
1095 self._name = name
1096 self._url = url
1097 self._releases = {}
1098 self._tags = {}
1099 self._fetchedAt = fetchedAt
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.")
1112 release._parent = self
1113 self._releases[release._tagName] = release
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.")
1126 tag._parent = self
1127 self._tags[tag._tagName] = tag
1129 @readonly
1130 def Owner(self) -> str:
1131 """
1132 Read-only property to access the repository's owner (:attr:`_owner`).
1134 :returns: The name of the user or organization owning the repository.
1135 """
1136 return self._owner
1138 @readonly
1139 def Name(self) -> str:
1140 """
1141 Read-only property to access the repository's name (:attr:`_name`).
1143 :returns: The name, without the owner.
1144 """
1145 return self._name
1147 @readonly
1148 def URL(self) -> _URL:
1149 """
1150 Read-only property to access the repository's URL (:attr:`_url`).
1152 :returns: The URL on github.com.
1153 """
1154 return self._url
1156 @readonly
1157 def Releases(self) -> dict[str, Release]:
1158 """
1159 Read-only property to access the releases (:attr:`_releases`).
1161 :returns: Dictionary of a tag's name to the release published at it, the newest first.
1162 """
1163 return self._releases
1165 @readonly
1166 def Tags(self) -> dict[str, Tag]:
1167 """
1168 Read-only property to access the tags (:attr:`_tags`).
1170 :returns: Dictionary of a tag's name to the tag, the tag of the newest commit first.
1171 """
1172 return self._tags
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`).
1179 A consumer storing the model tells the age of what it stored by this time.
1181 :returns: The time with its time zone, or ``None`` if unknown.
1182 """
1183 return self._fetchedAt
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`).
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
1196 return None
1198 def __len__(self) -> int:
1199 """
1200 Return the number of releases.
1202 :returns: The number of releases.
1203 """
1204 return len(self._releases)
1206 def __iter__(self) -> Iterator[Release]:
1207 """
1208 Iterate the releases, the newest first.
1210 :returns: An iterator of the releases.
1211 """
1212 return iter(self._releases.values())
1214 def __contains__(self, tagName: str) -> bool:
1215 """
1216 Check whether a release is published at a tag.
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
1223 def __getitem__(self, tagName: str) -> Release:
1224 """
1225 Return the release published at a tag.
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]
1233 def __str__(self) -> str:
1234 """
1235 Return a string representation of the repository.
1237 :returns: The repository as ``owner/name``.
1238 """
1239 return f"{self._owner}/{self._name}"
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.
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.
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
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.")
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
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 ]
1283 return cls(parts[0], parts[1], URL.Parse(repository["url"]), releases, tags, fetchedAt=fetchedAt)
1285 def ToJSON(self) -> JSONObject:
1286 """
1287 Write the releases and tags as a ``Repository`` object of GitHub's GraphQL API.
1289 Each connection holds all its nodes, and an asset its label.
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 }
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`.
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
1319 for field in ("version", "repository"):
1320 if snapshot.get(field, None) is None:
1321 raise GitHubError(f"Field '{field}' is missing.")
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
1330 return cls.Parse(snapshot["repository"], _parseISO8601Timestamp(snapshot.get("fetchedAt", None), "fetchedAt"))
1332 def ToSnapshot(self) -> JSONObject:
1333 """
1334 Write the releases and tags as a snapshot, which :meth:`ParseSnapshot` reads back.
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.
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 }