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

498 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-04 09:05 +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 

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, JSONObject, MatrixInstanceMixin, Outcome 

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

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

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

78from pyTooling.Common import getFullyQualifiedName, parseISO8601Timestamp, StringEnum 

79from pyTooling.Decorators import export, readonly 

80from pyTooling.GenericPath.URL import URL 

81from pyTooling.MetaClasses import ExtendedType 

82 

83 

84__all__ = ["CONCLUSION_TO_OUTCOME"] 

85 

86 

87@export 

88class GitHubError(CIError): 

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

90 

91 

92@export 

93class Status(StringEnum): 

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

95 

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

97 InProgress = "in_progress" #: Running. 

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

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

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

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

102 

103 @classmethod 

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

105 """ 

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

107 

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

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

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

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

112 The note lists the documented values. 

113 """ 

114 try: 

115 return super().Parse(value) 

116 except ValueError as ex: 

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

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

119 raise error from ex 

120 

121 

122@export 

123class Conclusion(StringEnum): 

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

125 

126 Success = "success" #: Succeeded. 

127 Failure = "failure" #: Failed. 

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

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

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

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

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

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

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

135 

136 @classmethod 

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

138 """ 

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

140 

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

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

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

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

145 The note lists the documented values. 

146 """ 

147 try: 

148 return super().Parse(value) 

149 except ValueError as ex: 

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

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

152 raise error from ex 

153 

154 def ToOutcome(self) -> Outcome: 

155 """ 

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

157 

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

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

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

161 

162 :returns: The outcome. 

163 """ 

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

165 

166 

167CONCLUSION_TO_OUTCOME = { 

168 Conclusion.Success: Outcome.Success, 

169 Conclusion.Failure: Outcome.Failure, 

170 Conclusion.TimedOut: Outcome.Timeout, 

171 Conclusion.Skipped: Outcome.Skip, 

172 Conclusion.Cancelled: Outcome.Cancellation, 

173} 

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

175 

176 

177@export 

178class Event(StringEnum): 

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

180 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

214 

215 @classmethod 

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

217 """ 

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

219 

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

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

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

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

224 The note lists the documented values. 

225 """ 

226 try: 

227 return super().Parse(value) 

228 except ValueError as ex: 

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

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

231 raise error from ex 

232 

233 

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

235 """ 

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

237 

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

239 

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

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

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

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

244 The note reports the value that was read. 

245 """ 

246 try: 

247 return parseISO8601Timestamp(value, timezone.utc) 

248 except ValueError as ex: 

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

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

251 raise error from ex 

252 

253 

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

255 """ 

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

257 

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

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

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

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

262 """ 

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

264 return None 

265 

266 return URL.Parse(value) 

267 

268 

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

270 """ 

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

272 

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

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

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

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

277 

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

279 

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

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

282 """ 

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

284 return name, None 

285 

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

287 base = base.rstrip() 

288 if base == "": 

289 return name, None 

290 

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

292 

293 

294@export 

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

296 """ 

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

298 

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

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

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

302 """ 

303 

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

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

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

307 

308 def __init__( 

309 self, 

310 status: Nullable[Status] = None, 

311 conclusion: Nullable[Conclusion] = None, 

312 url: Nullable[URL] = None 

313 ) -> None: 

314 """ 

315 Initializes what GitHub reports about an element. 

316 

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

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

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

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

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

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

323 """ 

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

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

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

327 raise ex 

328 

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

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

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

332 raise ex 

333 

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

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

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

337 raise ex 

338 

339 self._status = status 

340 self._conclusion = conclusion 

341 self._url = url 

342 if conclusion is not None: 

343 self._outcome = conclusion.ToOutcome() 

344 

345 @readonly 

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

347 """ 

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

349 

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

351 """ 

352 return self._status 

353 

354 @readonly 

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

356 """ 

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

358 

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

360 :meth:`Conclusion.ToOutcome`. 

361 

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

363 """ 

364 return self._conclusion 

365 

366 @readonly 

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

368 """ 

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

370 

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

372 """ 

373 return self._url 

374 

375 

376@export 

377class PipelineGroup(CIPipelineGroup): 

378 """ 

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

380 

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

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

383 

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

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

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

387 were 

388 

389 .. code-block:: text 

390 

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

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

393 

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

395 

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

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

398 """ 

399 

400 def __init__( 

401 self, 

402 sha: str, 

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

404 *, 

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

406 ) -> None: 

