Coverage for pyTooling/GitHub/__init__.py: 97%

525 statements  

« 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 Actions workflow run. 

33 

34The GitHub REST API answers with nested JSON objects whose fields are strings - ``"status": "completed"``, 

35``"conclusion": "timed_out"``, timestamps as ISO 8601 text. This model reads those payloads once into objects: 

36 

37.. tree:: 

38 

39 - :class:`PipelineGroup` | every run started for one commit 

40 - :class:`Pipeline` | a workflow run 

41 - :class:`~pyTooling.CI.Workflow` | a called (reusable) workflow, grouping the jobs it contains 

42 - :class:`~pyTooling.CI.Workflow` | a workflow called by that workflow 

43 - :class:`~pyTooling.CI.Matrix` | a matrix, grouping the job instances it produced 

44 - :class:`MatrixJob` 

45 - :class:`Job` 

46 - :class:`~pyTooling.CI.Matrix` 

47 - :class:`Job` | a job that ran on a runner 

48 - :class:`Step` | a step of that job 

49 

50The classes derive from the service-independent model :mod:`pyTooling.CI`, which a called workflow, a 

51matrix and their base-class are taken from unchanged. Every element knows its parent and the pipeline it belongs to, 

52and the string fields become :class:`Status`, :class:`Conclusion` and :class:`Event` members, so an undocumented value 

53is an error rather than a comparison that never matches. A conclusion is reported as the model's 

54:class:`~pyTooling.CI.Outcome` as well. 

55 

56The model carries no dependency on what is done with it. Converting a :class:`Pipeline` into a software execution 

57trace, a graph or a report is a consumer of this model. 

58""" 

59from __future__ import annotations 

60 

61__author__ = "Patrick Lehmann" 

62__email__ = "Paebbels@gmail.com" 

63__copyright__ = "2026-2026, Patrick Lehmann" 

64__license__ = "Apache License, Version 2.0" 

65__version__ = "0.1.0" 

66__keywords__ = ["GitHub", "GitHub Actions", "Workflow", "Pipeline", "CI", "Trace", "OpenTelemetry", "OTLP", "Sphinx"] 

67__project_url__ = "https://github.com/pyTooling/pyTooling.GitHub" 

68__documentation_url__ = "https://pyTooling.github.io/pyTooling.GitHub" 

69__issue_tracker_url__ = "https://GitHub.com/pyTooling/pyTooling.GitHub/issues" 

70 

71from datetime import datetime, timezone 

72from typing import Optional as Nullable, Any, ClassVar, Hashable, Iterable, Mapping, Self, Union 

73 

74from pyTooling.CI import CIError, MatrixInstanceMixin, Outcome, Job as CIJob, JobGroup as CIJobGroup, \ 

75 Matrix as CIMatrix, MatrixWorkflow as CIMatrixWorkflow, Pipeline as CIPipeline, \ 

76 PipelineGroup as CIPipelineGroup, Step as CIStep, Workflow as CIWorkflow 

77from pyTooling.Common import getFullyQualifiedName, parseISO8601Timestamp, StringEnum 

78from pyTooling.Decorators import export, readonly 

79from pyTooling.URI import URL 

80from pyTooling.MetaClasses import ExtendedType 

81from pyTooling.REST import JSONObject, RESTClient 

82 

83 

84__all__ = ["CONCLUSION_TO_OUTCOME", "GITHUB_API_URL"] 

85 

86 

87@export 

88class GitHubError(CIError): 

89 """Base-exception of all exceptions raised by :mod:`pyTooling.GitHub`.""" 

90 

91 

92GITHUB_API_URL = URL.Parse("https://api.github.com") 

93"""Base URL of the GitHub REST API.""" 

94 

95_GITHUB_HEADERS = { 

96 "Accept": "application/vnd.github+json", 

97 "X-GitHub-Api-Version": "2022-11-28" 

98} 

99"""Headers every request to the GitHub REST API carries.""" 

100 

101 

102@export 

103class RepositoryClient(RESTClient): 

104 """ 

105 A client of the GitHub REST API for one repository. 

106 

107 A reader of a repository's data derives from it, e.g. :class:`~pyTooling.GitHub.Tracing.WorkflowRunReader`. The 

108 requests themselves - the retries, the pagination and the token's boundary - are 

109 :class:`~pyTooling.REST.RESTClient`'s. 

110 """ 

111 

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

113 _repository: str #: Name of the repository. 

114 

115 def __init__( 

116 self, 

117 owner: str, 

118 repository: str, 

119 token: Nullable[str] = None, 

120 *, 

121 apiURL: Union[str, URL] = GITHUB_API_URL, 

122 timeout: float = 30.0, 

123 retries: int = 3, 

124 retryDelay: float = 2.0 

125 ) -> None: 

126 """ 

127 Initializes a client for one repository. 

128 

129 :param owner: The owner of the repository, a user or an organization. 

130 :param repository: The name of the repository. 

131 :param token: Optional, token authorizing the requests. Default: anonymous requests. 

132 :param apiURL: Optional, base URL of the GitHub REST API, e.g. of a GitHub Enterprise Server. 

133 Default: :data:`GITHUB_API_URL`. 

134 :param timeout: Optional, timeout of a single request in seconds. Default: ``30.0``. 

135 :param retries: Optional, how often a transiently failing request is tried again. ``0`` tries once. 

136 Default: ``3``. 

137 :param retryDelay: Optional, pause in seconds before a request is tried again the first time. The pause doubles 

138 with every further attempt. Default: ``2.0``. 

139 :raises ValueError: If parameter 'owner' is ``None``. 

140 :raises TypeError: If parameter 'owner' is not of type :class:`str`. 

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

142 :raises ValueError: If parameter 'repository' is ``None``. 

143 :raises TypeError: If parameter 'repository' is not of type :class:`str`. 

144 :raises ValueError: If parameter 'repository' is empty or contains a ``/``. 

145 :raises TypeError: If a parameter of :class:`~pyTooling.REST.RESTClient` has the wrong type. 

146 :raises ValueError: If a parameter of :class:`~pyTooling.REST.RESTClient` has an invalid value. 

147 """ 

148 super().__init__(apiURL, token, headers=_GITHUB_HEADERS, timeout=timeout, retries=retries, retryDelay=retryDelay) 

149 

150 for parameterName, text in (("owner", owner), ("repository", repository)): 

151 if text is None: 

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

153 elif not isinstance(text, str): 

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

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

156 raise ex 

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

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

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

160 raise ex 

161 

162 self._owner = owner 

163 self._repository = repository 

164 

165 @readonly 

166 def Owner(self) -> str: 

167 """ 

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

169 

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

171 """ 

172 return self._owner 

173 

174 @readonly 

175 def Repository(self) -> str: 

176 """ 

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

178 

179 :returns: The name of the repository, without its owner. 

180 """ 

181 return self._repository 

182 

183 

184@export 

185class Status(StringEnum): 

186 """The state a workflow run, job or step is in.""" 

187 

188 Queued = "queued" #: Waiting to be picked up. 

189 InProgress = "in_progress" #: Running. 

190 Completed = "completed" #: Finished, with a :class:`Conclusion`. 

191 Waiting = "waiting" #: Held, e.g. for an environment's approval. 

192 Requested = "requested" #: Requested, but not yet queued. 

193 Pending = "pending" #: Blocked by a concurrency group. 

194 

195 @classmethod 

196 def Parse(cls, value: Nullable[str]) -> Nullable[Status]: 

197 """ 

198 Convert GitHub's ``status`` field to a member of this enumeration. 

199 

200 :param value: Optional, the field's value. Default: ``None``. 

201 :returns: The matching member, or ``None`` if the field was absent or empty. 

202 :raises TypeError: If parameter 'value' is not of type :class:`str`. 

