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

525 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-25 00:39 +0000

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

2# _____ _ _ ____ _ _ _ _ _ # 

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

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

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

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

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

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

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

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

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

15# # 

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

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

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

19# # 

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

21# # 

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

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

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

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

26# limitations under the License. # 

27# # 

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

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

30# 

31""" 

32A data model of a GitHub Actions workflow run. 

33 

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

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

36 

37.. tree:: 

38 

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

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

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

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

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

44 - :class:`MatrixJob` 

45 - :class:`Job` 

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

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

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

49 

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

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

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

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

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

55 

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

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

58""" 

59from __future__ import annotations 

60 

61__author__ = "Patrick Lehmann" 

62__email__ = "Paebbels@gmail.com" 

63__copyright__ = "2026-2026, Patrick Lehmann" 

64__license__ = "Apache License, Version 2.0" 

65__version__ = "0.1.0rc3" 

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

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

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

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

70 

71from datetime import datetime, timezone 

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

73 

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

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

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

77from pyTooling.Common import getFullyQualifiedName, parseISO8601Timestamp, StringEnum 

78from pyTooling.Decorators import export, readonly 

79from pyTooling.GenericPath.URL import URL 

80from pyTooling.MetaClasses import ExtendedType 

81from pyTooling.REST import JSONObject, RESTClient 

82 

83 

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

85 

86 

87@export 

88class GitHubError(CIError): 

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

90 

91 

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

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

94 

95_GITHUB_HEADERS = { 

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

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

98} 

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

100 

101 

102@export 

103class RepositoryClient(RESTClient): 

104 """ 

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

106 

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

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

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

110 """ 

111 

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

113 _repository: str #: Name of the repository. 

114 

115 def __init__( 

116 self, 

117 owner: str, 

118 repository: str, 

119 token: Nullable[str] = None, 

120 *, 

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

122 timeout: float = 30.0, 

123 retries: int = 3, 

124 retryDelay: float = 2.0 

125 ) -> None: 

126 """ 

127 Initializes a client for one repository. 

128 

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

130 :param repository: The name of the repository. 

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

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

133 Default: :data:`GITHUB_API_URL`. 

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

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

136 Default: ``3``. 

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

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

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

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

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

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

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

144 """ 

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

146 

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

148 if text is None: 

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

150 elif not isinstance(text, str): 

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

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

153 raise ex 

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

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

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

157 raise ex 

158 

159 self._owner = owner 

160 self._repository = repository 

161 

162 @readonly 

163 def Owner(self) -> str: 

164 """ 

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

166 

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

168 """ 

169 return self._owner 

170 

171 @readonly 

172 def Repository(self) -> str: 

173 """ 

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

175 

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

177 """ 

178 return self._repository 

179 

180 

181@export 

182class Status(StringEnum): 

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

184 

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

186 InProgress = "in_progress" #: Running. 

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

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

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

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

191 

192 @classmethod 

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

194 """ 

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

196 

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

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

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

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

201 The note lists the documented values. 

202 """ 

203 try: 

204 return super().Parse(value) 

205 except ValueError as ex: 

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

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

208 raise error from ex 

209 

210 

211@export 

212class Conclusion(StringEnum): 

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

214 

215 Success = "success" #: Succeeded. 

216 Failure = "failure" #: Failed. 

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

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

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

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

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

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

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

224 

225 @classmethod 

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

227 """ 

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

229 

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

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

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

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

234 The note lists the documented values. 

235 """ 

236 try: 

237 return super().Parse(value) 

238 except ValueError as ex: 

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

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

241 raise error from ex 

242 

243 def ToOutcome(self) -> Outcome: 

244 """ 

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

246 

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

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

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

250 

251 :returns: The outcome. 

252 """ 

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

254 

255 

256CONCLUSION_TO_OUTCOME = { 

257 Conclusion.Success: Outcome.Success, 

258 Conclusion.Failure: Outcome.Failure, 

259 Conclusion.TimedOut: Outcome.Timeout, 

260 Conclusion.Skipped: Outcome.Skip, 

261 Conclusion.Cancelled: Outcome.Cancellation, 

262} 

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