407 """ 

408 Initializes a group of pipelines started for one commit. 

409 

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

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

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

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

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

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

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

417 """ 

418 if sha is None: 

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

420 elif not isinstance(sha, str): 

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

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

423 raise ex 

424 elif sha == "": 

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

426 

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

428 

429 @readonly 

430 def SHA(self) -> str: 

431 """ 

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

433 

434 :returns: The commit's hash. 

435 """ 

436 return self._name 

437 

438 @readonly 

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

440 """ 

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

442 

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

444 rule would. The order is: 

445 

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

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

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

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

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

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

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

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

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

455 

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

457 """ 

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

459 return None 

460 

461 conclusions = set() 

462 for pipeline in self._pipelines: 

463 if pipeline.Conclusion is None: 

464 return None 

465 

466 conclusions.add(pipeline.Conclusion) 

467 

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

469 Conclusion.Failure, Conclusion.TimedOut, Conclusion.StartupFailure, Conclusion.ActionRequired, 

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

471 ): 

472 if conclusion in conclusions: 

473 return conclusion 

474 

475 return None 

476 

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

478 """ 

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

480 

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

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

483 

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

485 """ 

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

487 for pipeline in self._pipelines: 

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

489 pipelines = [] 

490 byReference[pipeline.GitReference] = pipelines 

491 

492 pipelines.append(pipeline) 

493 

494 return byReference 

495 

496 @classmethod 

497 def FromJSON( 

498 cls, 

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

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

501 sha: Nullable[str] = None 

502 ) -> Self: 

503 """ 

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

505 

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

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

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

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

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

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

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

513 """ 

514 if not isinstance(runs, dict): 

515 workflowRuns = runs 

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

517 workflowRuns = [] 

518 

519 pipelines = [] 

520 shas = set() 

521 for run in workflowRuns: 

522 runJobs = None 

523 if jobs is not None: 

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

525 

526 pipeline = Pipeline.FromJSON(run, runJobs) 

527 pipelines.append(pipeline) 

528 if pipeline.SHA is not None: 

529 shas.add(pipeline.SHA) 

530 

531 if sha is None: 

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

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

534 elif commits > 1: 

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

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

537 raise error 

538 

539 sha = shas.pop() 

540 

541 return cls(sha, pipelines) 

542 

543 

544@export 

545class Pipeline(CIPipeline, StatusMixin): 

546 """ 

547 A workflow run. 

548 

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

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

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

552 top of the tree. 

553 """ 

554 

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

556 

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

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

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

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

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

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

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

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

565 

566 def __init__( 

567 self, 

568 name: str, 

569 identifier: Nullable[int] = None, 

570 status: Nullable[Status] = None, 

571 conclusion: Nullable[Conclusion] = None, 

572 createdAt: Nullable[datetime] = None, 

573 startedAt: Nullable[datetime] = None, 

574 completedAt: Nullable[datetime] = None, 

575 url: Nullable[URL] = None, 

576 workflowID: Nullable[int] = None, 

577 path: Nullable[str] = None, 

578 runNumber: Nullable[int] = None, 

579 runAttempt: Nullable[int] = None, 

580 event: Nullable[Event] = None, 

581 gitReference: Nullable[str] = None, 

582 sha: Nullable[str] = None, 

583 *, 

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

585 parent: Nullable[PipelineGroup] = None 

586 ) -> None: 

587 """ 

588 Initializes a workflow run. 

589 

590 :param name: Name of the workflow. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

615 """ 

616 for parameterName, number in ( 

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

618 ): 

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

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

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

622 raise ex 

623 

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

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

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

627 raise ex 

628 

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

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

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

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

633 raise ex 

634 

635 super().__init__( 

636 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs, 

637 parent=parent 

638 ) 

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

640 

641 self._id = identifier 

642 self._workflowID = workflowID 

643 self._path = path 

644 self._runNumber = runNumber 

645 self._runAttempt = runAttempt 

646 self._event = event 

647 self._gitReference = gitReference 

648 self._sha = sha 

649 

650 @readonly 

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

652 """ 

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

654 

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

656 """ 

657 return self._id 

658 

659 @readonly 

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

661 """ 

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

663 

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

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

666 

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

668 """ 

669 return self._workflowID 

670 

671 @readonly 

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

673 """ 

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

675 

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

677 

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

679 """ 

680 return self._path 

681 

682 @readonly 

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

684 """ 

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

686 

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

688 """ 

689 return self._runNumber 

690 

691 @readonly 

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