203 :raises GitHubError: If the value is not a status GitHub documents. |br| 

204 The note lists the documented values. 

205 """ 

206 try: 

207 return super().Parse(value) 

208 except ValueError as ex: 

209 error = GitHubError(f"'{value}' is not a GitHub status.") 

210 error.add_note(f"Known: {', '.join(member.value for member in cls)}.") 

211 raise error from ex 

212 

213 

214@export 

215class Conclusion(StringEnum): 

216 """How a completed workflow run, job or step ended.""" 

217 

218 Success = "success" #: Succeeded. 

219 Failure = "failure" #: Failed. 

220 Cancelled = "cancelled" #: Cancelled before it finished. 

221 Skipped = "skipped" #: Not run, because a condition excluded it. 

222 TimedOut = "timed_out" #: Stopped by a timeout. 

223 ActionRequired = "action_required" #: Waiting for a manual action. 

224 Neutral = "neutral" #: Finished without a verdict. 

225 Stale = "stale" #: Never ran, because the run was superseded. 

226 StartupFailure = "startup_failure" #: The workflow file itself couldn't be started. 

227 

228 @classmethod 

229 def Parse(cls, value: Nullable[str]) -> Nullable[Conclusion]: 

230 """ 

231 Convert GitHub's ``conclusion`` field to a member of this enumeration. 

232 

233 :param value: Optional, the field's value. Default: ``None``. 

234 :returns: The matching member, or ``None`` while it hasn't concluded. 

235 :raises TypeError: If parameter 'value' is not of type :class:`str`. 

236 :raises GitHubError: If the value is not a conclusion GitHub documents. |br| 

237 The note lists the documented values. 

238 """ 

239 try: 

240 return super().Parse(value) 

241 except ValueError as ex: 

242 error = GitHubError(f"'{value}' is not a GitHub conclusion.") 

243 error.add_note(f"Known: {', '.join(member.value for member in cls)}.") 

244 raise error from ex 

245 

246 def ToOutcome(self) -> Outcome: 

247 """ 

248 Return the service-independent outcome this conclusion corresponds to. 

249 

250 The conclusions with a counterpart of their own - e.g. :attr:`TimedOut`, :attr:`Skipped`, :attr:`Cancelled` - are 

251 listed in :data:`CONCLUSION_TO_OUTCOME`; any other, e.g. :attr:`StartupFailure` or :attr:`Neutral`, is an 

252 :attr:`~pyTooling.CI.Outcome.Error`. 

253 

254 :returns: The outcome. 

255 """ 

256 return CONCLUSION_TO_OUTCOME.get(self, Outcome.Error) 

257 

258 

259CONCLUSION_TO_OUTCOME = { 

260 Conclusion.Success: Outcome.Success, 

261 Conclusion.Failure: Outcome.Failure, 

262 Conclusion.TimedOut: Outcome.Timeout, 

263 Conclusion.Skipped: Outcome.Skip, 

264 Conclusion.Cancelled: Outcome.Cancellation, 

265} 

266"""GitHub's conclusions with a service-independent outcome of their own.""" 

267 

268 

269@export 

270class Event(StringEnum): 

271 """The event that triggered a workflow run.""" 

272 

273 CheckRun = "check_run" #: A check run was created or completed. 

274 CheckSuite = "check_suite" #: A check suite was created or completed. 

275 Create = "create" #: A branch or tag was created. 

276 Delete = "delete" #: A branch or tag was deleted. 

277 Deployment = "deployment" #: A deployment was created. 

278 DeploymentStatus = "deployment_status" #: A deployment's status changed. 

279 Discussion = "discussion" #: A discussion was touched. 

280 DiscussionComment = "discussion_comment" #: A discussion was commented on. 

281 Fork = "fork" #: The repository was forked. 

282 Gollum = "gollum" #: A wiki page was created or updated. 

283 IssueComment = "issue_comment" #: An issue or pull-request was commented on. 

284 Issues = "issues" #: An issue was touched. 

285 Label = "label" #: A label was touched. 

286 MergeGroup = "merge_group" #: A merge group entered the merge queue. 

287 Milestone = "milestone" #: A milestone was touched. 

288 PageBuild = "page_build" #: GitHub Pages was built. 

289 Public = "public" #: The repository was made public. 

290 PullRequest = "pull_request" #: A pull-request was touched. 

291 PullRequestComment = "pull_request_comment" #: A pull-request was commented on. 

292 PullRequestReview = "pull_request_review" #: A pull-request was reviewed. 

293 PullRequestReviewComment = "pull_request_review_comment" #: A review was commented on. 

294 PullRequestTarget = "pull_request_target" #: A pull-request, run against its base. 

295 Push = "push" #: A commit or tag was pushed. 

296 RegistryPackage = "registry_package" #: A package was published or updated. 

297 Release = "release" #: A release was touched. 

298 RepositoryDispatch = "repository_dispatch" #: An external event was dispatched. 

299 Schedule = "schedule" #: A cron schedule fired. 

300 Status = "status" #: A commit's status changed. 

301 Watch = "watch" #: The repository was starred. 

302 WorkflowCall = "workflow_call" #: The workflow was called by another one. 

303 WorkflowDispatch = "workflow_dispatch" #: The workflow was started by hand or by a token. 

304 WorkflowRun = "workflow_run" #: Another workflow run completed. 

305 Dynamic = "dynamic" #: GitHub started the run without a workflow file. 

306 

307 @classmethod 

308 def Parse(cls, value: Nullable[str]) -> Nullable[Event]: 

309 """ 

310 Convert GitHub's ``event`` field to a member of this enumeration. 

311 

312 :param value: Optional, the field's value. Default: ``None``. 

313 :returns: The matching member, or ``None`` if the field was absent or empty. 

314 :raises TypeError: If parameter 'value' is not of type :class:`str`. 

315 :raises GitHubError: If the value is not an event GitHub documents. |br| 

316 The note lists the documented values. 

317 """ 

318 try: 

319 return super().Parse(value) 

320 except ValueError as ex: 

321 error = GitHubError(f"'{value}' is not a GitHub event.") 

322 error.add_note(f"Known: {', '.join(member.value for member in cls)}.") 

323 raise error from ex 

324 

325 

326def _parseISO8601Timestamp(value: Nullable[str], field: str) -> Nullable[datetime]: 

327 """ 

328 Parse an ISO 8601 timestamp, as the GitHub REST API reports them. 

329 

330 A timestamp without a time zone is read as UTC, so every timestamp of a run can be compared with every other. 

331 

332 :param value: Optional, the field's value. Default: ``None``. 

333 :param field: Name of the field, for the exception's message. 

334 :returns: The timestamp, or ``None`` if the field was absent or empty. 

335 :raises GitHubError: If the value isn't an ISO 8601 timestamp. |br| 

336 The note reports the value that was read. 

337 """ 

338 try: 

339 return parseISO8601Timestamp(value, timezone.utc) 

340 except ValueError as ex: 

341 error = GitHubError(f"Field '{field}' isn't an ISO 8601 timestamp.") 

342 error.add_note(f"Got '{value}'.") 

343 raise error from ex 

344 

345 

346def _parseURL(value: Nullable[str], field: str) -> Nullable[URL]: 

347 """ 

348 Parse a URL, as the GitHub REST API reports them. 

349 

350 :param value: Optional, the field's value. Default: ``None``. 

351 :param field: Name of the field, kept for symmetry with :func:`_parseISO8601Timestamp` and for the message this 

352 will report once :meth:`~pyTooling.URI.URL.Parse` rejects what isn't a URL. 

353 :returns: The URL, or ``None`` if the field was absent or empty. 

354 """ 

355 if value is None or value == "": 

356 return None 

357 

358 return URL.Parse(value) 

359 

360 

