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
« 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.
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.GenericPath.URL 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' 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.")
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
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
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.")
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
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
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
223 @readonly
224 def Parent(self) -> Nullable[Release]:
225 """
226 Read-only property to access the release the asset is attached to (:attr:`_parent`).
228 :returns: The release, or ``None`` if the asset isn't attached to one.
229 """
230 return self._parent
232 @readonly
233 def Name(self) -> str:
234 """
235 Read-only property to access the asset's file name (:attr:`_name`).
237 :returns: The file name, unique within its release.
238 """
239 return self._name
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`).
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``.
249 :returns: The label, or ``None`` if the asset has none.
250 """
251 return self._label
253 @readonly
254 def Digest(self) -> Nullable[str]:
255 """
256 Read-only property to access the asset's checksum (:attr:`_digest`).
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.
261 :returns: The checksum as ``algorithm:value``, or ``None`` if GitHub has none.
262 """
263 return self._digest
265 @readonly
266 def ContentType(self) -> str:
267 """
268 Read-only property to access the asset's media type (:attr:`_contentType`).
270 The uploader declares it, e.g. ``application/zip`` for a ZIP file.
272 :returns: The media type.
273 """
274 return self._contentType
276 @readonly
277 def Size(self) -> int:
278 """
279 Read-only property to access the asset's size (:attr:`_size`).
281 :returns: The size in bytes.
282 """
283 return self._size
285 @readonly
286 def DownloadURL(self) -> URL:
287 """
288 Read-only property to access the URL to download the asset from (:attr:`_downloadURL`).
290 :returns: The download URL on github.com.
291 """
292 return self._downloadURL
294 def __str__(self) -> str:
295 """
296 Return a string representation of the asset.
298 :returns: The asset's file name.
299 """
300 return self._name
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.
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.
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
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.")
325 label = asset.get("label", None)
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 )
336 def ToJSON(self) -> JSONObject:
337 """
338 Write the asset as a ``ReleaseAsset`` object of GitHub's GraphQL API, plus its label.
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 }
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.
357 An annotated tag is resolved to the commit it points to.
358 """
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.
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.
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.")
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.")
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
398 self._parent = None
399 self._tagName = tagName
400 self._commitSHA = commitSHA
401 self._commitURL = commitURL
403 @readonly
404 def Parent(self) -> Nullable[Repository]:
405 """
406 Read-only property to access the repository the element belongs to (:attr:`_parent`).
408 :returns: The repository, or ``None`` if the element belongs to none.
409 """
410 return self._parent
412 @readonly
413 def TagName(self) -> str:
414 """
415 Read-only property to access the tag's name (:attr:`_tagName`).
417 :returns: The tag's name, unique among the repository's tags and among its releases.
418 """
419 return self._tagName
421 @readonly
422 def CommitSHA(self) -> Nullable[str]:
423 """
424 Read-only property to access the commit the tag points to (:attr:`_commitSHA`).
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
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`).
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
439 def __str__(self) -> str:
440 """
441 Return a string representation of the element.
443 :returns: The tag's name.
444 """
445 return self._tagName
448@export
449class Release(CommitReference):
450 """
451 A release, published at a tag.
453 A draft is no release: it is unpublished and may change, so the model has none.
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.
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 """
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.
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.
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)
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
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.")
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
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
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
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 = {}
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.")
577 asset._parent = self
578 self._assets[asset._name] = asset
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
592 @readonly
593 def URL(self) -> _URL:
594 """
595 Read-only property to access the URL of the release page (:attr:`_url`).
597 :returns: The release page's URL on github.com.
598 """
599 return self._url
601 @readonly
602 def Name(self) -> Nullable[str]:
603 """
604 Read-only property to access the release's title (:attr:`_name`).
606 GitHub's release page shows the tag's name instead, when a release has no title.
608 :returns: The title, or ``None`` if the release has none.
609 """
610 return self._name
612 @readonly
613 def Description(self) -> str:
614 """
615 Read-only property to access the release notes (:attr:`_description`).
617 :returns: The release notes as GitHub Flavored Markdown, empty if the release has none.
618 """
619 return self._description
621 @readonly
622 def PublishedAt(self) -> Nullable[datetime]:
623 """
624 Read-only property to access the time the release was published (:attr:`_publishedAt`).
626 :returns: The time with its time zone - UTC, as GitHub reports it -, or ``None`` if unknown.
627 """
628 return self._publishedAt
630 @readonly
631 def IsPrerelease(self) -> bool:
632 """
633 Read-only property to access whether the release is a pre-release (:attr:`_isPrerelease`).
635 :returns: ``True``, if the release is a pre-release.
636 """
637 return self._isPrerelease
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`).
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.
647 :returns: ``True``, if the release is the latest release.
648 """
649 return self._isLatest
651 @readonly
652 def Assets(self) -> dict[str, Asset]:
653 """
654 Read-only property to access the files attached to the release (:attr:`_assets`).
656 :returns: Dictionary of a file name to its asset, in the order GitHub reported them.
657 """
658 return self._assets
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.
664 GitHub creates the archive on request, so it is no asset of the release and has no size.
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
679 if self._parent is None:
680 raise GitHubError(f"Release '{self._tagName}' belongs to no repository.")
682 return self._parent._url / f"archive/refs/tags/{quote(self._tagName)}.{archive}"
684 def __len__(self) -> int:
685 """
686 Return the number of assets.
688 :returns: The number of files attached to the release.
689 """
690 return len(self._assets)
692 def __iter__(self) -> Iterator[Asset]:
693 """
694 Iterate the assets, in the order GitHub reported them.
696 :returns: An iterator of the assets.
697 """
698 return iter(self._assets.values())
700 def __contains__(self, name: str) -> bool:
701 """
702 Check whether a file of a name is attached to the release.
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
709 def __getitem__(self, name: str) -> Asset:
710 """
711 Return the asset of a file name.
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]
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.
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.
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
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.")
747 if release["isDraft"] is True:
748 raise GitHubError(f"Release '{release['tagName']}' is a draft.")
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")
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 ]
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 )
778 def ToJSON(self) -> JSONObject:
779 """
780 Write the release as a ``Release`` object of GitHub's GraphQL API.
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)}
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 }
803@export
804class Tag(CommitReference):
805 """
806 A tag of a repository and the commit it points to.
808 A tag exists without a release - a repository may tag every version and publish a release for a few of them.
809 """
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.
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.
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)
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
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
862 self._committedAt = committedAt
863 self._isAnnotated = isAnnotated
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
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`).
882 This is the only time a tag without a release has.
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
888 @readonly
889 def IsAnnotated(self) -> bool:
890 """
891 Read-only property to access whether the tag is an annotated tag (:attr:`_isAnnotated`).
893 An annotated tag is a tag object of its own, carrying a message; a lightweight tag is a name for a commit.
895 :returns: ``True``, if the tag is an annotated tag.
896 """
897 return self._isAnnotated
899 @readonly
900 def Release(self) -> Nullable[Release]:
901 """
902 Read-only property to return the release published at the tag.
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
909 return self._parent._releases.get(self._tagName, None)
911 @readonly
912 def HasRelease(self) -> bool:
913 """
914 Read-only property to return whether a release is published at the tag.
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
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.
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.
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
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.")
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
959 isAnnotated = True
960 targetPath = f"{targetPath}.target"
961 target = target["target"]
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.")
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 )
976 def ToJSON(self) -> JSONObject:
977 """
978 Write the tag as a ``Ref`` object of GitHub's GraphQL API.
980 A target that is no commit is written as ``None``, and an annotated tag's ``Tag`` object without its own hash.
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 }
995 if self._isAnnotated:
996 target = {"__typename": "Tag", "target": target}
998 return {"name": self._tagName, "target": target}
1001@export
1002class Repository(metaclass=ExtendedType, slots=True):
1003 """
1004 The releases and tags of a GitHub repository, and the top of the tree.
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 """
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.
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.
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
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
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
1079 self._owner = owner
1080 self._name = name
1081 self._url = url
1082 self._releases = {}
1083 self._tags = {}
1084 self._fetchedAt = fetchedAt
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.")
1097 release._parent = self
1098 self._releases[release._tagName] = release
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.")
1111 tag._parent = self
1112 self._tags[tag._tagName] = tag
1114 @readonly
1115 def Owner(self) -> str:
1116 """
1117 Read-only property to access the repository's owner (:attr:`_owner`).
1119 :returns: The name of the user or organization owning the repository.
1120 """
1121 return self._owner
1123 @readonly
1124 def Name(self) -> str:
1125 """
1126 Read-only property to access the repository's name (:attr:`_name`).
1128 :returns: The name, without the owner.
1129 """
1130 return self._name
1132 @readonly
1133 def URL(self) -> _URL:
1134 """
1135 Read-only property to access the repository's URL (:attr:`_url`).
1137 :returns: The URL on github.com.
1138 """
1139 return self._url
1141 @readonly
1142 def Releases(self) -> dict[str, Release]:
1143 """
1144 Read-only property to access the releases (:attr:`_releases`).
1146 :returns: Dictionary of a tag's name to the release published at it, the newest first.
1147 """
1148 return self._releases
1150 @readonly
1151 def Tags(self) -> dict[str, Tag]:
1152 """
1153 Read-only property to access the tags (:attr:`_tags`).
1155 :returns: Dictionary of a tag's name to the tag, the tag of the newest commit first.
1156 """
1157 return self._tags
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`).
1164 A consumer storing the model tells the age of what it stored by this time.
1166 :returns: The time with its time zone, or ``None`` if unknown.
1167 """
1168 return self._fetchedAt
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`).
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
1181 return None
1183 def __len__(self) -> int:
1184 """
1185 Return the number of releases.
1187 :returns: The number of releases.
1188 """
1189 return len(self._releases)
1191 def __iter__(self) -> Iterator[Release]:
1192 """
1193 Iterate the releases, the newest first.
1195 :returns: An iterator of the releases.
1196 """
1197 return iter(self._releases.values())
1199 def __contains__(self, tagName: str) -> bool:
1200 """
1201 Check whether a release is published at a tag.
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
1208 def __getitem__(self, tagName: str) -> Release:
1209 """
1210 Return the release published at a tag.
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]
1218 def __str__(self) -> str:
1219 """
1220 Return a string representation of the repository.
1222 :returns: The repository as ``owner/name``.
1223 """
1224 return f"{self._owner}/{self._name}"
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.
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.
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
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.")
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
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 ]
1268 return cls(parts[0], parts[1], URL.Parse(repository["url"]), releases, tags, fetchedAt=fetchedAt)
1270 def ToJSON(self) -> JSONObject:
1271 """
1272 Write the releases and tags as a ``Repository`` object of GitHub's GraphQL API.
1274 Each connection holds all its nodes, and an asset its label.
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 }
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`.
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
1304 for field in ("version", "repository"):
1305 if snapshot.get(field, None) is None:
1306 raise GitHubError(f"Field '{field}' is missing.")
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
1315 return cls.Parse(snapshot["repository"], _parseISO8601Timestamp(snapshot.get("fetchedAt", None), "fetchedAt"))
1317 def ToSnapshot(self) -> JSONObject:
1318 """
1319 Write the releases and tags as a snapshot, which :meth:`ParseSnapshot` reads back.
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.
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 }