693 """ 

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

695 

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

697 """ 

698 return self._runAttempt 

699 

700 @readonly 

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

702 """ 

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

704 

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

706 """ 

707 return self._event 

708 

709 @readonly 

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

711 """ 

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

713 

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

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

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

717 

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

719 """ 

720 return self._gitReference 

721 

722 @readonly 

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

724 """ 

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

726 

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

728 """ 

729 return self._sha 

730 

731 @classmethod 

732 def FromJSON( 

733 cls, 

734 run: JSONObject, 

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

736 *, 

737 parent: Nullable[PipelineGroup] = None 

738 ) -> Self: 

739 """ 

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

741 

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

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

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

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

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

747 

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

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

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

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

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

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

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

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

756 """ 

757 if not isinstance(run, dict): 

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

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

760 raise ex 

761 

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

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

764 

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

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

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

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

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

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

771 if status is Status.Completed else None 

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

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

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

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

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

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

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

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

780 

781 pipeline = cls( 

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

783 runAttempt, event, gitReference, sha, parent=parent 

784 ) 

785 

786 if jobs is None: 

787 return pipeline 

788 

789 for position, job in enumerate(jobs): 

790 if not isinstance(job, dict): 

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

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

793 raise ex 

794 

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

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

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

798 

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

800 

801 group: CIWorkflow = pipeline 

802 for caller in callers: 

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

804 callerName, callerDimensions = _splitMatrixJobName(caller) 

805 if callerDimensions is None: 

806 calledWorkflow = CIWorkflow(caller, parent=group) 

807 else: 

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

809 matrix = CIMatrix(callerName, parent=group) 

810 

811 if matrix.ContainsElement(caller): 

812 calledWorkflow = matrix.GetElement(caller) 

813 else: 

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

815 

816 group = calledWorkflow 

817 

818 jobName, dimensions = _splitMatrixJobName(leaf) 

819 if dimensions is None: 

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

821 else: 

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

823 matrix = CIMatrix(jobName, parent=group) 

824 

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

826 

827 return pipeline 

828 

829 

830@export 

831class Job(CIJob, StatusMixin): 

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

833 

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

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

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

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

838 

839 def __init__( 

840 self, 

841 name: str, 

842 identifier: Nullable[int] = None, 

843 status: Nullable[Status] = None, 

844 conclusion: Nullable[Conclusion] = None, 

845 createdAt: Nullable[datetime] = None, 

846 startedAt: Nullable[datetime] = None, 

847 completedAt: Nullable[datetime] = None, 

848 url: Nullable[URL] = None, 

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

850 runnerName: Nullable[str] = None, 

851 runnerGroupName: Nullable[str] = None, 

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

853 *, 

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

855 parent: Nullable[CIJobGroup] = None 

856 ) -> None: 

857 """ 

858 Initializes a job of a workflow run. 

859 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

879 """ 

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

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

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

883 raise ex 

884 

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

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

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

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

889 raise ex 

890 

891 self._labels = [] 

892 if labels is not None: 

893 for label in labels: 

894 if not isinstance(label, str): 

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

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

897 raise ex 

898 

899 self._labels.append(label) 

900 

901 stepList = [] 

902 if steps is not None: 

903 for step in steps: 

904 if not isinstance(step, Step): 

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

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

907 raise ex 

908 

909 stepList.append(step) 

910 

911 super().__init__( 

912 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs, 

913 parent=parent 

914 ) 

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

916 

917 self._id = identifier 

918 self._runnerName = runnerName 

919 self._runnerGroupName = runnerGroupName 

920 

921 for step in stepList: 

922 step._parent = self 

923 step._pipeline = self._pipeline 

924 self._steps.append(step) 

925 

926 @readonly 

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

928 """ 

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

930 

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

932 """ 

933 return self._id 

934 

935 @readonly 

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

937 """ 

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

939 

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

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

942 

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

944 """ 

945 return self._labels 

946 

947 @readonly 

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

949 """ 

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

951 

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

953 """ 

954 return self._runnerName 

955 

956 @readonly 

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

958 """ 

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

960 

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

962 """ 

963 return self._runnerGroupName 

964 

965 @readonly 

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

967 """ 

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

969 

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

971 """ 

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

973 return self._createdAt 

974 

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

976 

977 @readonly 

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

979 """ 

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

981 

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

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

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

985 

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

987 """ 

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

989 if len(times) == 0: 

990 return self._startedAt 

991 