361def _splitMatrixJobName(name: str) -> tuple[str, Nullable[dict[str, str]]]: 

362 """ 

363 Split a job's name into the matrix' name and the dimensions, if it carries any. 

364 

365 GitHub appends the dimensions' values of a matrix instance to the job's name, as 

366 ``Unit Tests (ubuntu-26.04, 3.14)``. That bracketed suffix is a naming convention of GitHub's own interface, not a 

367 field of the payload, so a job genuinely named ``Build (fast)`` and produced by no matrix is indistinguishable from 

368 one that was. A job whose workflow sets its own ``name:`` carries no values at all, and its matrix stays invisible. 

369 

370 The suffix carries no dimension names, so a dimension is named by the position of its value: ``"0"``, ``"1"``, ... 

371 

372 :param name: The job's name, without any calling workflows' prefixes. 

373 :returns: The name without the suffix and the dimensions, or the name and ``None`` if it carries none. 

374 """ 

375 if not name.endswith(")") or "(" not in name: 

376 return name, None 

377 

378 base, _, values = name[:-1].rpartition("(") 

379 base = base.rstrip() 

380 if base == "": 

381 return name, None 

382 

383 return base, {str(position): value.strip() for position, value in enumerate(values.split(","))} 

384 

385 

386@export 

387class StatusMixin(metaclass=ExtendedType, mixin=True, expects=("_outcome",)): 

388 """ 

389 Mixin-class for the elements GitHub reports: a workflow run, a job and a step. 

390 

391 GitHub reports a :class:`Status` and, once completed, a :class:`Conclusion` for each of them, and a URL on 

392 github.com for a run and a job. A called workflow and a matrix aren't reported, so they have none of it. The 

393 conclusion is also the element's generic :attr:`~pyTooling.CI.Base.Outcome`. 

394 """ 

395 

396 _status: Nullable[Status] #: State the element is in. 

397 _conclusion: Nullable[Conclusion] #: How the element ended. 

398 _url: Nullable[URL] #: URL of the element on github.com. 

399 

400 def __init__( 

401 self, 

402 status: Nullable[Status] = None, 

403 conclusion: Nullable[Conclusion] = None, 

404 url: Nullable[URL] = None 

405 ) -> None: 

406 """ 

407 Initializes what GitHub reports about an element. 

408 

409 :param status: Optional, state the element is in. Default: ``None``. 

410 :param conclusion: Optional, how the element ended. Default: ``None``. 

411 :param url: Optional, URL of the element on github.com. Default: ``None``. 

412 :raises TypeError: If parameter 'status' is not of type :class:`Status`. 

413 :raises TypeError: If parameter 'conclusion' is not of type :class:`Conclusion`. 

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

415 """ 

416 if status is not None and not isinstance(status, Status): 

417 ex = TypeError("Parameter 'status' is not of type 'Status'.") 

418 ex.add_note(f"Got type '{getFullyQualifiedName(status)}'.") 

419 raise ex 

420 

421 if conclusion is not None and not isinstance(conclusion, Conclusion): 

422 ex = TypeError("Parameter 'conclusion' is not of type 'Conclusion'.") 

423 ex.add_note(f"Got type '{getFullyQualifiedName(conclusion)}'.") 

424 raise ex 

425 

426 if url is not None and not isinstance(url, URL): 

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

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

429 raise ex 

430 

431 self._status = status 

432 self._conclusion = conclusion 

433 self._url = url 

434 if conclusion is not None: 

435 self._outcome = conclusion.ToOutcome() 

436 

437 @readonly 

438 def Status(self) -> Nullable[Status]: 

439 """ 

440 Read-only property to access the state the element is in (:attr:`_status`). 

441 

442 :returns: The state, or ``None`` if GitHub reported none. 

443 """ 

444 return self._status 

445 

446 @readonly 

447 def Conclusion(self) -> Nullable[Conclusion]: 

448 """ 

449 Read-only property to access how the element ended (:attr:`_conclusion`). 

450 

451 The service-independent :attr:`~pyTooling.CI.Base.Outcome` is derived from it by 

452 :meth:`Conclusion.ToOutcome`. 

453 

454 :returns: The conclusion, or ``None`` while the element hasn't concluded. 

455 """ 

456 return self._conclusion 

457 

458 @readonly 

459 def URL(self) -> Nullable[URL]: 

460 """ 

461 Read-only property to access the element's URL on github.com (:attr:`_url`). 

462 

463 :returns: The URL, or ``None`` if GitHub reported none. 

464 """ 

465 return self._url 

466 

467 

468@export 

469class PipelineGroup(CIPipelineGroup): 

470 """ 

471 Every workflow run GitHub started for one commit, and the top of the tree. 

472 

473 A push starts one run per workflow file whose triggers match, so a commit has as many pipelines as the repository 

474 has matching workflows - `pyTooling/Actions` answers a push with six. 

475 

476 **A run at a tag is in the group as well, and is not the same thing.** It carries the same commit, so the API 

477 cannot separate it, but it was started later and for a different reason: a release pipeline tags its own commit, 

478 and the run at that tag publishes the release. For `pyTooling/MiKTeX` v1.6.0 the two runs of commit ``2c36ead`` 

479 were 

480 

481 .. code-block:: text 

482 

483 event=push head_branch=main run_started_at=07:53:39 tags the commit 

484 event=workflow_dispatch head_branch=v1.6.0 run_started_at=08:09:37 publishes the release 

485 

486 :meth:`ByGitReference` separates them, since :attr:`Pipeline.GitReference` holds the tag's name for the second. 

487 

488 The group has no times of its own and derives them from its pipelines - so its span covers the tag's run too, and 

489 is wider than the time the commit's checks took. 

490 """ 

491 

492 def __init__( 

493 self, 

494 sha: str, 

495 pipelines: Nullable[Iterable[Pipeline]] = None, 

496 *, 

497 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None 

498 ) -> None: 

499 """ 

500 Initializes a group of pipelines started for one commit. 

501 

502 :param sha: Commit every pipeline of the group was started on. 

503 :param pipelines: Optional, the pipelines, which are attached to the group. Default: ``None``. 

504 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

505 :raises ValueError: If parameter 'sha' is ``None``. 

506 :raises TypeError: If parameter 'sha' is not of type :class:`str`. 

507 :raises ValueError: If parameter 'sha' is empty. 

508 :raises TypeError: If an element of parameter 'pipelines' is not of type :class:`Pipeline`. 

509 """ 

510 if sha is None: 

511 raise ValueError("Parameter 'sha' is None.") 

512 elif not isinstance(sha, str): 

513 ex = TypeError("Parameter 'sha' is not of type 'str'.") 

514 ex.add_note(f"Got type '{getFullyQualifiedName(sha)}'.") 

515 raise ex 

516 elif sha == "": 

517 raise ValueError("Parameter 'sha' is empty.") 

518 

519 super().__init__(sha, pipelines, keyValuePairs=keyValuePairs) 

520 

521 @readonly 

522 def SHA(self) -> str: 

523 """ 

524 Read-only property to access the commit every pipeline of the group was started on (:attr:`_name`). 

525 

526 :returns: The commit's hash. 

527 """ 

528 return self._name 

529 

530 @readonly 

531 def Conclusion(self) -> Nullable[Conclusion]: 

532 """ 

533 Read-only property to return how the commit's pipelines ended, taken together. 

534 

535 The worst conclusion wins, so one failed pipeline makes the commit's verdict a failure, as a branch protection 

536 rule would. The order is: 

537 

538 #. :attr:`~Conclusion.Failure` 

539 #. :attr:`~Conclusion.TimedOut` 

540 #. :attr:`~Conclusion.StartupFailure` 

541 #. :attr:`~Conclusion.ActionRequired` 

542 #. :attr:`~Conclusion.Cancelled` 

543 #. :attr:`~Conclusion.Stale` 

544 #. :attr:`~Conclusion.Neutral` 

545 #. :attr:`~Conclusion.Skipped` 

546 #. :attr:`~Conclusion.Success` 

547 

548 :returns: The worst conclusion of the group's pipelines, or ``None`` while one hasn't concluded. 

549 """ 