264 

265 

266@export 

267class Event(StringEnum): 

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

269 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

303 

304 @classmethod 

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

306 """ 

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

308 

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

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

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

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

313 The note lists the documented values. 

314 """ 

315 try: 

316 return super().Parse(value) 

317 except ValueError as ex: 

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

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

320 raise error from ex 

321 

322 

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

324 """ 

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

326 

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

328 

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

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

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

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

333 The note reports the value that was read. 

334 """ 

335 try: 

336 return parseISO8601Timestamp(value, timezone.utc) 

337 except ValueError as ex: 

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

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

340 raise error from ex 

341 

342 

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

344 """ 

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

346 

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

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

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

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

351 """ 

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

353 return None 

354 

355 return URL.Parse(value) 

356 

357 

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

359 """ 

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

361 

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

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

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

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

366 

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

368 

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

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

371 """ 

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

373 return name, None 

374 

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

376 base = base.rstrip() 

377 if base == "": 

378 return name, None 

379 

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

381 

382 

383@export 

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

385 """ 

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

387 

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

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

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

391 """ 

392 

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

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

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

396 

397 def __init__( 

398 self, 

399 status: Nullable[Status] = None, 

400 conclusion: Nullable[Conclusion] = None, 

401 url: Nullable[URL] = None 

402 ) -> None: 

403 """ 

404 Initializes what GitHub reports about an element. 

405 

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

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

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

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

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

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

412 """ 

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

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

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

416 raise ex 

417 

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

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

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

421 raise ex 

422 

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

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

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

426 raise ex 

427 

428 self._status = status 

429 self._conclusion = conclusion 

430 self._url = url 

431 if conclusion is not None: 

432 self._outcome = conclusion.ToOutcome() 

433 

434 @readonly 

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

436 """ 

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

438 

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

440 """ 

441 return self._status 

442 

443 @readonly 

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

445 """ 

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

447 

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

449 :meth:`Conclusion.ToOutcome`. 

450 

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

452 """ 

453 return self._conclusion 

454 

455 @readonly 

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

457 """ 

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

459 

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

461 """ 

462 return self._url 

463 

464 

465@export 

466class PipelineGroup(CIPipelineGroup): 

467 """ 

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

469 

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

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

472 

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

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

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

476 were 

477 

478 .. code-block:: text 

479 

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

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

482 

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

484 

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

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

487 """ 

488 

489 def __init__( 

490 self, 

491 sha: str, 

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

493 *, 

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

495 ) -> None: 

496 """ 

497 Initializes a group of pipelines started for one commit. 

498 

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

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

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

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

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

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

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

506 """ 

507 if sha is None: 

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

509 elif not isinstance(sha, str): 

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

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

512 raise ex 

513 elif sha == "": 

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

515 

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

517 

518 @readonly 

519 def SHA(self) -> str: 

520 """ 

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

522 

523 :returns: The commit's hash. 

524 """ 

525 return self._name 

526 

527 @readonly 

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

529 """ 

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

531 

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

533 rule would. The order is: 

534 

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

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

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

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

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

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

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

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

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

544 

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

546 """ 

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

548 return None 

549 

550 conclusions = set() 

551 for pipeline in self._pipelines: 

552 if pipeline.Conclusion is None: 

553 return None 

554 

555 conclusions.add(pipeline.Conclusion) 

556 

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

558 Conclusion.Failure, Conclusion.TimedOut, Conclusion.StartupFailure, Conclusion.ActionRequired, 

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

560 ): 

561 if conclusion in conclusions: 

562 return conclusion 

563 

564 return None 

565 

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

567 """ 

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

569 

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

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

572 

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

574 """ 

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

576 for pipeline in self._pipelines: 

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

578 pipelines = [] 

579 byReference[pipeline.GitReference] = pipelines 

580 

581 pipelines.append(pipeline) 

582 

583 return byReference 

584 

585 @classmethod 