992 first = min(times) 

993 

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

995 

996 @readonly 

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

998 """ 

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

1000 

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

1002 job is the timespan that stretches. 

1003 

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

1005 """ 

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

1007 return self._completedAt 

1008 

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

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

1011 return self._completedAt 

1012 

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

1014 

1015 @readonly 

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

1017 """ 

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

1019 

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

1021 """ 

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

1023 return None 

1024 

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

1026 

1027 @classmethod 

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

1029 """ 

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

1031 

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

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

1034 

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

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

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

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

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

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

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

1042 """ 

1043 if not isinstance(json, dict): 

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

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

1046 raise ex 

1047 

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

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

1050 

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

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

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

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

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

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

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

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

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

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

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

1062 

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

1064 steps = None 

1065 else: 

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

1067 

1068 return cls( 

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

1070 runnerGroupName, steps, parent=parent 

1071 ) 

1072 

1073 

1074@export 

1075class MatrixJob(Job, MatrixInstanceMixin): 

1076 """ 

1077 One instance of a job produced by a matrix. 

1078 

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

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

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

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

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

1084 """ 

1085 

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

1087 

1088 def __init__( 

1089 self, 

1090 name: str, 

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

1092 identifier: Nullable[int] = None, 

1093 status: Nullable[Status] = None, 

1094 conclusion: Nullable[Conclusion] = None, 

1095 createdAt: Nullable[datetime] = None, 

1096 startedAt: Nullable[datetime] = None, 

1097 completedAt: Nullable[datetime] = None, 

1098 url: Nullable[URL] = None, 

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

1100 runnerName: Nullable[str] = None, 

1101 runnerGroupName: Nullable[str] = None, 

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

1103 *, 

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

1105 parent: Nullable[CIMatrix] = None 

1106 ) -> None: 

1107 """ 

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

1109 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1125 """ 

1126 super().__init__( 

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

1128 runnerGroupName, steps, keyValuePairs=keyValuePairs, parent=parent 

1129 ) 

1130 MatrixInstanceMixin.__init__(self, dimensions) 

1131 

1132 def __str__(self) -> str: 

1133 """ 

1134 Return a string representation of the matrix instance. 

1135 

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

1137 """ 

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

1139 return self._name 

1140 

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

1142 

1143 @classmethod 

1144 def FromJSON( 

1145 cls, 

1146 json: JSONObject, 

1147 path: str = "job", 

1148 name: Nullable[str] = None, 

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

1150 *, 

1151 parent: Nullable[CIMatrix] = None 

1152 ) -> Self: 

1153 """ 

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

1155 

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

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

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

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

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

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

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

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

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

1165 """ 

1166 if not isinstance(json, dict): 

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

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

1169 raise ex 

1170 

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

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

1173 

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

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

1176 

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

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

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

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

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

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

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

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

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

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

1187 

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

1189 steps = None 

1190 else: 

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

1192 

1193 return cls( 

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

1195 runnerName, runnerGroupName, steps, parent=parent 

1196 ) 

1197 

1198 

1199@export 

1200class Step(CIStep, StatusMixin): 

1201 """A step within a job.""" 

1202 

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

1204 

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

1206 

1207 def __init__( 

1208 self, 

1209 name: str, 

1210 number: Nullable[int] = None, 

1211 status: Nullable[Status] = None, 

1212 conclusion: Nullable[Conclusion] = None, 

1213 startedAt: Nullable[datetime] = None, 

1214 completedAt: Nullable[datetime] = None, 

1215 *, 

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

1217 parent: Nullable[Job] = None 

1218 ) -> None: 

1219 """ 

1220 Initializes a step within a job. 

1221 

1222 :param name: Name of the step. 

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

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

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

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

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

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

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

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

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

1232 """ 

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

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

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

1236 raise ex 

1237 elif number is not None and number < 1: 

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

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

1240 raise ex 

1241 

1242 super().__init__( 

1243 name, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs, parent=parent 

1244 ) 

1245 StatusMixin.__init__(self, status, conclusion) 

1246 

1247 self._number = number 

1248 

1249 @readonly 

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

1251 """ 

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

1253 

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

1255 """ 

1256 return self._number 

1257 

1258 @classmethod 

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

1260 """ 

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

1262 

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

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

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

1266 :returns: The step. 

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

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

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

1270 """ 

1271 if not isinstance(json, dict): 

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

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

1274 raise ex 

1275 

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

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

1278 

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

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

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

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

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

1284 

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