550 if len(self._pipelines) == 0: 550 ↛ 551line 550 didn't jump to line 551 because the condition on line 550 was never true

551 return None 

552 

553 conclusions = set() 

554 for pipeline in self._pipelines: 

555 if pipeline.Conclusion is None: 

556 return None 

557 

558 conclusions.add(pipeline.Conclusion) 

559 

560 for conclusion in ( 560 ↛ 567line 560 didn't jump to line 567 because the loop on line 560 didn't complete

561 Conclusion.Failure, Conclusion.TimedOut, Conclusion.StartupFailure, Conclusion.ActionRequired, 

562 Conclusion.Cancelled, Conclusion.Stale, Conclusion.Neutral, Conclusion.Skipped, Conclusion.Success 

563 ): 

564 if conclusion in conclusions: 

565 return conclusion 

566 

567 return None 

568 

569 def ByGitReference(self) -> dict[Nullable[str], list[Pipeline]]: 

570 """ 

571 Group the commit's pipelines by the branch or tag they were started on. 

572 

573 A commit pushed to a branch and later tagged has its pipelines under two keys - the branch's name and the 

574 tag's - which is what separates the checks of a commit from the run that published its release. 

575 

576 :returns: Dictionary of a reference's name to the pipelines started on it, in the order they were reported. 

577 """ 

578 byReference: dict[Nullable[str], list[Pipeline]] = {} 

579 for pipeline in self._pipelines: 

580 if (pipelines := byReference.get(pipeline.GitReference, None)) is None: 580 ↛ 584line 580 didn't jump to line 584 because the condition on line 580 was always true

581 pipelines = [] 

582 byReference[pipeline.GitReference] = pipelines 

583 

584 pipelines.append(pipeline) 

585 

586 return byReference 

587 

588 @classmethod 

589 def Parse( 

590 cls, 

591 runs: Union[JSONObject, Iterable[JSONObject]], 

592 jobs: Nullable[dict[int, Iterable[JSONObject]]] = None, 

593 sha: Nullable[str] = None 

594 ) -> Self: 

595 """ 

596 Build a group of pipelines from the JSON objects the GitHub REST API answers with. 

597 

598 :param runs: The runs, as returned by ``GET /repos/{owner}/{repo}/actions/runs?head_sha=...`` - either 

599 the answer itself or its ``workflow_runs`` array. 

600 :param jobs: Optional, the jobs of each run, by the run's identifier. Default: ``None``. 

601 :param sha: Optional, the commit. Default: the ``head_sha`` the runs report. 

602 :returns: The group, with its pipelines attached. 

603 :raises GitHubError: If the runs report different commits. 

604 :raises GitHubError: If no run reports a commit and parameter 'sha' wasn't given. 

605 """ 

606 if not isinstance(runs, dict): 

607 workflowRuns = runs 

608 elif (workflowRuns := runs.get("workflow_runs", None)) is None: 608 ↛ 609line 608 didn't jump to line 609 because the condition on line 608 was never true

609 workflowRuns = [] 

610 

611 pipelines = [] 

612 shas = set() 

613 for run in workflowRuns: 

614 runJobs = None 

615 if jobs is not None: 

616 runJobs = jobs.get(run.get("id", None), None) 

617 

618 pipeline = Pipeline.Parse(run, runJobs) 

619 pipelines.append(pipeline) 

620 if pipeline.SHA is not None: 

621 shas.add(pipeline.SHA) 

622 

623 if sha is None: 

624 if (commits := len(shas)) == 0: 

625 raise GitHubError("None of the runs reports a 'head_sha', and parameter 'sha' wasn't given.") 

626 elif commits > 1: 

627 error = GitHubError("The runs report different commits.") 

628 error.add_note(f"Got {', '.join(sorted(shas))}.") 

629 raise error 

630 

631 sha = shas.pop() 

632 

633 return cls(sha, pipelines) 

634 

635 

636@export 

637class Pipeline(CIPipeline, StatusMixin): 

638 """ 

639 A workflow run. 

640 

641 A run contains its own jobs, a :class:`~pyTooling.CI.Matrix` for every matrix, and a 

642 :class:`~pyTooling.CI.Workflow` for every workflow it called. Unlike a called workflow, a run reports its 

643 own status, conclusion and times. Several runs of one commit are held by a :class:`PipelineGroup`, which is the 

644 top of the tree. 

645 """ 

646 

647 _PARENT_TYPE: ClassVar[Nullable[type]] = PipelineGroup #: A workflow run is contained in a pipeline group. 

648 

649 _id: Nullable[int] #: GitHub's identifier of the run. 

650 _workflowID: Nullable[int] #: GitHub's identifier of the workflow the run belongs to. 

651 _path: Nullable[str] #: Path of the workflow's YAML file in the repository. 

652 _runNumber: Nullable[int] #: Number of the run within its workflow. 

653 _runAttempt: Nullable[int] #: Attempt of the run, starting at 1. 

654 _event: Nullable[Event] #: Event that triggered the run. 

655 _gitReference: Nullable[str] #: Branch or tag the run was started on. 

656 _sha: Nullable[str] #: Commit the run was started on. 

657 

658 def __init__( 

659 self, 

660 name: str, 

661 identifier: Nullable[int] = None, 

662 status: Nullable[Status] = None, 

663 conclusion: Nullable[Conclusion] = None, 

664 createdAt: Nullable[datetime] = None, 

665 startedAt: Nullable[datetime] = None, 

666 completedAt: Nullable[datetime] = None, 

667 url: Nullable[URL] = None, 

668 workflowID: Nullable[int] = None, 

669 path: Nullable[str] = None, 

670 runNumber: Nullable[int] = None, 

671 runAttempt: Nullable[int] = None, 

672 event: Nullable[Event] = None, 

673 gitReference: Nullable[str] = None, 

674 sha: Nullable[str] = None, 

675 *, 

676 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

677 parent: Nullable[PipelineGroup] = None 

678 ) -> None: 

679 """ 

680 Initializes a workflow run. 

681 

682 :param name: Name of the workflow. 

683 :param identifier: Optional, GitHub's identifier of the run. Default: ``None``. 

684 :param status: Optional, state the run is in. Default: ``None``. 

685 :param conclusion: Optional, how the run ended. Default: ``None``. 

686 :param createdAt: Optional, time the run was created. Default: ``None``. 

687 :param startedAt: Optional, time the run started. Default: ``None``. 

688 :param completedAt: Optional, time the run was last updated, once completed. Default: ``None``. 

689 :param url: Optional, URL of the run on github.com. Default: ``None``. 

690 :param workflowID: Optional, GitHub's identifier of the workflow the run belongs to. Default: ``None``. 

691 :param path: Optional, path of the workflow's YAML file in the repository. Default: ``None``. 

692 :param runNumber: Optional, number of the run within its workflow. Default: ``None``. 

693 :param runAttempt: Optional, attempt of the run, starting at 1. Default: ``None``. 

694 :param event: Optional, event that triggered the run. Default: ``None``. 

695 :param gitReference: Optional, branch or tag the run was started on. Default: ``None``. 

696 :param sha: Optional, commit the run was started on. Default: ``None``. 

697 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

698 :param parent: Optional, reference to the group of the commit's runs. Default: ``None``. 

699 :raises TypeError: If parameter 'identifier' is not of type :class:`int`. 

700 :raises TypeError: If parameter 'workflowID' is not of type :class:`int`. 

701 :raises TypeError: If parameter 'path' is not of type :class:`str`. 

702 :raises TypeError: If parameter 'runNumber' is not of type :class:`int`. 

703 :raises TypeError: If parameter 'runAttempt' is not of type :class:`int`. 

704 :raises TypeError: If parameter 'event' is not of type :class:`Event`. 

705 :raises TypeError: If parameter 'gitReference' is not of type :class:`str`. 

706 :raises TypeError: If parameter 'sha' is not of type :class:`str`. 

707 """ 

