Coverage for pyTooling/CI/GitHub/__init__.py: 96%

489 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-03 23:02 +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.. code-block:: text 

38 

39 PipelineGroup every run started for one commit 

40 +-- Pipeline a workflow run 

41 +-- Workflow a called (reusable) workflow, grouping the jobs it contains 

42 | +-- Workflow a workflow called by that workflow 

43 | +-- Matrix a matrix, grouping the job instances it produced 

44 | | +-- MatrixJob 

45 | +-- Job 

46 +-- Matrix 

47 +-- Job a job that ran on a runner 

48 +-- 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 

61from datetime import datetime, timezone 

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

63 

64from pyTooling.CI import CIError, JSONObject, MatrixInstanceMixin, Outcome 

65from pyTooling.CI import Job as CIJob, JobGroup as CIJobGroup, Matrix as CIMatrix 

66from pyTooling.CI import MatrixWorkflow as CIMatrixWorkflow, Pipeline as CIPipeline 

67from pyTooling.CI import PipelineGroup as CIPipelineGroup, Step as CIStep, Workflow as CIWorkflow 

68from pyTooling.Common import getFullyQualifiedName, parseISO8601Timestamp, StringEnum 

69from pyTooling.Decorators import export, readonly 

70from pyTooling.GenericPath.URL import URL 

71from pyTooling.MetaClasses import ExtendedType 

72 

73 

74__all__ = ["CONCLUSION_TO_OUTCOME"] 

75 

76 

77@export 

78class GitHubError(CIError): 

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

80 

81 

82@export 

83class Status(StringEnum): 

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

85 

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

87 InProgress = "in_progress" #: Running. 

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

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

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

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

92 

93 @classmethod 

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

95 """ 

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

97 

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

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

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

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

102 The note lists the documented values. 

103 """ 

104 try: 

105 return super().Parse(value) 

106 except ValueError as ex: 

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

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

109 raise error from ex 

110 

111 

112@export 

113class Conclusion(StringEnum): 

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

115 

116 Success = "success" #: Succeeded. 

117 Failure = "failure" #: Failed. 

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

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

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

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

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

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

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

125 

126 @classmethod 

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

128 """ 

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

130 

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

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

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

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

135 The note lists the documented values. 

136 """ 

137 try: 

138 return super().Parse(value) 

139 except ValueError as ex: 

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

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

142 raise error from ex 

143 

144 def ToOutcome(self) -> Outcome: 

145 """ 

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

147 

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

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

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

151 

152 :returns: The outcome. 

153 """ 

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

155 

156 

157CONCLUSION_TO_OUTCOME = { 

158 Conclusion.Success: Outcome.Success, 

159 Conclusion.Failure: Outcome.Failure, 

160 Conclusion.TimedOut: Outcome.Timeout, 

161 Conclusion.Skipped: Outcome.Skip, 

162 Conclusion.Cancelled: Outcome.Cancellation, 

163} 

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

165 

166 

167@export 

168class Event(StringEnum): 

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

170 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

204 

205 @classmethod 

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

207 """ 

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

209 

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

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

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

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

214 The note lists the documented values. 

215 """ 

216 try: 

217 return super().Parse(value) 

218 except ValueError as ex: 

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

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

221 raise error from ex 

222 

223 

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

225 """ 

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

227 

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

229 

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

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

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

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

234 The note reports the value that was read. 

235 """ 

236 try: 

237 return parseISO8601Timestamp(value, timezone.utc) 

238 except ValueError as ex: 

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

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

241 raise error from ex 

242 

243 

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

245 """ 

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

247 

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

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

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

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

252 """ 

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

254 return None 

255 

256 return URL.Parse(value) 

257 

258 

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

260 """ 

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

262 

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

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

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

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

267 

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

269 

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

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

272 """ 

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

274 return name, None 

275 

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

277 base = base.rstrip() 

278 if base == "": 

279 return name, None 

280 

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

282 

283 

284@export 

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

286 """ 

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

288 

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

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

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

292 """ 

293 

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

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

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

297 

298 def __init__( 

299 self, 

300 status: Nullable[Status] = None, 

301 conclusion: Nullable[Conclusion] = None, 

302 url: Nullable[URL] = None 

303 ) -> None: 

304 """ 

305 Initializes what GitHub reports about an element. 

306 

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

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

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

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

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

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

313 """ 

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

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

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

317 raise ex 

318 

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

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

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

322 raise ex 

323 

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

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

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

327 raise ex 

328 

329 self._status = status 

330 self._conclusion = conclusion 

331 self._url = url 

332 if conclusion is not None: 

333 self._outcome = conclusion.ToOutcome() 

334 

335 @readonly 

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

337 """ 

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

339 

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

341 """ 