586 def Parse( 

587 cls, 

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

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

590 sha: Nullable[str] = None 

591 ) -> Self: 

592 """ 

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

594 

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

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

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

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

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

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

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

602 """ 

603 if not isinstance(runs, dict): 

604 workflowRuns = runs 

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

606 workflowRuns = [] 

607 

608 pipelines = [] 

609 shas = set() 

610 for run in workflowRuns: 

611 runJobs = None 

612 if jobs is not None: 

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

614 

615 pipeline = Pipeline.Parse(run, runJobs) 

616 pipelines.append(pipeline) 

617 if pipeline.SHA is not None: 

618 shas.add(pipeline.SHA) 

619 

620 if sha is None: 

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

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

623 elif commits > 1: 

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

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

626 raise error 

627 

628 sha = shas.pop() 

629 

630 return cls(sha, pipelines) 

631 

632 

633@export 

634class Pipeline(CIPipeline, StatusMixin): 

635 """ 

636 A workflow run. 

637 

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

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

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

641 top of the tree. 

642 """ 

643 

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

645 

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

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

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

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

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

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

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

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

654 

655 def __init__( 

656 self, 

657 name: str, 

658 identifier: Nullable[int] = None, 

659 status: Nullable[Status] = None, 

660 conclusion: Nullable[Conclusion] = None, 

661 createdAt: Nullable[datetime] = None, 

662 startedAt: Nullable[datetime] = None, 

663 completedAt: Nullable[datetime] = None, 

664 url: Nullable[URL] = None, 

665 workflowID: Nullable[int] = None, 

666 path: Nullable[str] = None, 

667 runNumber: Nullable[int] = None, 

668 runAttempt: Nullable[int] = None, 

669 event: Nullable[Event] = None, 

670 gitReference: Nullable[str] = None, 

671 sha: Nullable[str] = None, 

672 *, 

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

674 parent: Nullable[PipelineGroup] = None 

675 ) -> None: 

676 """ 

677 Initializes a workflow run. 

678 

679 :param name: Name of the workflow. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

704 """ 

705 for parameterName, number in ( 

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

707 ): 

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

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

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

711 raise ex 

712 

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

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

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

716 raise ex 

717 

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

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

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

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

722 raise ex 

723 

724 super().__init__( 

725 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs, 

726 parent=parent 

727 ) 

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

729 

730 self._id = identifier 

731 self._workflowID = workflowID 

732 self._path = path 

733 self._runNumber = runNumber 

734 self._runAttempt = runAttempt 

735 self._event = event 

736 self._gitReference = gitReference 

737 self._sha = sha 

738 

739 @readonly 

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

741 """ 

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

743 

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

745 """ 

746 return self._id 

747 

748 @readonly 

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

750 """ 

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

752 

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

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

755 

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

757 """ 

758 return self._workflowID 

759 

760 @readonly 

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

762 """ 

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

764 

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

766 

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

768 """ 

769 return self._path 

770 

771 @readonly 

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

773 """ 

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

775 

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

777 """ 

778 return self._runNumber 

779 

780 @readonly 

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

782 """ 

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

784 

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

786 """ 

787 return self._runAttempt 

788 

789 @readonly 

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

791 """ 

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

793 

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

795 """ 

796 return self._event 

797 

798 @readonly 

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

800 """ 

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

802 

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

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

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

806 

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

808 """ 

809 return self._gitReference 

810 

811 @readonly 

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

813 """ 

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

815 

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

817 """ 

818 return self._sha 

819 

820 @classmethod 

821 def Parse( 

822 cls, 

823 run: JSONObject, 

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

825 *, 

826 parent: Nullable[PipelineGroup] = None 

827 ) -> Self: 

828 """ 

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

830 

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

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

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

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

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

836 

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

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

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

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

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

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

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

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

845 """ 

846 if not isinstance(run, dict): 

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

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

849 raise ex 

850 

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

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

853 

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

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

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

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

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

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

860 if status is Status.Completed else None 

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

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

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

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

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

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

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

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

869 