708 for parameterName, number in ( 

709 ("identifier", identifier), ("workflowID", workflowID), ("runNumber", runNumber), ("runAttempt", runAttempt) 

710 ): 

711 if number is not None and not isinstance(number, int): 

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

713 ex.add_note(f"Got type '{getFullyQualifiedName(number)}'.") 

714 raise ex 

715 

716 if event is not None and not isinstance(event, Event): 

717 ex = TypeError("Parameter 'event' is not of type 'Event'.") 

718 ex.add_note(f"Got type '{getFullyQualifiedName(event)}'.") 

719 raise ex 

720 

721 for parameterName, text in (("path", path), ("gitReference", gitReference), ("sha", sha)): 

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

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

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

725 raise ex 

726 

727 super().__init__( 

728 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs, 

729 parent=parent 

730 ) 

731 StatusMixin.__init__(self, status, conclusion, url) 

732 

733 self._id = identifier 

734 self._workflowID = workflowID 

735 self._path = path 

736 self._runNumber = runNumber 

737 self._runAttempt = runAttempt 

738 self._event = event 

739 self._gitReference = gitReference 

740 self._sha = sha 

741 

742 @readonly 

743 def ID(self) -> Nullable[int]: 

744 """ 

745 Read-only property to access GitHub's identifier of the run (:attr:`_id`). 

746 

747 :returns: The identifier, or ``None`` if GitHub reported none. 

748 """ 

749 return self._id 

750 

751 @readonly 

752 def WorkflowID(self) -> Nullable[int]: 

753 """ 

754 Read-only property to access GitHub's identifier of the workflow the run belongs to (:attr:`_workflowID`). 

755 

756 Every run of the same workflow file reports the same identifier, so it groups a workflow's runs over time, 

757 where :attr:`ID` identifies the single run. 

758 

759 :returns: The identifier, or ``None`` if GitHub reported none. 

760 """ 

761 return self._workflowID 

762 

763 @readonly 

764 def Path(self) -> Nullable[str]: 

765 """ 

766 Read-only property to access the path of the workflow's YAML file (:attr:`_path`). 

767 

768 GitHub reports it relative to the repository's root, e.g. ``.github/workflows/Pipeline.yml``. 

769 

770 :returns: The path, or ``None`` if GitHub reported none. 

771 """ 

772 return self._path 

773 

774 @readonly 

775 def RunNumber(self) -> Nullable[int]: 

776 """ 

777 Read-only property to access the run's number within its workflow (:attr:`_runNumber`). 

778 

779 :returns: The number, or ``None`` if GitHub reported none. 

780 """ 

781 return self._runNumber 

782 

783 @readonly 

784 def RunAttempt(self) -> Nullable[int]: 

785 """ 

786 Read-only property to access which attempt of the run this is (:attr:`_runAttempt`). 

787 

788 :returns: The attempt, starting at 1, or ``None`` if GitHub reported none. 

789 """ 

790 return self._runAttempt 

791 

792 @readonly 

793 def Event(self) -> Nullable[Event]: 

794 """ 

795 Read-only property to access the event that triggered the run (:attr:`_event`). 

796 

797 :returns: The event, or ``None`` if GitHub reported none. 

798 """ 

799 return self._event 

800 

801 @readonly 

802 def GitReference(self) -> Nullable[str]: 

803 """ 

804 Read-only property to access the branch or tag the run was started on (:attr:`_gitReference`). 

805 

806 GitHub reports this as ``head_branch`` and puts the **tag's** name there for a run started at a tag, with no 

807 field saying which it is - a run of `pyTooling/MiKTeX` v1.6.0 reports ``main``, and the run at the tag of the 

808 very same commit reports ``v1.6.0``. :attr:`Event` is the other half of telling them apart. 

809 

810 :returns: The branch's or tag's name, or ``None`` if GitHub reported none. 

811 """ 

812 return self._gitReference 

813 

814 @readonly 

815 def SHA(self) -> Nullable[str]: 

816 """ 

817 Read-only property to access the commit the run was started on (:attr:`_sha`). 

818 

819 :returns: The commit's hash, or ``None`` if GitHub reported none. 

820 """ 

821 return self._sha 

822 

823 @classmethod 

824 def Parse( 

825 cls, 

826 run: JSONObject, 

827 jobs: Nullable[Iterable[JSONObject]] = None, 

828 *, 

829 parent: Nullable[PipelineGroup] = None 

830 ) -> Self: 

831 """ 

832 Build a workflow run and its tree from the JSON objects the GitHub REST API answers with. 

833 

834 A job's name says where it sits, and is read back into the tree: ``Docs / Sphinx / HTML`` nests below a 

835 :class:`~pyTooling.CI.Workflow` per prefix, and ``Unit Tests (ubuntu-26.04, 3.14)`` becomes a 

836 :class:`MatrixJob` below a :class:`~pyTooling.CI.Matrix` named ``Unit Tests``. A prefix carrying 

837 dimension values - ``Tests (3.14) / Unit``, a matrix of calls of a reusable workflow - becomes a 

838 :class:`~pyTooling.CI.MatrixWorkflow` below a :class:`~pyTooling.CI.Matrix` named ``Tests``. 

839 

840 :param run: The workflow run, as returned by ``GET /repos/{owner}/{repo}/actions/runs/{run_id}``. 

841 :param jobs: Optional, the run's jobs, as listed by ``GET .../actions/runs/{run_id}/jobs``. 

842 :param parent: Optional, reference to the group of the commit's runs. Default: ``None``. 

843 :returns: The workflow run, with its workflows, matrices, jobs and steps attached. 

844 :raises TypeError: If parameter 'run' is not of type :class:`dict`. 

845 :raises TypeError: If an element of parameter 'jobs' is not of type :class:`dict`. 

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

847 :raises GitHubError: If a field holds a value GitHub doesn't document. 

848 """ 

849 if not isinstance(run, dict): 

850 ex = TypeError("Parameter 'run' is not of type 'dict'.") 

851 ex.add_note(f"Got type '{getFullyQualifiedName(run)}'.") 

852 raise ex 

853 

854 if (name := run.get("name", None)) is None: 

855 raise GitHubError("Field 'run.name' is missing.") 

856 

857 identifier = run.get("id", None) 

858 status = Status.Parse(run.get("status", None)) 

859 conclusion = Conclusion.Parse(run.get("conclusion", None)) 

860 createdAt = _parseISO8601Timestamp(run.get("created_at", None), "run.created_at") 

861 startedAt = _parseISO8601Timestamp(run.get("run_started_at", None), "run.run_started_at") 

862 completedAt = _parseISO8601Timestamp(run.get("updated_at", None), "run.updated_at") \ 

863 if status is Status.Completed else None 

864 url = _parseURL(run.get("html_url", None), "run.html_url") 

865 workflowID = run.get("workflow_id", None) 

866 path = run.get("path", None) 

867 runNumber = run.get("run_number", None) 

868 runAttempt = run.get("run_attempt", None) 

869 event = Event.Parse(run.get("event", None)) 

870 gitReference = run.get("head_branch", None) 

871 sha = run.get("head_sha", None) 

872 

873 pipeline = cls( 

874 name, identifier, status, conclusion, createdAt, startedAt, completedAt, url, workflowID, path, runNumber, 

875 runAttempt, event, gitReference, sha, parent=parent 

876 ) 