342 return self._status 

343 

344 @readonly 

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

346 """ 

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

348 

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

350 :meth:`Conclusion.ToOutcome`. 

351 

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

353 """ 

354 return self._conclusion 

355 

356 @readonly 

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

358 """ 

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

360 

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

362 """ 

363 return self._url 

364 

365 

366@export 

367class PipelineGroup(CIPipelineGroup): 

368 """ 

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

370 

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

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

373 

374 **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 

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

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

377 were 

378 

379 .. code-block:: text 

380 

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

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

383 

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

385 

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

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

388 """ 

389 

390 def __init__( 

391 self, 

392 sha: str, 

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

394 *, 

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

396 ) -> None: 

397 """ 

398 Initializes a group of pipelines started for one commit. 

399 

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

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

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

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

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

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

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

407 """ 

408 if sha is None: 

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

410 elif not isinstance(sha, str): 

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

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

413 raise ex 

414 elif sha == "": 

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

416 

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

418 

419 @readonly 

420 def SHA(self) -> str: 

421 """ 

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

423 

424 :returns: The commit's hash. 

425 """ 

426 return self._name 

427 

428 @readonly 

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

430 """ 

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

432 

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

434 rule would. The order is: 

435 

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

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

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

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

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

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

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

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

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

445 

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

447 """ 

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

449 return None 

450 

451 conclusions = set() 

452 for pipeline in self._pipelines: 

453 if pipeline.Conclusion is None: 

454 return None 

455 

456 conclusions.add(pipeline.Conclusion) 

457 

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

459 Conclusion.Failure, Conclusion.TimedOut, Conclusion.StartupFailure, Conclusion.ActionRequired, 

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

461 ): 

462 if conclusion in conclusions: 

463 return conclusion 

464 

465 return None 

466 

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

468 """ 

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

470 

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

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

473 

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

475 """ 

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

477 for pipeline in self._pipelines: 

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

479 pipelines = [] 

480 byReference[pipeline.GitReference] = pipelines 

481 

482 pipelines.append(pipeline) 

483 

484 return byReference 

485 

486 @classmethod 

487 def FromJSON( 

488 cls, 

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

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

491 sha: Nullable[str] = None 

492 ) -> Self: 

493 """ 

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

495 

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

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

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

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

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

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

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

503 """ 

504 if not isinstance(runs, dict): 

505 workflowRuns = runs 

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

507 workflowRuns = [] 

508 

509 pipelines = [] 

510 shas = set() 

511 for run in workflowRuns: 

512 runJobs = None 

513 if jobs is not None: 

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

515 

516 pipeline = Pipeline.FromJSON(run, runJobs) 

517 pipelines.append(pipeline) 

518 if pipeline.SHA is not None: 

519 shas.add(pipeline.SHA) 

520 

521 if sha is None: 

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

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

524 elif commits > 1: 

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

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

527 raise error 

528 

529 sha = shas.pop() 

530 

531 return cls(sha, pipelines) 

532 

533 

534@export 

535class Pipeline(CIPipeline, StatusMixin): 

536 """ 

537 A workflow run. 

538 

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

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

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

542 top of the tree. 

543 """ 

544 

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

546 

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

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

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

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

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

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

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

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

555 

556 def __init__( 

557 self, 

558 name: str, 

559 identifier: Nullable[int] = None, 

560 status: Nullable[Status] = None, 

561 conclusion: Nullable[Conclusion] = None, 

562 createdAt: Nullable[datetime] = None, 

563 startedAt: Nullable[datetime] = None, 

564 completedAt: Nullable[datetime] = None, 

565 url: Nullable[URL] = None, 

566 workflowID: Nullable[int] = None, 

567 path: Nullable[str] = None, 

568 runNumber: Nullable[int] = None, 

569 runAttempt: Nullable[int] = None, 

570 event: Nullable[Event] = None, 

571 gitReference: Nullable[str] = None, 

572 sha: Nullable[str] = None, 

573 *, 

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

575 parent: Nullable[PipelineGroup] = None 

576 ) -> None: 

577 """ 

578 Initializes a workflow run. 

579 

580 :param name: Name of the workflow. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

605 """ 

606 for parameterName, number in ( 

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

608 ): 

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

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

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

612 raise ex 

613 

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

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

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

617 raise ex 

618 

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

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

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

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

623 raise ex 

624 

625 super().__init__( 

626 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs, 

627 parent=parent 

628 ) 

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

630 

631 self._id = identifier 

632 self._workflowID = workflowID 

633 self._path = path 

634 self._runNumber = runNumber 

635 self._runAttempt = runAttempt 

636 self._event = event 

637 self._gitReference = gitReference 

638 self._sha = sha 

639 

640 @readonly 

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