870 pipeline = cls( 

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

872 runAttempt, event, gitReference, sha, parent=parent 

873 ) 

874 

875 if jobs is None: 

876 return pipeline 

877 

878 for position, job in enumerate(jobs): 

879 if not isinstance(job, dict): 

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

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

882 raise ex 

883 

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

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

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

887 

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

889 

890 group: CIWorkflow = pipeline 

891 for caller in callers: 

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

893 callerName, callerDimensions = _splitMatrixJobName(caller) 

894 if callerDimensions is None: 

895 calledWorkflow = CIWorkflow(caller, parent=group) 

896 else: 

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

898 matrix = CIMatrix(callerName, parent=group) 

899 

900 if matrix.ContainsElement(caller): 

901 calledWorkflow = matrix.GetElement(caller) 

902 else: 

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

904 

905 group = calledWorkflow 

906 

907 jobName, dimensions = _splitMatrixJobName(leaf) 

908 if dimensions is None: 

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

910 else: 

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

912 matrix = CIMatrix(jobName, parent=group) 

913 

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

915 

916 return pipeline 

917 

918 

919@export 

920class Job(CIJob, StatusMixin): 

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

922 

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

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

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

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

927 

928 def __init__( 

929 self, 

930 name: str, 

931 identifier: Nullable[int] = None, 

932 status: Nullable[Status] = None, 

933 conclusion: Nullable[Conclusion] = None, 

934 createdAt: Nullable[datetime] = None, 

935 startedAt: Nullable[datetime] = None, 

936 completedAt: Nullable[datetime] = None, 

937 url: Nullable[URL] = None, 

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

939 runnerName: Nullable[str] = None, 

940 runnerGroupName: Nullable[str] = None, 

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

942 *, 

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

944 parent: Nullable[CIJobGroup] = None 

945 ) -> None: 

946 """ 

947 Initializes a job of a workflow run. 

948 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

968 """ 

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

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

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

972 raise ex 

973 

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

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

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

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

978 raise ex 

979 

980 self._labels = labelList = [] 

981 if labels is not None: 

982 for label in labels: 

983 if not isinstance(label, str): 

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

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

986 raise ex 

987 

988 labelList.append(label) 

989 

990 stepList = [] 

991 if steps is not None: 

992 for step in steps: 

993 if not isinstance(step, Step): 

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

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

996 raise ex 

997 

998 stepList.append(step) 

999 

1000 super().__init__( 

1001 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs, 

1002 parent=parent 

1003 ) 

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

1005 

1006 self._id = identifier 

1007 self._runnerName = runnerName 

1008 self._runnerGroupName = runnerGroupName 

1009 

1010 for step in stepList: 

1011 step._parent = self 

1012 step._pipeline = self._pipeline 

1013 self._steps.append(step) 

1014 

1015 @readonly 

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

1017 """ 

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

1019 

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

1021 """ 

1022 return self._id 

1023 

1024 @readonly 

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

1026 """ 

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

1028 

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

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

1031 

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

1033 """ 

1034 return self._labels 

1035 

1036 @readonly 

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

1038 """ 

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

1040 

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

1042 """ 

1043 return self._runnerName 

1044 

1045 @readonly 

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

1047 """ 

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

1049 

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

1051 """ 

1052 return self._runnerGroupName 

1053 

1054 @readonly 

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

1056 """ 

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

1058 

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

1060 """ 

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

1062 return self._createdAt 

1063 

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

1065 

1066 @readonly 

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

1068 """ 

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

1070 

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

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

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

1074 

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

1076 """ 

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

1078 if len(times) == 0: 

1079 return self._startedAt 

1080 

1081 first = min(times) 

1082 

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

1084 

1085 @readonly 

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

1087 """ 

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

1089 

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

1091 job is the timespan that stretches. 

1092 

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

1094 """ 

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

1096 return self._completedAt 

1097 

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

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

1100 return self._completedAt 

1101 

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

1103 

1104 @readonly 

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

1106 """ 

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

1108 

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

1110 """ 

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

1112 return None 

1113 

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

1115 