877 

878 if jobs is None: 

879 return pipeline 

880 

881 for position, job in enumerate(jobs): 

882 if not isinstance(job, dict): 

883 ex = TypeError(f"Job {position} is not of type 'dict'.") 

884 ex.add_note(f"Got type '{getFullyQualifiedName(job)}'.") 

885 raise ex 

886 

887 path = f"jobs[{position}]" 

888 if (fullName := job.get("name", None)) is None: 

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

890 

891 *callers, leaf = fullName.split(" / ") 

892 

893 group: CIWorkflow = pipeline 

894 for caller in callers: 

895 if (calledWorkflow := group.Workflows.get(caller, None)) is None: 

896 callerName, callerDimensions = _splitMatrixJobName(caller) 

897 if callerDimensions is None: 

898 calledWorkflow = CIWorkflow(caller, parent=group) 

899 else: 

900 if (matrix := group.Matrices.get(callerName, None)) is None: 

901 matrix = CIMatrix(callerName, parent=group) 

902 

903 if matrix.ContainsElement(caller): 

904 calledWorkflow = matrix.GetElement(caller) 

905 else: 

906 calledWorkflow = CIMatrixWorkflow(callerName, callerDimensions, parent=matrix) 

907 

908 group = calledWorkflow 

909 

910 jobName, dimensions = _splitMatrixJobName(leaf) 

911 if dimensions is None: 

912 Job.Parse(job, path, parent=group) 

913 else: 

914 if (matrix := group.Matrices.get(jobName, None)) is None: 

915 matrix = CIMatrix(jobName, parent=group) 

916 

917 MatrixJob.Parse(job, path, jobName, dimensions, parent=matrix) 

918 

919 return pipeline 

920 

921 

922@export 

923class Job(CIJob, StatusMixin): 

924 """A job of a workflow run, which ran on a runner and contains steps.""" 

925 

926 _id: Nullable[int] #: GitHub's identifier of the job. 

927 _labels: list[str] #: Labels the job requested its runner by, i.e. its ``runs-on``. 

928 _runnerName: Nullable[str] #: Name of the runner the job ran on. 

929 _runnerGroupName: Nullable[str] #: Name of the runner group the runner belongs to. 

930 

931 def __init__( 

932 self, 

933 name: str, 

934 identifier: Nullable[int] = None, 

935 status: Nullable[Status] = None, 

936 conclusion: Nullable[Conclusion] = None, 

937 createdAt: Nullable[datetime] = None, 

938 startedAt: Nullable[datetime] = None, 

939 completedAt: Nullable[datetime] = None, 

940 url: Nullable[URL] = None, 

941 labels: Nullable[Iterable[str]] = None, 

942 runnerName: Nullable[str] = None, 

943 runnerGroupName: Nullable[str] = None, 

944 steps: Nullable[Iterable[Step]] = None, 

945 *, 

946 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

947 parent: Nullable[CIJobGroup] = None 

948 ) -> None: 

949 """ 

950 Initializes a job of a workflow run. 

951 

952 :param name: Name of the job, without the calling workflows' prefixes. 

953 :param identifier: Optional, GitHub's identifier of the job. Default: ``None``. 

954 :param status: Optional, state the job is in. Default: ``None``. 

955 :param conclusion: Optional, how the job ended. Default: ``None``. 

956 :param createdAt: Optional, time the job was created, i.e. queued for a runner. Default: ``None``. 

957 :param startedAt: Optional, time the job started running on a runner. Default: ``None``. 

958 :param completedAt: Optional, time the job completed. Default: ``None``. 

959 :param url: Optional, URL of the job on github.com. Default: ``None``. 

960 :param labels: Optional, labels the job requested its runner by. Default: ``None``. 

961 :param runnerName: Optional, name of the runner the job ran on. Default: ``None``. 

962 :param runnerGroupName: Optional, name of the runner group the runner belongs to. Default: ``None``. 

963 :param steps: Optional, the job's steps, which are attached to it. Default: ``None``. 

964 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

965 :param parent: Optional, reference to the group containing the job. Default: ``None``. 

966 :raises TypeError: If parameter 'identifier' is not of type :class:`int`. 

967 :raises TypeError: If an element of parameter 'labels' is not of type :class:`str`. 

968 :raises TypeError: If parameter 'runnerName' is not of type :class:`str`. 

969 :raises TypeError: If parameter 'runnerGroupName' is not of type :class:`str`. 

970 :raises TypeError: If an element of parameter 'steps' is not of type :class:`Step`. 

971 """ 

972 if identifier is not None and not isinstance(identifier, int): 

973 ex = TypeError("Parameter 'identifier' is not of type 'int'.") 

974 ex.add_note(f"Got type '{getFullyQualifiedName(identifier)}'.") 

975 raise ex 

976 

977 for parameterName, value in (("runnerName", runnerName), ("runnerGroupName", runnerGroupName)): 

978 if value is not None and not isinstance(value, str): 

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

980 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.") 

981 raise ex 

982 

983 self._labels = labelList = [] 

984 if labels is not None: 

985 for label in labels: 

986 if not isinstance(label, str): 

987 ex = TypeError("An element of parameter 'labels' is not of type 'str'.") 

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

989 raise ex 

990 

991 labelList.append(label) 

992 

993 stepList = [] 

994 if steps is not None: 

995 for step in steps: 

996 if not isinstance(step, Step): 

997 ex = TypeError("An element of parameter 'steps' is not of type 'Step'.") 

998 ex.add_note(f"Got type '{getFullyQualifiedName(step)}'.") 

999 raise ex 

1000 

1001 stepList.append(step) 

1002 

1003 super().__init__( 

1004 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs, 

1005 parent=parent 

1006 ) 

1007 StatusMixin.__init__(self, status, conclusion, url) 

1008 

1009 self._id = identifier 

1010 self._runnerName = runnerName 

1011 self._runnerGroupName = runnerGroupName 

1012 

1013 for step in stepList: 

1014 step._parent = self 

1015 step._pipeline = self._pipeline 

1016 self._steps.append(step) 

1017 

1018 @readonly 

1019 def ID(self) -> Nullable[int]: 

1020 """ 

1021 Read-only property to access GitHub's identifier of the job (:attr:`_id`). 

1022 

1023 :returns: The identifier, or ``None`` if GitHub reported none. 

1024 """ 

1025 return self._id 

1026 

1027 @readonly 

1028 def Labels(self) -> list[str]: 

1029 """ 

1030 Read-only property to access the labels the job requested its runner by (:attr:`_labels`). 

1031 

1032 These are the values of the workflow's ``runs-on``. For a hosted runner a label doubles as the image's name 

1033 (``ubuntu-26.04``); for a self-hosted runner they are the tags the runner was registered with. 

1034 

1035 :returns: The labels, empty if GitHub reported none. 

1036 """ 

1037 return self._labels 

1038 

1039 @readonly 

1040 def RunnerName(self) -> Nullable[str]: 

1041 """ 

1042 Read-only property to access the name of the runner the job ran on (:attr:`_runnerName`). 

1043 

1044 :returns: The runner's name, or ``None`` while the job hasn't started. 

1045 """ 

1046 return self._runnerName 

1047 

1048 @readonly 

1049 def RunnerGroupName(self) -> Nullable[str]: 

1050 """ 

1051 Read-only property to access the name of the runner group the runner belongs to (:attr:`_runnerGroupName`). 

1052 

1053 :returns: The group's name, or ``None`` if GitHub reported none. 

1054 """ 

1055 return self._runnerGroupName 

1056 

1057 @readonly 

1058 def CreatedAt(self) -> Nullable[datetime]: 

1059 """ 

1060 Read-only property to return the time the job was created, at the latest when it started. 

1061 

1062 :returns: The time, or ``None`` if GitHub reported none. 

1063 """ 