642 """ 

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

644 

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

646 """ 

647 return self._id 

648 

649 @readonly 

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

651 """ 

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

653 

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

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

656 

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

658 """ 

659 return self._workflowID 

660 

661 @readonly 

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

663 """ 

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

665 

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

667 

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

669 """ 

670 return self._path 

671 

672 @readonly 

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

674 """ 

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

676 

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

678 """ 

679 return self._runNumber 

680 

681 @readonly 

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

683 """ 

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

685 

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

687 """ 

688 return self._runAttempt 

689 

690 @readonly 

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

692 """ 

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

694 

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

696 """ 

697 return self._event 

698 

699 @readonly 

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

701 """ 

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

703 

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

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

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

707 

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

709 """ 

710 return self._gitReference 

711 

712 @readonly 

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

714 """ 

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

716 

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

718 """ 

719 return self._sha 

720 

721 @classmethod 

722 def FromJSON( 

723 cls, 

724 run: JSONObject, 

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

726 *, 

727 parent: Nullable[PipelineGroup] = None 

728 ) -> Self: 

729 """ 

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

731 

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

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

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

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

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

737 

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

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

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

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

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

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

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

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

746 """ 

747 if not isinstance(run, dict): 

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

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

750 raise ex 

751 

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

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

754 

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

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

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

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

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

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

761 if status is Status.Completed else None 

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

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

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

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

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

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

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

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

770 

771 pipeline = cls( 

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

773 runAttempt, event, gitReference, sha, parent=parent 

774 ) 

775 

776 if jobs is None: 

777 return pipeline 

778 

779 for position, job in enumerate(jobs): 

780 if not isinstance(job, dict): 

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

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

783 raise ex 

784 

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

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

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

788 

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

790 

791 group: CIWorkflow = pipeline 

792 for caller in callers: 

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

794 callerName, callerDimensions = _splitMatrixJobName(caller) 

795 if callerDimensions is None: 

796 calledWorkflow = CIWorkflow(caller, parent=group) 

797 else: 

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

799 matrix = CIMatrix(callerName, parent=group) 

800 

801 if matrix.ContainsElement(caller): 

802 calledWorkflow = matrix.GetElement(caller) 

803 else: 

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

805 

806 group = calledWorkflow 

807 

808 jobName, dimensions = _splitMatrixJobName(leaf) 

809 if dimensions is None: 

810 Job.FromJSON(job, path, parent=group) 

811 else: 

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

813 matrix = CIMatrix(jobName, parent=group) 

814 

815 MatrixJob.FromJSON(job, path, jobName, dimensions, parent=matrix) 

816 

817 return pipeline 

818 

819 

820@export 

821class Job(CIJob, StatusMixin): 

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

823 

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

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

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

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

828 

829 def __init__( 

830 self, 

831 name: str, 

832 identifier: Nullable[int] = None, 

833 status: Nullable[Status] = None, 

834 conclusion: Nullable[Conclusion] = None, 

835 createdAt: Nullable[datetime] = None, 

836 startedAt: Nullable[datetime] = None, 

837 completedAt: Nullable[datetime] = None, 

838 url: Nullable[URL] = None, 

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

840 runnerName: Nullable[str] = None, 

841 runnerGroupName: Nullable[str] = None, 

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

843 *, 

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

845 parent: Nullable[CIJobGroup] = None 

846 ) -> None: 

847 """ 

848 Initializes a job of a workflow run. 

849 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

869 """ 

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

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

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

873 raise ex 

874 

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

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

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

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

879 raise ex 

880 

881 self._labels = [] 

882 if labels is not None: 

883 for label in labels: 

884 if not isinstance(label, str): 

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

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

887 raise ex 

888 

889 self._labels.append(label) 

890 

891 stepList = [] 

892 if steps is not None: 

893 for step in steps: 

894 if not isinstance(step, Step): 

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

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

897 raise ex 

898 

899 stepList.append(step) 

900 

901 super().__init__( 

902 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs, 

903 parent=parent 

904 ) 

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

906 

907 self._id = identifier 

908 self._runnerName = runnerName 

909 self._runnerGroupName = runnerGroupName 

910 

911 for step in stepList: 

912 step._parent = self 

913 step._pipeline = self._pipeline 

914 self._steps.append(step) 

915 

916 @readonly 

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

918 """ 

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

920 

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

922 """ 

923 return self._id 

924 

925 @readonly 

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

927 """ 

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

929 

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

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

932 

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

934 """ 

935 return self._labels 

936 

937 @readonly 

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

939 """ 

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

941 

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

943 """ 

944 return self._runnerName 

945 

946 @readonly 

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

948 """ 

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

950 

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

952 """ 

953 return self._runnerGroupName 

954 

955 @readonly 

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

