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

489 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-09-29 07:08 +0000

1# ==================================================================================================================== # 

2# _____ _ _ ____ ___ # 

3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___|_ _| # 

4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` || | | | # 

5# | |_) | |_| || | (_) | (_) | | | | | | (_| || |___ | | # 

6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____|___| # 

7# |_| |___/ |___/ # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany # 

15# # 

16# Licensed under the Apache License, Version 2.0 (the "License"); # 

17# you may not use this file except in compliance with the License. # 

18# You may obtain a copy of the License at # 

19# # 

20# http://www.apache.org/licenses/LICENSE-2.0 # 

21# # 

22# Unless required by applicable law or agreed to in writing, software # 

23# distributed under the License is distributed on an "AS IS" BASIS, # 

24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

25# See the License for the specific language governing permissions and # 

26# limitations under the License. # 

27# # 

28# SPDX-License-Identifier: Apache-2.0 # 

29# ==================================================================================================================== # 

30# 

31""" 

32A data model of a GitHub Actions workflow run. 

33 

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

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

36 

37.. 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, 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__(self, sha: str, pipelines: Nullable[Iterable[Pipeline]] = None) -> None: 

391 """ 

392 Initializes a group of pipelines started for one commit. 

393 

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

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

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

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

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

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

400 """ 

401 if sha is None: 

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

403 elif not isinstance(sha, str): 

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

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

406 raise ex 

407 elif sha == "": 

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

409 

410 super().__init__(sha, pipelines) 

411 

412 @readonly 

413 def SHA(self) -> str: 

414 """ 

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

416 

417 :returns: The commit's hash. 

418 """ 

419 return self._name 

420 

421 @readonly 

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

423 """ 

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

425 

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

427 rule would. The order is: 

428 

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

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

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

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

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

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

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

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

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

438 

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

440 """ 

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

442 return None 

443 

444 conclusions = set() 

445 for pipeline in self._pipelines: 

446 if pipeline.Conclusion is None: 

447 return None 

448 

449 conclusions.add(pipeline.Conclusion) 

450 

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

452 Conclusion.Failure, Conclusion.TimedOut, Conclusion.StartupFailure, Conclusion.ActionRequired, 

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

454 ): 

455 if conclusion in conclusions: 

456 return conclusion 

457 

458 return None 

459 

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

461 """ 

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

463 

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

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

466 

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

468 """ 

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

470 for pipeline in self._pipelines: 

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

472 pipelines = [] 

473 byReference[pipeline.GitReference] = pipelines 

474 

475 pipelines.append(pipeline) 

476 

477 return byReference 

478 

479 @classmethod 

480 def FromJSON( 

481 cls, 

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

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

484 sha: Nullable[str] = None 

485 ) -> Self: 

486 """ 

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

488 

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

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

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

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

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

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

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

496 """ 

497 if not isinstance(runs, dict): 

498 workflowRuns = runs 

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

500 workflowRuns = [] 

501 

502 pipelines = [] 

503 shas = set() 

504 for run in workflowRuns: 

505 runJobs = None 

506 if jobs is not None: 

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

508 

509 pipeline = Pipeline.FromJSON(run, runJobs) 

510 pipelines.append(pipeline) 

511 if pipeline.SHA is not None: 

512 shas.add(pipeline.SHA) 

513 

514 if sha is None: 

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

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

517 elif commits > 1: 

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

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

520 raise error 

521 

522 sha = shas.pop() 

523 

524 return cls(sha, pipelines) 

525 

526 

527@export 

528class Pipeline(CIPipeline, StatusMixin): 

529 """ 

530 A workflow run. 

531 

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

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

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

535 top of the tree. 

536 """ 

537 

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

539 

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

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

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

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

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

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

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

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

548 

549 def __init__( 

550 self, 

551 name: str, 

552 identifier: Nullable[int] = None, 

553 status: Nullable[Status] = None, 

554 conclusion: Nullable[Conclusion] = None, 

555 createdAt: Nullable[datetime] = None, 

556 startedAt: Nullable[datetime] = None, 

557 completedAt: Nullable[datetime] = None, 

558 url: Nullable[URL] = None, 

559 workflowID: Nullable[int] = None, 

560 path: Nullable[str] = None, 

561 runNumber: Nullable[int] = None, 

562 runAttempt: Nullable[int] = None, 

563 event: Nullable[Event] = None, 

564 gitReference: Nullable[str] = None, 

565 sha: Nullable[str] = None, 

566 *, 

567 parent: Nullable[PipelineGroup] = None 

568 ) -> None: 

569 """ 

570 Initializes a workflow run. 

571 

572 :param name: Name of the workflow. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

596 """ 

597 for parameterName, number in ( 

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

599 ): 

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

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

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

603 raise ex 

604 

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

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

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

608 raise ex 

609 

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

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

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

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

614 raise ex 

615 

616 super().__init__( 

617 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, parent=parent 

618 ) 

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

620 

621 self._id = identifier 

622 self._workflowID = workflowID 

623 self._path = path 

624 self._runNumber = runNumber 

625 self._runAttempt = runAttempt 

626 self._event = event 

627 self._gitReference = gitReference 

628 self._sha = sha 

629 

630 @readonly 

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

632 """ 

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

634 

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

636 """ 

637 return self._id 

638 

639 @readonly 

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

641 """ 

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

643 

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

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

646 

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

648 """ 

649 return self._workflowID 

650 

651 @readonly 

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

653 """ 

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

655 

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

657 

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

659 """ 

660 return self._path 

661 

662 @readonly 

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

664 """ 

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

666 

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

668 """ 

669 return self._runNumber 

670 

671 @readonly 

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

673 """ 

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

675 

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

677 """ 

678 return self._runAttempt 

679 

680 @readonly 

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

682 """ 

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

684 

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

686 """ 

687 return self._event 

688 

689 @readonly 

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

691 """ 

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

693 

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

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

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

697 

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

699 """ 

700 return self._gitReference 

701 

702 @readonly 

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

704 """ 

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

706 

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

708 """ 

709 return self._sha 

710 

711 @classmethod 

712 def FromJSON( 

713 cls, 

714 run: JSONObject, 

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

716 *, 

717 parent: Nullable[PipelineGroup] = None 

718 ) -> Self: 

719 """ 

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

721 

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

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

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

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

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

727 

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

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

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

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

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

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

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

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

736 """ 

737 if not isinstance(run, dict): 

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

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

740 raise ex 

741 

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

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

744 

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

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

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

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

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

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

751 if status is Status.Completed else None 

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

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

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

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

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

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

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

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

760 

761 pipeline = cls( 

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

763 runAttempt, event, gitReference, sha, parent=parent 

764 ) 

765 

766 if jobs is None: 

767 return pipeline 

768 

769 for position, job in enumerate(jobs): 

770 if not isinstance(job, dict): 

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

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

773 raise ex 

774 

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

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

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

778 

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

780 

781 group: CIWorkflow = pipeline 

782 for caller in callers: 

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

784 callerName, callerDimensions = _splitMatrixJobName(caller) 

785 if callerDimensions is None: 

786 calledWorkflow = CIWorkflow(caller, parent=group) 

787 else: 

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

789 matrix = CIMatrix(callerName, parent=group) 

790 

791 if caller in matrix: 

792 calledWorkflow = matrix[caller] 

793 else: 

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

795 

796 group = calledWorkflow 

797 

798 jobName, dimensions = _splitMatrixJobName(leaf) 

799 if dimensions is None: 

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

801 else: 

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

803 matrix = CIMatrix(jobName, parent=group) 

804 

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

806 

807 return pipeline 

808 

809 

810@export 

811class Job(CIJob, StatusMixin): 

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

813 

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

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

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

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

818 

819 def __init__( 

820 self, 

821 name: str, 

822 identifier: Nullable[int] = None, 

823 status: Nullable[Status] = None, 

824 conclusion: Nullable[Conclusion] = None, 

825 createdAt: Nullable[datetime] = None, 

826 startedAt: Nullable[datetime] = None, 

827 completedAt: Nullable[datetime] = None, 

828 url: Nullable[URL] = None, 

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

830 runnerName: Nullable[str] = None, 

831 runnerGroupName: Nullable[str] = None, 

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

833 *, 

834 parent: Nullable[CIJobGroup] = None 

835 ) -> None: 

836 """ 

837 Initializes a job of a workflow run. 

838 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

857 """ 

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

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

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

861 raise ex 

862 

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

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

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

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

867 raise ex 

868 

869 self._labels = [] 

870 if labels is not None: 

871 for label in labels: 

872 if not isinstance(label, str): 

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

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

875 raise ex 

876 

877 self._labels.append(label) 

878 

879 stepList = [] 

880 if steps is not None: 

881 for step in steps: 

882 if not isinstance(step, Step): 

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

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

885 raise ex 

886 

887 stepList.append(step) 

888 

889 super().__init__( 

890 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, parent=parent 

891 ) 

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

893 

894 self._id = identifier 

895 self._runnerName = runnerName 

896 self._runnerGroupName = runnerGroupName 

897 

898 for step in stepList: 

899 step._parent = self 

900 step._pipeline = self._pipeline 

901 self._steps.append(step) 

902 

903 @readonly 

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

905 """ 

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

907 

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

909 """ 

910 return self._id 

911 

912 @readonly 

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

914 """ 

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

916 

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

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

919 

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

921 """ 

922 return self._labels 

923 

924 @readonly 

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

926 """ 

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

928 

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

930 """ 

931 return self._runnerName 

932 

933 @readonly 

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

935 """ 

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

937 

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

939 """ 

940 return self._runnerGroupName 

941 

942 @readonly 

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

944 """ 

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

946 

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

948 """ 

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

950 return self._createdAt 

951 

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

953 

954 @readonly 

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

956 """ 

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

958 

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

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

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

962 

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

964 """ 

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

966 if len(times) == 0: 

967 return self._startedAt 

968 

969 first = min(times) 

970 

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

972 

973 @readonly 

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

975 """ 

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

977 

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

979 job is the timespan that stretches. 

980 

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

982 """ 

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

984 return self._completedAt 

985 

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

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

988 return self._completedAt 

989 

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

991 

992 @readonly 

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

994 """ 

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

996 

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

998 """ 

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

1000 return None 

1001 

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

1003 

1004 @classmethod 

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

1006 """ 

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

1008 

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

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

1011 

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

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

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

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

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

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

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

1019 """ 

1020 if not isinstance(json, dict): 

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

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

1023 raise ex 

1024 

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

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

1027 

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

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

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

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

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

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

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

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

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

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

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

1039 

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

1041 steps = None 

1042 else: 

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

1044 

1045 return cls( 

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

1047 runnerGroupName, steps, parent=parent 

1048 ) 

1049 

1050 

1051@export 

1052class MatrixJob(Job, MatrixInstanceMixin): 

1053 """ 

1054 One instance of a job produced by a matrix. 

1055 

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

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

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

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

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

1061 """ 

1062 

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

1064 

1065 def __init__( 

1066 self, 

1067 name: str, 

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

1069 identifier: Nullable[int] = None, 

1070 status: Nullable[Status] = None, 

1071 conclusion: Nullable[Conclusion] = None, 

1072 createdAt: Nullable[datetime] = None, 

1073 startedAt: Nullable[datetime] = None, 

1074 completedAt: Nullable[datetime] = None, 

1075 url: Nullable[URL] = None, 

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

1077 runnerName: Nullable[str] = None, 

1078 runnerGroupName: Nullable[str] = None, 

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

1080 *, 

1081 parent: Nullable[CIMatrix] = None 

1082 ) -> None: 

1083 """ 

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

1085 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1100 """ 

1101 super().__init__( 

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

1103 runnerGroupName, steps, parent=parent 

1104 ) 

1105 MatrixInstanceMixin.__init__(self, dimensions) 

1106 

1107 def __str__(self) -> str: 

1108 """ 

1109 Return a string representation of the matrix instance. 

1110 

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

1112 """ 

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

1114 return self._name 

1115 

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

1117 

1118 @classmethod 

1119 def FromJSON( 

1120 cls, 

1121 json: JSONObject, 

1122 path: str = "job", 

1123 name: Nullable[str] = None, 

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

1125 *, 

1126 parent: Nullable[CIMatrix] = None 

1127 ) -> Self: 

1128 """ 

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

1130 

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

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

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

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

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

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

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

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

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

1140 """ 

1141 if not isinstance(json, dict): 

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

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

1144 raise ex 

1145 

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

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

1148 

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

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

1151 

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

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

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

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

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

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

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

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

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

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

1162 

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

1164 steps = None 

1165 else: 

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

1167 

1168 return cls( 

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

1170 runnerName, runnerGroupName, steps, parent=parent 

1171 ) 

1172 

1173 

1174@export 

1175class Step(CIStep, StatusMixin): 

1176 """A step within a job.""" 

1177 

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

1179 

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

1181 

1182 def __init__( 

1183 self, 

1184 name: str, 

1185 number: Nullable[int] = None, 

1186 status: Nullable[Status] = None, 

1187 conclusion: Nullable[Conclusion] = None, 

1188 startedAt: Nullable[datetime] = None, 

1189 completedAt: Nullable[datetime] = None, 

1190 *, 

1191 parent: Nullable[Job] = None 

1192 ) -> None: 

1193 """ 

1194 Initializes a step within a job. 

1195 

1196 :param name: Name of the step. 

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

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

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

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

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

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

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

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

1205 """ 

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

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

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

1209 raise ex 

1210 elif number is not None and number < 1: 

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

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

1213 raise ex 

1214 

1215 super().__init__( 

1216 name, startedAt=startedAt, completedAt=completedAt, parent=parent 

1217 ) 

1218 StatusMixin.__init__(self, status, conclusion) 

1219 

1220 self._number = number 

1221 

1222 @readonly 

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

1224 """ 

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

1226 

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

1228 """ 

1229 return self._number 

1230 

1231 @classmethod 

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

1233 """ 

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

1235 

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

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

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

1239 :returns: The step. 

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

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

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

1243 """ 

1244 if not isinstance(json, dict): 

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

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

1247 raise ex 

1248 

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

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

1251 

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

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

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

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

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

1257 

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