1064 if self._createdAt is None or all(step.StartedAt is None for step in self._steps): 

1065 return self._createdAt 

1066 

1067 return min(self._createdAt, self.StartedAt) 

1068 

1069 @readonly 

1070 def StartedAt(self) -> Nullable[datetime]: 

1071 """ 

1072 Read-only property to return the time the job started, at the latest when its first step started. 

1073 

1074 GitHub reports a job's times and its steps' times independently and in whole seconds, so a step is sometimes 

1075 reported as starting before the job containing it. The step really did run then, so the job is the timespan 

1076 that stretches - otherwise a consumer building a tree has a child outside its parent. 

1077 

1078 :returns: The time, or ``None`` while neither the job nor a step of it has started. 

1079 """ 

1080 times = [step.StartedAt for step in self._steps if step.StartedAt is not None] 

1081 if len(times) == 0: 

1082 return self._startedAt 

1083 

1084 first = min(times) 

1085 

1086 return first if self._startedAt is None else min(self._startedAt, first) 

1087 

1088 @readonly 

1089 def CompletedAt(self) -> Nullable[datetime]: 

1090 """ 

1091 Read-only property to return the time the job completed, at the earliest when its last step completed. 

1092 

1093 A job that hasn't completed reports no time, even when a step of it has - see :attr:`StartedAt` for why the 

1094 job is the timespan that stretches. 

1095 

1096 :returns: The time, or ``None`` while the job hasn't completed. 

1097 """ 

1098 if self._completedAt is None or all(step.StartedAt is None for step in self._steps): 

1099 return self._completedAt 

1100 

1101 times = [step.CompletedAt for step in self._steps if step.CompletedAt is not None] 

1102 if len(times) == 0: 1102 ↛ 1103line 1102 didn't jump to line 1103 because the condition on line 1102 was never true

1103 return self._completedAt 

1104 

1105 return max(self._completedAt, max(times)) 

1106 

1107 @readonly 

1108 def QueuedDuration(self) -> Nullable[float]: 

1109 """ 

1110 Read-only property to return how long the job waited for a runner. 

1111 

1112 :returns: Seconds from being created to starting, or ``None`` while either time is unknown. 

1113 """ 

1114 if self._createdAt is None or self._startedAt is None: 

1115 return None 

1116 

1117 return (self._startedAt - self._createdAt).total_seconds() 

1118 

1119 @classmethod 

1120 def Parse(cls, json: JSONObject, path: str = "job", *, parent: Nullable[CIJobGroup] = None) -> Self: 

1121 """ 

1122 Build a job and its steps from the JSON object the GitHub REST API answers with. 

1123 

1124 The job's name is taken without the calling workflows' prefixes: a job reported as ``Caller / Build`` is named 

1125 ``Build``, because the prefix describes where it sits, which the tree already says. 

1126 

1127 :param json: The job, as listed by ``GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs``. 

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

1129 :param parent: Optional, reference to the group containing the job. Default: ``None``. 

1130 :returns: The job, with its steps attached. 

1131 :raises TypeError: If parameter 'json' is not of type :class:`dict`. 

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

1133 :raises GitHubError: If a field holds a value GitHub doesn't document. 

1134 """ 

1135 if not isinstance(json, dict): 

1136 ex = TypeError("Parameter 'json' is not of type 'dict'.") 

1137 ex.add_note(f"Got type '{getFullyQualifiedName(json)}'.") 

1138 raise ex 

1139 

1140 if (fullName := json.get("name", None)) is None: 1140 ↛ 1141line 1140 didn't jump to line 1141 because the condition on line 1140 was never true

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

1142 

1143 name = fullName.rsplit(" / ", 1)[-1] 

1144 identifier = json.get("id", None) 

1145 status = Status.Parse(json.get("status", None)) 

1146 conclusion = Conclusion.Parse(json.get("conclusion", None)) 

1147 createdAt = _parseISO8601Timestamp(json.get("created_at", None), f"{path}.created_at") 

1148 startedAt = _parseISO8601Timestamp(json.get("started_at", None), f"{path}.started_at") 

1149 completedAt = _parseISO8601Timestamp(json.get("completed_at", None), f"{path}.completed_at") 

1150 url = _parseURL(json.get("html_url", None), f"{path}.html_url") 

1151 labels = json.get("labels", None) 

1152 runnerName = json.get("runner_name", None) 

1153 runnerGroupName = json.get("runner_group_name", None) 

1154 

1155 if (jsonSteps := json.get("steps", None)) is None: 1155 ↛ 1156line 1155 didn't jump to line 1156 because the condition on line 1155 was never true

1156 steps = None 

1157 else: 

1158 steps = [Step.Parse(step, f"{path}.steps[{pos}]") for pos, step in enumerate(jsonSteps)] 

1159 

1160 return cls( 

1161 name, identifier, status, conclusion, createdAt, startedAt, completedAt, url, labels, runnerName, 

1162 runnerGroupName, steps, parent=parent 

1163 ) 

1164 

1165 

1166@export 

1167class MatrixJob(Job, MatrixInstanceMixin): 

1168 """ 

1169 One instance of a job produced by a matrix. 

1170 

1171 GitHub reports a matrix instance as an ordinary job whose name carries the dimensions' values in brackets, e.g. 

1172 ``Unit Tests (ubuntu-26.04, 3.14)``. :meth:`Pipeline.Parse` reads those back into :attr:`Dimensions` and groups 

1173 the instances below a :class:`~pyTooling.CI.Matrix`. The values are in the order GitHub prints them. The 

1174 job's payload names no dimension - only the workflow file does -, so a dimension's name is the position of its 

1175 value, ``{"0": "ubuntu-26.04", "1": "3.14"}``, until the names are known. 

1176 """ 

1177 

1178 _PARENT_TYPE: ClassVar[Nullable[type]] = CIMatrix #: A matrix instance is contained in a matrix. 

1179 

1180 def __init__( 

1181 self, 

1182 name: str, 

1183 dimensions: Nullable[Mapping[str, Any]] = None, 

1184 identifier: Nullable[int] = None, 

1185 status: Nullable[Status] = None, 

1186 conclusion: Nullable[Conclusion] = None, 

1187 createdAt: Nullable[datetime] = None, 

1188 startedAt: Nullable[datetime] = None, 

1189 completedAt: Nullable[datetime] = None, 

1190 url: Nullable[URL] = None, 

1191 labels: Nullable[Iterable[str]] = None, 

1192 runnerName: Nullable[str] = None, 

1193 runnerGroupName: Nullable[str] = None, 

1194 steps: Nullable[Iterable[Step]] = None, 

1195 *, 

1196 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

1197 parent: Nullable[CIMatrix] = None 

1198 ) -> None: 

1199 """ 

1200 Initializes one instance of a job produced by a matrix. 

1201 

1202 :param name: Name of the job, without the dimensions' values. 

1203 :param dimensions: Optional, the dimensions' names and values this instance ran with. Default: ``None``. 

1204 :param identifier: Optional, GitHub's identifier of the job. Default: ``None``. 

1205 :param status: Optional, state the job is in. Default: ``None``. 

1206 :param conclusion: Optional, how the job ended. Default: ``None``. 

1207 :param createdAt: Optional, time the job was created, i.e. queued for a runner. Default: ``None``. 

1208 :param startedAt: Optional, time the job started running on a runner. Default: ``None``. 

1209 :param completedAt: Optional, time the job completed. Default: ``None``. 

1210 :param url: Optional, URL of the job on github.com. Default: ``None``. 

1211 :param labels: Optional, labels the job requested its runner by. Default: ``None``. 

1212 :param runnerName: Optional, name of the runner the job ran on. Default: ``None``. 

1213 :param runnerGroupName: Optional, name of the runner group the runner belongs to. Default: ``None``. 

1214 :param steps: Optional, the job's steps, which are attached to it. Default: ``None``. 

1215 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

1216 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``. 

1217 """ 