957 """ 

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

959 

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

961 """ 

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

963 return self._createdAt 

964 

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

966 

967 @readonly 

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

969 """ 

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

971 

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

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

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

975 

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

977 """ 

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

979 if len(times) == 0: 

980 return self._startedAt 

981 

982 first = min(times) 

983 

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

985 

986 @readonly 

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

988 """ 

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

990 

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

992 job is the timespan that stretches. 

993 

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

995 """ 

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

997 return self._completedAt 

998 

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

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

1001 return self._completedAt 

1002 

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

1004 

1005 @readonly 

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

1007 """ 

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

1009 

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

1011 """ 

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

1013 return None 

1014 

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

1016 

1017 @classmethod 

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

1019 """ 

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

1021 

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

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

1024 

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

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

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

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

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

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

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

1032 """ 

1033 if not isinstance(json, dict): 

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

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

1036 raise ex 

1037 

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

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

1040 

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

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

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

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

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

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

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

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

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

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

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

1052 

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

1054 steps = None 

1055 else: 

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

1057 

1058 return cls( 

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

1060 runnerGroupName, steps, parent=parent 

1061 ) 

1062 

1063 

1064@export 

1065class MatrixJob(Job, MatrixInstanceMixin): 

1066 """ 

1067 One instance of a job produced by a matrix. 

1068 

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

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

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

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

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

1074 """ 

1075 

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

1077 

1078 def __init__( 

1079 self, 

1080 name: str, 

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

1082 identifier: Nullable[int] = None, 

1083 status: Nullable[Status] = None, 

1084 conclusion: Nullable[Conclusion] = None, 

1085 createdAt: Nullable[datetime] = None, 

1086 startedAt: Nullable[datetime] = None, 

1087 completedAt: Nullable[datetime] = None, 

1088 url: Nullable[URL] = None, 

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

1090 runnerName: Nullable[str] = None, 

1091 runnerGroupName: Nullable[str] = None, 

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

1093 *, 

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

1095 parent: Nullable[CIMatrix] = None 

1096 ) -> None: 

1097 """ 

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

1099 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1115 """ 

1116 super().__init__( 

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

1118 runnerGroupName, steps, keyValuePairs=keyValuePairs, parent=parent 

1119 ) 

1120 MatrixInstanceMixin.__init__(self, dimensions) 

1121 

1122 def __str__(self) -> str: 

1123 """ 

1124 Return a string representation of the matrix instance. 

1125 

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

1127 """ 

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

1129 return self._name 

1130 

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

1132 

1133 @classmethod 

1134 def FromJSON( 

1135 cls, 

1136 json: JSONObject, 

1137 path: str = "job", 

1138 name: Nullable[str] = None, 

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

1140 *, 

1141 parent: Nullable[CIMatrix] = None 

1142 ) -> Self: 

1143 """ 

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

1145 

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

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

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

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

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

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

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

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

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

1155 """ 

1156 if not isinstance(json, dict): 

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

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

1159 raise ex 

1160 

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

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

1163 

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

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

1166 

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

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

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

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

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

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

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

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

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

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

1177 

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

1179 steps = None 

1180 else: 

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

1182 

1183 return cls( 

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

1185 runnerName, runnerGroupName, steps, parent=parent 

1186 ) 

1187 

1188 

1189@export 

1190class Step(CIStep, StatusMixin): 

1191 """A step within a job.""" 

1192 

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

1194 

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

1196 

1197 def __init__( 

1198 self, 

1199 name: str, 

1200 number: Nullable[int] = None, 

1201 status: Nullable[Status] = None, 

1202 conclusion: Nullable[Conclusion] = None, 

1203 startedAt: Nullable[datetime] = None, 

1204 completedAt: Nullable[datetime] = None, 

1205 *, 

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

1207 parent: Nullable[Job] = None 

1208 ) -> None: 

1209 """ 

1210 Initializes a step within a job. 

1211 

1212 :param name: Name of the step. 

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

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

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

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

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

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

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

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

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

1222 """ 

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

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

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

1226 raise ex 

1227 elif number is not None and number < 1: 

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

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

1230 raise ex 

1231 

1232 super().__init__( 

1233 name, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs, parent=parent 

1234 ) 

1235 StatusMixin.__init__(self, status, conclusion) 

1236 

1237 self._number = number 

1238 

1239 @readonly 

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

1241 """ 

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

1243 

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

1245 """ 

1246 return self._number 

1247 

1248 @classmethod 

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

1250 """ 

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

1252 

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

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

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

1256 :returns: The step. 

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

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

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

1260 """ 

1261 if not isinstance(json, dict): 

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

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

1264 raise ex 

1265 

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

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

1268 

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

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

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

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

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

1274 

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