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

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. 

33 

34.. code-block:: python 

35 

36 from os import getenv 

37 from pyTooling.GitHub.ReleaseReader import ReleaseReader 

38 

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]}") 

42 

43.. hint:: 

44 

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 

51 

52from pyTooling.Decorators import export 

53from pyTooling.GenericPath.URL import URL 

54from pyTooling.REST import JSONObject, RESTError 

55 

56from pyTooling.GitHub import GITHUB_API_URL, GitHubError, RepositoryClient 

57from pyTooling.GitHub.Release import Repository 

58 

59 

60__all__ = ["RELEASES_QUERY", "RELEASE_ASSETS_QUERY"] 

61 

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 } 

112 

113 fragment Asset on ReleaseAsset { name contentType size downloadUrl digest } 

114 

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. 

119 

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""" 

123 

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.""" 

137 

138 

139@export 

140class ReleaseReader(RepositoryClient): 

141 """ 

142 Reads the releases and tags of a GitHub repository through GitHub's GraphQL API. 

143 

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. 

147 

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 """ 

152 

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. 

166 

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 ) 

184 

185 if token is None: 

186 raise ValueError("Parameter 'token' is None.") 

187 elif token == "": 

188 raise ValueError("Parameter 'token' is empty.") 

189 

190 def Read(self) -> Repository: 

191 """ 

192 Read the releases and tags of the repository. 

193 

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. 

198 

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}'.") 

223 

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.") 

236 

237 nodes.extend(connection["nodes"]) 

238 variables[flag] = pageInfo.get("hasNextPage", False) is True 

239 variables[cursor] = pageInfo.get("endCursor", None) 

240 

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}'.") 

248 

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.") 

252 

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}'.") 

259 

260 assets.extend(connection["nodes"]) 

261 

262 release["releaseAssets"] = {"nodes": assets} 

263 if len(assets) == 0: 

264 continue 

265 

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.") 

268 

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 

276 

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.") 

279 

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 

286 

287 asset["label"] = labels[assetName] 

288 

289 document = { 

290 "nameWithOwner": repository.get("nameWithOwner", None), 

291 "url": repository.get("url", None), 

292 "releases": {"nodes": releases}, 

293 "refs": {"nodes": refs} 

294 } 

295 

296 return Repository.Parse(document, fetchedAt) 

297 

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. 

301 

302 A query changes nothing, so a transiently failing request is tried again, unlike a ``POST`` sent by 

303 :meth:`~pyTooling.REST.RESTClient.PostJSONObject`. 

304 

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 

323 

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 

333 

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) 

337 

338 types.add(errorType) 

339 ex.add_note(f"{'Error' if errorType is None else errorType}: {error.get('message', '')}") 

340 

341 if "NOT_FOUND" in types: 

342 ex.add_note("Check the repository's name, and that the token may read the repository.") 

343 

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.") 

346 

347 raise ex 

348 elif not isinstance(data := response.get("data", None), dict): 

349 raise GitHubError("GraphQL response has no data.") 

350 

351 return data 

352 

353 def _AddErrorNotes(self, error: RESTError, status: int) -> None: 

354 """ 

355 Add a note saying what a failing status means for GitHub's APIs. 

356 

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). 

359 

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.")