1116 @classmethod 

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

1118 """ 

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

1120 

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

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

1123 

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

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

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

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

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

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

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

1131 """ 

1132 if not isinstance(json, dict): 

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

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

1135 raise ex 

1136 

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

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

1139 

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

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

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

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

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

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

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

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

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

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

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

1151 

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

1153 steps = None 

1154 else: 

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

1156 

1157 return cls( 

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

1159 runnerGroupName, steps, parent=parent 

1160 ) 

1161 

1162 

1163@export 

1164class MatrixJob(Job, MatrixInstanceMixin): 

1165 """ 

1166 One instance of a job produced by a matrix. 

1167 

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

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

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

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

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

1173 """ 

1174 

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

1176 

1177 def __init__( 

1178 self, 

1179 name: str, 

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

1181 identifier: Nullable[int] = None, 

1182 status: Nullable[Status] = None, 

1183 conclusion: Nullable[Conclusion] = None, 

1184 createdAt: Nullable[datetime] = None, 

1185 startedAt: Nullable[datetime] = None, 

1186 completedAt: Nullable[datetime] = None, 

1187 url: Nullable[URL] = None, 

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

1189 runnerName: Nullable[str] = None, 

1190 runnerGroupName: Nullable[str] = None, 

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

1192 *, 

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

1194 parent: Nullable[CIMatrix] = None 

1195 ) -> None: 

1196 """ 

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

1198 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1214 """ 

1215 super().__init__( 

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

1217 runnerGroupName, steps, keyValuePairs=keyValuePairs, parent=parent 

1218 ) 

1219 MatrixInstanceMixin.__init__(self, dimensions) 

1220 

1221 def __str__(self) -> str: 

1222 """ 

1223 Return a string representation of the matrix instance. 

1224 

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

1226 """ 

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

1228 return self._name 

1229 

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

1231 

1232 @classmethod 

1233 def Parse( 

1234 cls, 

1235 json: JSONObject, 

1236 path: str = "job", 

1237 name: Nullable[str] = None, 

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

1239 *, 

1240 parent: Nullable[CIMatrix] = None 

1241 ) -> Self: 

1242 """ 

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

1244 

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

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

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

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

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

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

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

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

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

1254 """ 

1255 if not isinstance(json, dict): 

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

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

1258 raise ex 

1259 

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

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

1262 

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

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

1265 

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

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

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

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

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

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

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

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

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

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

1276 

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

1278 steps = None 

1279 else: 

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

1281 

1282 return cls( 

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

1284 runnerName, runnerGroupName, steps, parent=parent 

1285 ) 

1286 

1287 

1288@export 

1289class Step(CIStep, StatusMixin): 

1290 """A step within a job.""" 

1291 

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

1293 

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

1295 

1296 def __init__( 

1297 self, 

1298 name: str, 

1299 number: Nullable[int] = None, 

1300 status: Nullable[Status] = None, 

1301 conclusion: Nullable[Conclusion] = None, 

1302 startedAt: Nullable[datetime] = None, 

1303 completedAt: Nullable[datetime] = None, 

1304 *, 

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

1306 parent: Nullable[Job] = None 

1307 ) -> None: 

1308 """ 

1309 Initializes a step within a job. 

1310 

1311 :param name: Name of the step. 

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

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

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

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

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

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

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

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

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

1321 """ 

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

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

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

1325 raise ex 

1326 elif number is not None and number < 1: 

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

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

1329 raise ex 

1330 

1331 super().__init__( 

1332 name, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs, parent=parent 

1333 ) 

1334 StatusMixin.__init__(self, status, conclusion) 

1335 

1336 self._number = number 

1337 

1338 @readonly 

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

1340 """ 

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

1342 

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

1344 """ 

1345 return self._number 

1346 

1347 @classmethod 

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

1349 """ 

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

1351 

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

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

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

1355 :returns: The step. 

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

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

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

1359 """ 

1360 if not isinstance(json, dict): 

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

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

1363 raise ex 

1364 

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

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

1367 

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

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

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

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

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

1373 

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