Coverage for pyTooling/GitHub/ReleaseReader.py: 81%
112 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"""
32Read the releases and tags of a GitHub repository through GitHub's GraphQL API.
34.. code-block:: python
36 from os import getenv
37 from pyTooling.GitHub.ReleaseReader import ReleaseReader
39 repository = ReleaseReader("ghdl", "ghdl", token=getenv("GITHUB_TOKEN")).Read()
40 release = repository["v5.0.1"]
41 print(f"{release.TagName} published {release.PublishedAt:%d.%m.%Y %H:%M:%S} at commit {release.CommitSHA[:7]}")
43.. hint::
45 See :ref:`high-level help <DATA/Release>` for explanations and usage examples.
46"""
47from datetime import datetime, timezone
48from http import HTTPMethod
49from textwrap import dedent
50from typing import Any, Optional as Nullable, Union
52from pyTooling.Decorators import export
53from pyTooling.GenericPath.URL import URL
54from pyTooling.REST import JSONObject, RESTError
56from pyTooling.GitHub import GITHUB_API_URL, GitHubError, RepositoryClient
57from pyTooling.GitHub.Release import Repository
60__all__ = ["RELEASES_QUERY", "RELEASE_ASSETS_QUERY"]
62RELEASES_QUERY = dedent("""\
63 query Releases(
64 $owner: String!, $name: String!,
65 $withReleases: Boolean!, $releasesCursor: String,
66 $withTags: Boolean!, $tagsCursor: String
67 ) {
68 repository(owner: $owner, name: $name) {
69 nameWithOwner
70 url
71 releases(
72 first: 50, after: $releasesCursor, orderBy: {field: CREATED_AT, direction: DESC}
73 ) @include(if: $withReleases) {
74 pageInfo { hasNextPage endCursor }
75 nodes {
76 id
77 databaseId
78 tagName
79 name
80 description
81 publishedAt
82 url
83 isDraft
84 isPrerelease
85 isLatest
86 tagCommit { oid url }
87 releaseAssets(first: 100) {
88 pageInfo { hasNextPage endCursor }
89 nodes { ...Asset }
90 }
91 }
92 }
93 refs(
94 refPrefix: "refs/tags/", first: 100, after: $tagsCursor, orderBy: {field: TAG_COMMIT_DATE, direction: DESC}
95 ) @include(if: $withTags) {
96 pageInfo { hasNextPage endCursor }
97 nodes {
98 name
99 target {
100 ...Target
101 ... on Tag {
102 target {
103 ...Target
104 ... on Tag { target { ...Target ... on Tag { target { __typename oid } } } }
105 }
106 }
107 }
108 }
109 }
110 }
111 }
113 fragment Asset on ReleaseAsset { name contentType size downloadUrl digest }
115 fragment Target on GitObject { __typename oid ... on Commit { url committedDate } }
116""")
117"""
118GraphQL query reading a page of releases and a page of tags of a repository.
120The variables ``withReleases`` and ``withTags`` leave out a connection whose last page was read already. An annotated
121tag is followed through up to three tag objects to its commit.
122"""
124RELEASE_ASSETS_QUERY = dedent("""\
125 query ReleaseAssets($id: ID!, $cursor: String) {
126 node(id: $id) {
127 ... on Release {
128 releaseAssets(first: 100, after: $cursor) {
129 pageInfo { hasNextPage endCursor }
130 nodes { name contentType size downloadUrl digest }
131 }
132 }
133 }
134 }
135""")
136"""GraphQL query reading a further page of a release's assets."""
139@export
140class ReleaseReader(RepositoryClient):
141 """
142 Reads the releases and tags of a GitHub repository through GitHub's GraphQL API.
144 One query reads a page of releases with their assets and a page of tags with their commits, so a repository with
145 fewer than 50 releases and 100 tags is read by one request. GitHub's GraphQL API reports no asset labels, so each
146 release with assets costs one request to the REST API for its labels. Draft releases are skipped.
148 GitHub's GraphQL API serves no anonymous requests, so a token is required - inside a workflow, ``GITHUB_TOKEN``
149 with the ``contents: read`` permission suffices. The GraphQL endpoint is ``graphql`` below :attr:`APIURL`, as on
150 github.com.
151 """
153 def __init__(
154 self,
155 owner: str,
156 repository: str,
157 token: str,
158 *,
159 apiURL: Union[str, URL] = GITHUB_API_URL,
160 timeout: float = 30.0,
161 retries: int = 3,
162 retryDelay: float = 2.0
163 ) -> None:
164 """
165 Initializes a reader for the releases and tags of a repository.
167 :param owner: The owner of the repository, a user or an organization.
168 :param repository: The name of the repository.
169 :param token: Token authorizing the requests.
170 :param apiURL: Optional, base URL of the GitHub API. Default: :data:`~pyTooling.GitHub.GITHUB_API_URL`.
171 :param timeout: Optional, timeout of a single request in seconds. Default: ``30.0``.
172 :param retries: Optional, how often a transiently failing request is tried again. ``0`` tries once.
173 Default: ``3``.
174 :param retryDelay: Optional, pause in seconds before a request is tried again the first time. The pause doubles
175 with every further attempt. Default: ``2.0``.
176 :raises ValueError: If parameter 'token' is ``None``.
177 :raises ValueError: If parameter 'token' is empty.
178 :raises TypeError: If a parameter of :class:`~pyTooling.GitHub.RepositoryClient` has the wrong type.
179 :raises ValueError: If a parameter of :class:`~pyTooling.GitHub.RepositoryClient` has an invalid value.
180 """
181 super().__init__(
182 owner, repository, token, apiURL=apiURL, timeout=timeout, retries=retries, retryDelay=retryDelay
183 )
185 if token is None:
186 raise ValueError("Parameter 'token' is None.")
187 elif token == "":
188 raise ValueError("Parameter 'token' is empty.")
190 def Read(self) -> Repository:
191 """
192 Read the releases and tags of the repository.
194 Every page of releases, tags and assets is read, draft releases are skipped, the asset labels are added from the
195 REST API, and the result is parsed by :meth:`Repository.Parse <pyTooling.GitHub.Release.Repository.Parse>`.
196 A request fails e.g. on an exhausted rate limit or an unreachable API, and GitHub's GraphQL API reports e.g. an
197 unknown repository as an error.
199 :returns: The repository, with its releases and tags attached, and the time reading began.
200 :raises GitHubError: If a request fails. |br|
201 The notes report the cause.
202 :raises GitHubError: If GitHub's GraphQL API responds with errors. |br|
203 The notes report each error.
204 :raises GitHubError: If a response lacks a field or a page.
205 :raises GitHubError: If the REST API reports the assets of a release differently. |br|
206 The note says to read the repository again, as assets may have changed while reading.
207 """
208 fetchedAt = datetime.now(timezone.utc)
209 variables: dict[str, Any] = {
210 "owner": self._owner,
211 "name": self._repository,
212 "withReleases": True,
213 "releasesCursor": None,
214 "withTags": True,
215 "tagsCursor": None
216 }
217 releases: list[JSONObject] = []
218 refs: list[JSONObject] = []
219 repository: JSONObject
220 while variables["withReleases"] or variables["withTags"]:
221 if not isinstance(repository := self._Query(RELEASES_QUERY, variables).get("repository", None), dict):
222 raise GitHubError(f"GraphQL response has no repository '{self._owner}/{self._repository}'.")
224 for field, nodes, flag, cursor in (
225 ("releases", releases, "withReleases", "releasesCursor"),
226 ("refs", refs, "withTags", "tagsCursor")
227 ):
228 if not variables[flag]:
229 continue
230 elif not isinstance(connection := repository.get(field, None), dict) \
231 or not isinstance(connection.get("nodes", None), list) \
232 or not isinstance(pageInfo := connection.get("pageInfo", None), dict):
233 raise GitHubError(f"GraphQL response has no page of field '{field}'.")
234 elif pageInfo.get("hasNextPage", False) and pageInfo.get("endCursor", None) is None: 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true
235 raise GitHubError(f"GraphQL response announces a further page of field '{field}' without a cursor.")
237 nodes.extend(connection["nodes"])
238 variables[flag] = pageInfo.get("hasNextPage", False) is True
239 variables[cursor] = pageInfo.get("endCursor", None)
241 releases = [release for release in releases if release.get("isDraft", None) is not True]
242 for release in releases:
243 tagName = release.get("tagName", None)
244 if not isinstance(connection := release.get("releaseAssets", None), dict) \ 244 ↛ 247line 244 didn't jump to line 247 because the condition on line 244 was never true
245 or not isinstance(assets := connection.get("nodes", None), list) \
246 or not isinstance(pageInfo := connection.get("pageInfo", None), dict):
247 raise GitHubError(f"GraphQL response has no assets of release '{tagName}'.")
249 while pageInfo.get("hasNextPage", False):
250 if (assetsCursor := pageInfo.get("endCursor", None)) is None: 250 ↛ 251line 250 didn't jump to line 251 because the condition on line 250 was never true
251 raise GitHubError(f"GraphQL response announces further assets of release '{tagName}' without a cursor.")
253 node = self._Query(RELEASE_ASSETS_QUERY, {"id": release.get("id", None), "cursor": assetsCursor})
254 if not isinstance(node.get("node", None), dict) \ 254 ↛ 258line 254 didn't jump to line 258 because the condition on line 254 was never true
255 or not isinstance(connection := node["node"].get("releaseAssets", None), dict) \
256 or not isinstance(connection.get("nodes", None), list) \
257 or not isinstance(pageInfo := connection.get("pageInfo", None), dict):
258 raise GitHubError(f"GraphQL response has no further assets of release '{tagName}'.")
260 assets.extend(connection["nodes"])
262 release["releaseAssets"] = {"nodes": assets}
263 if len(assets) == 0:
264 continue
266 if (databaseID := release.get("databaseId", None)) is None: 266 ↛ 267line 266 didn't jump to line 267 because the condition on line 266 was never true
267 raise GitHubError(f"Field 'databaseId' of release '{tagName}' is missing.")
269 try:
270 restRelease, _ = self.GetJSONObject(f"repos/{self._owner}/{self._repository}/releases/{databaseID}")
271 except RESTError as cause:
272 ex = GitHubError(f"Request for asset labels of release '{tagName}' failed: {cause}")
273 for note in getattr(cause, "__notes__", []):
274 ex.add_note(note)
275 raise ex from cause
277 if not isinstance(restAssets := restRelease.get("assets", None), list): 277 ↛ 278line 277 didn't jump to line 278 because the condition on line 277 was never true
278 raise GitHubError(f"REST response for release '{tagName}' has no assets.")
280 labels = {asset.get("name", None): asset.get("label", None) for asset in restAssets if isinstance(asset, dict)}
281 for asset in assets:
282 if (assetName := asset.get("name", None)) not in labels:
283 ex = GitHubError(f"REST response for release '{tagName}' lacks asset '{assetName}'.")
284 ex.add_note("Assets may have changed while reading. Read the repository again.")
285 raise ex
287 asset["label"] = labels[assetName]
289 document = {
290 "nameWithOwner": repository.get("nameWithOwner", None),
291 "url": repository.get("url", None),
292 "releases": {"nodes": releases},
293 "refs": {"nodes": refs}
294 }
296 return Repository.Parse(document, fetchedAt)
298 def _Query(self, query: str, variables: dict[str, Any]) -> JSONObject:
299 """
300 Send a query to GitHub's GraphQL API and return the response's data.
302 A query changes nothing, so a transiently failing request is tried again, unlike a ``POST`` sent by
303 :meth:`~pyTooling.REST.RESTClient.PostJSONObject`.
305 :param query: The GraphQL query.
306 :param variables: The query's variables.
307 :returns: The field ``data`` of the response.
308 :raises GitHubError: If the request fails. |br|
309 The notes report the cause.
310 :raises GitHubError: If the response reports errors. |br|
311 The notes report each error. For an unknown repository, they say to check the repository's
312 name and the token's permission; for an exceeded rate limit, to wait for its reset or use a
313 token with a higher limit.
314 :raises GitHubError: If the response is empty or has no data.
315 """
316 try:
317 response, _ = self._Request(HTTPMethod.POST, "graphql", {"query": query, "variables": variables})
318 except RESTError as cause:
319 ex = GitHubError(f"GraphQL request for repository '{self._owner}/{self._repository}' failed: {cause}")
320 for note in getattr(cause, "__notes__", []):
321 ex.add_note(note)
322 raise ex from cause
324 if response is None: 324 ↛ 325line 324 didn't jump to line 325 because the condition on line 324 was never true
325 raise GitHubError("GraphQL response is empty.")
326 elif (errors := response.get("errors", None)) is not None:
327 ex = GitHubError(f"GraphQL request for repository '{self._owner}/{self._repository}' failed.")
328 types = set()
329 for error in errors if isinstance(errors, list) else [errors]:
330 if not isinstance(error, dict): 330 ↛ 331line 330 didn't jump to line 331 because the condition on line 330 was never true
331 ex.add_note(f"Error: {error}")
332 continue
334 errorType = error.get("type", None)
335 if errorType is None and isinstance(extensions := error.get("extensions", None), dict):
336 errorType = extensions.get("code", None)
338 types.add(errorType)
339 ex.add_note(f"{'Error' if errorType is None else errorType}: {error.get('message', '')}")
341 if "NOT_FOUND" in types:
342 ex.add_note("Check the repository's name, and that the token may read the repository.")
344 if "RATE_LIMITED" in types:
345 ex.add_note("GraphQL rate limit exceeded. Wait for its reset, or use a token with a higher limit.")
347 raise ex
348 elif not isinstance(data := response.get("data", None), dict):
349 raise GitHubError("GraphQL response has no data.")
351 return data
353 def _AddErrorNotes(self, error: RESTError, status: int) -> None:
354 """
355 Add a note saying what a failing status means for GitHub's APIs.
357 The note names an invalid or expired token (401), an exceeded rate limit or a token that may not read the
358 repository (403, 429), or says to check the repository's name and the token's permission (404).
360 :param error: The error the note is added to.
361 :param status: The HTTP status the request failed with.
362 """
363 if status == 401: 363 ↛ 365line 363 didn't jump to line 365 because the condition on line 363 was always true
364 error.add_note("Token is invalid or expired.")
365 elif status in (403, 429):
366 error.add_note("Rate limit exceeded, or the token may not read the repository.")
367 elif status == 404:
368 error.add_note("Check the repository's name, and that the token may read the repository.")