1218 super().__init__( 

1219 name, identifier, status, conclusion, createdAt, startedAt, completedAt, url, labels, runnerName, 

1220 runnerGroupName, steps, keyValuePairs=keyValuePairs, parent=parent 

1221 ) 

1222 MatrixInstanceMixin.__init__(self, dimensions) 

1223 

1224 def __str__(self) -> str: 

1225 """ 

1226 Return a string representation of the matrix instance. 

1227 

1228 :returns: The job's name with its dimensions' values, as GitHub prints it. 

1229 """ 

1230 if len(self._dimensions) == 0: 1230 ↛ 1231line 1230 didn't jump to line 1231 because the condition on line 1230 was never true

1231 return self._name 

1232 

1233 return f"{self._name} ({', '.join(str(value) for value in self._dimensions.values())})" 

1234 

1235 @classmethod 

1236 def Parse( 

1237 cls, 

1238 json: JSONObject, 

1239 path: str = "job", 

1240 name: Nullable[str] = None, 

1241 dimensions: Nullable[Mapping[str, Any]] = None, 

1242 *, 

1243 parent: Nullable[CIMatrix] = None 

1244 ) -> Self: 

1245 """ 

1246 Build a matrix instance and its steps from the JSON object the GitHub REST API answers with. 

1247 

1248 :param json: The job, as listed by ``GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs``. 

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

1250 :param name: Optional, the job's name without the dimensions' values. Default: read from the payload. 

1251 :param dimensions: Optional, the dimensions' names and values. Default: read from the payload. 

1252 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``. 

1253 :returns: The matrix instance, with its steps attached. 

1254 :raises TypeError: If parameter 'json' is not of type :class:`dict`. 

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

1256 :raises GitHubError: If a field holds a value GitHub doesn't document. 

1257 """ 

1258 if not isinstance(json, dict): 

1259 ex = TypeError("Parameter 'json' is not of type 'dict'.") 

1260 ex.add_note(f"Got type '{getFullyQualifiedName(json)}'.") 

1261 raise ex 

1262 

1263 if (fullName := json.get("name", None)) is None: 1263 ↛ 1264line 1263 didn't jump to line 1264 because the condition on line 1263 was never true

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

1265 

1266 if name is None: 1266 ↛ 1267line 1266 didn't jump to line 1267 because the condition on line 1266 was never true

1267 name, dimensions = _splitMatrixJobName(fullName.rsplit(" / ", 1)[-1]) 

1268 

1269 identifier = json.get("id", None) 

1270 status = Status.Parse(json.get("status", None)) 

1271 conclusion = Conclusion.Parse(json.get("conclusion", None)) 

1272 createdAt = _parseISO8601Timestamp(json.get("created_at", None), f"{path}.created_at") 

1273 startedAt = _parseISO8601Timestamp(json.get("started_at", None), f"{path}.started_at") 

1274 completedAt = _parseISO8601Timestamp(json.get("completed_at", None), f"{path}.completed_at") 

1275 url = _parseURL(json.get("html_url", None), f"{path}.html_url") 

1276 labels = json.get("labels", None) 

1277 runnerName = json.get("runner_name", None) 

1278 runnerGroupName = json.get("runner_group_name", None) 

1279 

1280 if (jsonSteps := json.get("steps", None)) is None: 1280 ↛ 1281line 1280 didn't jump to line 1281 because the condition on line 1280 was never true

1281 steps = None 

1282 else: 

1283 steps = [Step.Parse(step, f"{path}.steps[{pos}]") for pos, step in enumerate(jsonSteps)] 

1284 

1285 return cls( 

1286 name, dimensions, identifier, status, conclusion, createdAt, startedAt, completedAt, url, labels, 

1287 runnerName, runnerGroupName, steps, parent=parent 

1288 ) 

1289 

1290 

1291@export 

1292class Step(CIStep, StatusMixin): 

1293 """A step within a job.""" 

1294 

1295 _PARENT_TYPE: ClassVar[Nullable[type]] = Job #: A step is contained in a job of a workflow run. 

1296 

1297 _number: Nullable[int] #: Position of the step within its job, starting at 1. 

1298 

1299 def __init__( 

1300 self, 

1301 name: str, 

1302 number: Nullable[int] = None, 

1303 status: Nullable[Status] = None, 

1304 conclusion: Nullable[Conclusion] = None, 

1305 startedAt: Nullable[datetime] = None, 

1306 completedAt: Nullable[datetime] = None, 

1307 *, 

1308 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

1309 parent: Nullable[Job] = None 

1310 ) -> None: 

1311 """ 

1312 Initializes a step within a job. 

1313 

1314 :param name: Name of the step. 

1315 :param number: Optional, position of the step within its job, starting at 1. Default: ``None``. 

1316 :param status: Optional, state the step is in. Default: ``None``. 

1317 :param conclusion: Optional, how the step ended. Default: ``None``. 

1318 :param startedAt: Optional, time the step started running. Default: ``None``. 

1319 :param completedAt: Optional, time the step completed. Default: ``None``. 

1320 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

1321 :param parent: Optional, reference to the job containing the step. Default: ``None``. 

1322 :raises TypeError: If parameter 'number' is not of type :class:`int`. 

1323 :raises ValueError: If parameter 'number' is not positive. 

1324 """ 

1325 if number is not None and not isinstance(number, int): 

1326 ex = TypeError("Parameter 'number' is not of type 'int'.") 

1327 ex.add_note(f"Got type '{getFullyQualifiedName(number)}'.") 

1328 raise ex 

1329 elif number is not None and number < 1: 

1330 ex = ValueError("Parameter 'number' is not positive.") 

1331 ex.add_note(f"Got value '{number}'.") 

1332 raise ex 

1333 

1334 super().__init__( 

1335 name, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs, parent=parent 

1336 ) 

1337 StatusMixin.__init__(self, status, conclusion) 

1338 

1339 self._number = number 

1340 

1341 @readonly 

1342 def Number(self) -> Nullable[int]: 

1343 """ 

1344 Read-only property to access the step's position within its job (:attr:`_number`). 

1345 

1346 :returns: The position, starting at 1, or ``None`` if GitHub reported none. 

1347 """ 

1348 return self._number 

1349 

1350 @classmethod 

1351 def Parse(cls, json: JSONObject, path: str = "step", *, parent: Nullable[Job] = None) -> Self: 

1352 """ 

1353 Build a step from the JSON object the GitHub REST API answers with. 

1354 

1355 :param json: The step, as it appears in a job's ``steps`` array. 

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

1357 :param parent: Optional, reference to the job containing the step. Default: ``None``. 

1358 :returns: The step. 

1359 :raises TypeError: If parameter 'json' is not of type :class:`dict`. 

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

1361 :raises GitHubError: If a field holds a value GitHub doesn't document. 

1362 """ 

1363 if not isinstance(json, dict): 

1364 ex = TypeError("Parameter 'json' is not of type 'dict'.") 

1365 ex.add_note(f"Got type '{getFullyQualifiedName(json)}'.") 

1366 raise ex 

1367 

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

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

1370 

1371 number = json.get("number", None) 

1372 status = Status.Parse(json.get("status", None)) 

1373 conclusion = Conclusion.Parse(json.get("conclusion", None)) 

1374 startedAt = _parseISO8601Timestamp(json.get("started_at", None), f"{path}.started_at") 

1375 completedAt = _parseISO8601Timestamp(json.get("completed_at", None), f"{path}.completed_at") 

1376 

1377 return cls(name, number, status, conclusion, startedAt, completedAt, parent=parent)