Coverage for pyTooling/CI/GitHub/__init__.py: 96%
489 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +0000
1# ==================================================================================================================== #
2# _____ _ _ ____ ___ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___|_ _| #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` || | | | #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| || |___ | | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____|___| #
7# |_| |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32A data model of a GitHub Actions workflow run.
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:
37.. code-block:: text
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
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.
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
61from datetime import datetime, timezone
62from typing import Optional as Nullable, Any, ClassVar, Hashable, Iterable, Mapping, Self, Union
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
74__all__ = ["CONCLUSION_TO_OUTCOME"]
77@export
78class GitHubError(CIError):
79 """Base-exception of all exceptions raised by :mod:`pyTooling.CI.GitHub`."""
82@export
83class Status(StringEnum):
84 """The state a workflow run, job or step is in."""
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.
93 @classmethod
94 def Parse(cls, value: Nullable[str]) -> Nullable[Status]:
95 """
96 Convert GitHub's ``status`` field to a member of this enumeration.
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
112@export
113class Conclusion(StringEnum):
114 """How a completed workflow run, job or step ended."""
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.
126 @classmethod
127 def Parse(cls, value: Nullable[str]) -> Nullable[Conclusion]:
128 """
129 Convert GitHub's ``conclusion`` field to a member of this enumeration.
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
144 def ToOutcome(self) -> Outcome:
145 """
146 Return the service-independent outcome this conclusion corresponds to.
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`.
152 :returns: The outcome.
153 """
154 return CONCLUSION_TO_OUTCOME.get(self, Outcome.Error)
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."""
167@export
168class Event(StringEnum):
169 """The event that triggered a workflow run."""
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.
205 @classmethod
206 def Parse(cls, value: Nullable[str]) -> Nullable[Event]:
207 """
208 Convert GitHub's ``event`` field to a member of this enumeration.
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
224def _parseISO8601Timestamp(value: Nullable[str], field: str) -> Nullable[datetime]:
225 """
226 Parse an ISO 8601 timestamp, as the GitHub REST API reports them.
228 A timestamp without a time zone is read as UTC, so every timestamp of a run can be compared with every other.
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
244def _parseURL(value: Nullable[str], field: str) -> Nullable[URL]:
245 """
246 Parse a URL, as the GitHub REST API reports them.
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
256 return URL.Parse(value)
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.
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.
268 The suffix carries no dimension names, so a dimension is named by the position of its value: ``"0"``, ``"1"``, ...
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
276 base, _, values = name[:-1].rpartition("(")
277 base = base.rstrip()
278 if base == "":
279 return name, None
281 return base, {str(position): value.strip() for position, value in enumerate(values.split(","))}
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.
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 """
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.
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.
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
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
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
329 self._status = status
330 self._conclusion = conclusion
331 self._url = url
332 if conclusion is not None:
333 self._outcome = conclusion.ToOutcome()
335 @readonly
336 def Status(self) -> Nullable[Status]:
337 """
338 Read-only property to access the state the element is in (:attr:`_status`).
340 :returns: The state, or ``None`` if GitHub reported none.
341 """
342 return self._status
344 @readonly
345 def Conclusion(self) -> Nullable[Conclusion]:
346 """
347 Read-only property to access how the element ended (:attr:`_conclusion`).
349 The service-independent :attr:`~pyTooling.CI.Base.Outcome` is derived from it by
350 :meth:`Conclusion.ToOutcome`.
352 :returns: The conclusion, or ``None`` while the element hasn't concluded.
353 """
354 return self._conclusion
356 @readonly
357 def URL(self) -> Nullable[URL]:
358 """
359 Read-only property to access the element's URL on github.com (:attr:`_url`).
361 :returns: The URL, or ``None`` if GitHub reported none.
362 """
363 return self._url
366@export
367class PipelineGroup(CIPipelineGroup):
368 """
369 Every workflow run GitHub started for one commit, and the top of the tree.
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.
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
379 .. code-block:: text
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
384 :meth:`ByGitReference` separates them, since :attr:`Pipeline.GitReference` holds the tag's name for the second.
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 """
390 def __init__(
391 self,
392 sha: str,
393 pipelines: Nullable[Iterable[Pipeline]] = None,
394 *,
395 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None
396 ) -> None:
397 """
398 Initializes a group of pipelines started for one commit.
400 :param sha: Commit every pipeline of the group was started on.
401 :param pipelines: Optional, the pipelines, which are attached to the group. Default: ``None``.
402 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
403 :raises ValueError: If parameter 'sha' is ``None``.
404 :raises TypeError: If parameter 'sha' is not of type :class:`str`.
405 :raises ValueError: If parameter 'sha' is empty.
406 :raises TypeError: If an element of parameter 'pipelines' is not of type :class:`Pipeline`.
407 """
408 if sha is None:
409 raise ValueError("Parameter 'sha' is None.")
410 elif not isinstance(sha, str):
411 ex = TypeError("Parameter 'sha' is not of type 'str'.")
412 ex.add_note(f"Got type '{getFullyQualifiedName(sha)}'.")
413 raise ex
414 elif sha == "":
415 raise ValueError("Parameter 'sha' is empty.")
417 super().__init__(sha, pipelines, keyValuePairs=keyValuePairs)
419 @readonly
420 def SHA(self) -> str:
421 """
422 Read-only property to access the commit every pipeline of the group was started on (:attr:`_name`).
424 :returns: The commit's hash.
425 """
426 return self._name
428 @readonly
429 def Conclusion(self) -> Nullable[Conclusion]:
430 """
431 Read-only property to return how the commit's pipelines ended, taken together.
433 The worst conclusion wins, so one failed pipeline makes the commit's verdict a failure, as a branch protection
434 rule would. The order is:
436 #. :attr:`~Conclusion.Failure`
437 #. :attr:`~Conclusion.TimedOut`
438 #. :attr:`~Conclusion.StartupFailure`
439 #. :attr:`~Conclusion.ActionRequired`
440 #. :attr:`~Conclusion.Cancelled`
441 #. :attr:`~Conclusion.Stale`
442 #. :attr:`~Conclusion.Neutral`
443 #. :attr:`~Conclusion.Skipped`
444 #. :attr:`~Conclusion.Success`
446 :returns: The worst conclusion of the group's pipelines, or ``None`` while one hasn't concluded.
447 """
448 if len(self._pipelines) == 0: 448 ↛ 449line 448 didn't jump to line 449 because the condition on line 448 was never true
449 return None
451 conclusions = set()
452 for pipeline in self._pipelines:
453 if pipeline.Conclusion is None:
454 return None
456 conclusions.add(pipeline.Conclusion)
458 for conclusion in ( 458 ↛ 465line 458 didn't jump to line 465 because the loop on line 458 didn't complete
459 Conclusion.Failure, Conclusion.TimedOut, Conclusion.StartupFailure, Conclusion.ActionRequired,
460 Conclusion.Cancelled, Conclusion.Stale, Conclusion.Neutral, Conclusion.Skipped, Conclusion.Success
461 ):
462 if conclusion in conclusions:
463 return conclusion
465 return None
467 def ByGitReference(self) -> dict[Nullable[str], list[Pipeline]]:
468 """
469 Group the commit's pipelines by the branch or tag they were started on.
471 A commit pushed to a branch and later tagged has its pipelines under two keys - the branch's name and the
472 tag's - which is what separates the checks of a commit from the run that published its release.
474 :returns: Dictionary of a reference's name to the pipelines started on it, in the order they were reported.
475 """
476 byReference: dict[Nullable[str], list[Pipeline]] = {}
477 for pipeline in self._pipelines:
478 if (pipelines := byReference.get(pipeline.GitReference, None)) is None: 478 ↛ 482line 478 didn't jump to line 482 because the condition on line 478 was always true
479 pipelines = []
480 byReference[pipeline.GitReference] = pipelines
482 pipelines.append(pipeline)
484 return byReference
486 @classmethod
487 def FromJSON(
488 cls,
489 runs: Union[JSONObject, Iterable[JSONObject]],
490 jobs: Nullable[dict[int, Iterable[JSONObject]]] = None,
491 sha: Nullable[str] = None
492 ) -> Self:
493 """
494 Build a group of pipelines from the JSON objects the GitHub REST API answers with.
496 :param runs: The runs, as returned by ``GET /repos/{owner}/{repo}/actions/runs?head_sha=...`` - either
497 the answer itself or its ``workflow_runs`` array.
498 :param jobs: Optional, the jobs of each run, by the run's identifier. Default: ``None``.
499 :param sha: Optional, the commit. Default: the ``head_sha`` the runs report.
500 :returns: The group, with its pipelines attached.
501 :raises GitHubError: If the runs report different commits.
502 :raises GitHubError: If no run reports a commit and parameter 'sha' wasn't given.
503 """
504 if not isinstance(runs, dict):
505 workflowRuns = runs
506 elif (workflowRuns := runs.get("workflow_runs", None)) is None: 506 ↛ 507line 506 didn't jump to line 507 because the condition on line 506 was never true
507 workflowRuns = []
509 pipelines = []
510 shas = set()
511 for run in workflowRuns:
512 runJobs = None
513 if jobs is not None:
514 runJobs = jobs.get(run.get("id", None), None)
516 pipeline = Pipeline.FromJSON(run, runJobs)
517 pipelines.append(pipeline)
518 if pipeline.SHA is not None:
519 shas.add(pipeline.SHA)
521 if sha is None:
522 if (commits := len(shas)) == 0:
523 raise GitHubError("None of the runs reports a 'head_sha', and parameter 'sha' wasn't given.")
524 elif commits > 1:
525 error = GitHubError("The runs report different commits.")
526 error.add_note(f"Got {', '.join(sorted(shas))}.")
527 raise error
529 sha = shas.pop()
531 return cls(sha, pipelines)
534@export
535class Pipeline(CIPipeline, StatusMixin):
536 """
537 A workflow run.
539 A run contains its own jobs, a :class:`~pyTooling.CI.Matrix` for every matrix, and a
540 :class:`~pyTooling.CI.Workflow` for every workflow it called. Unlike a called workflow, a run reports its
541 own status, conclusion and times. Several runs of one commit are held by a :class:`PipelineGroup`, which is the
542 top of the tree.
543 """
545 _PARENT_TYPE: ClassVar[Nullable[type]] = PipelineGroup #: A workflow run is contained in a pipeline group.
547 _id: Nullable[int] #: GitHub's identifier of the run.
548 _workflowID: Nullable[int] #: GitHub's identifier of the workflow the run belongs to.
549 _path: Nullable[str] #: Path of the workflow's YAML file in the repository.
550 _runNumber: Nullable[int] #: Number of the run within its workflow.
551 _runAttempt: Nullable[int] #: Attempt of the run, starting at 1.
552 _event: Nullable[Event] #: Event that triggered the run.
553 _gitReference: Nullable[str] #: Branch or tag the run was started on.
554 _sha: Nullable[str] #: Commit the run was started on.
556 def __init__(
557 self,
558 name: str,
559 identifier: Nullable[int] = None,
560 status: Nullable[Status] = None,
561 conclusion: Nullable[Conclusion] = None,
562 createdAt: Nullable[datetime] = None,
563 startedAt: Nullable[datetime] = None,
564 completedAt: Nullable[datetime] = None,
565 url: Nullable[URL] = None,
566 workflowID: Nullable[int] = None,
567 path: Nullable[str] = None,
568 runNumber: Nullable[int] = None,
569 runAttempt: Nullable[int] = None,
570 event: Nullable[Event] = None,
571 gitReference: Nullable[str] = None,
572 sha: Nullable[str] = None,
573 *,
574 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
575 parent: Nullable[PipelineGroup] = None
576 ) -> None:
577 """
578 Initializes a workflow run.
580 :param name: Name of the workflow.
581 :param identifier: Optional, GitHub's identifier of the run. Default: ``None``.
582 :param status: Optional, state the run is in. Default: ``None``.
583 :param conclusion: Optional, how the run ended. Default: ``None``.
584 :param createdAt: Optional, time the run was created. Default: ``None``.
585 :param startedAt: Optional, time the run started. Default: ``None``.
586 :param completedAt: Optional, time the run was last updated, once completed. Default: ``None``.
587 :param url: Optional, URL of the run on github.com. Default: ``None``.
588 :param workflowID: Optional, GitHub's identifier of the workflow the run belongs to. Default: ``None``.
589 :param path: Optional, path of the workflow's YAML file in the repository. Default: ``None``.
590 :param runNumber: Optional, number of the run within its workflow. Default: ``None``.
591 :param runAttempt: Optional, attempt of the run, starting at 1. Default: ``None``.
592 :param event: Optional, event that triggered the run. Default: ``None``.
593 :param gitReference: Optional, branch or tag the run was started on. Default: ``None``.
594 :param sha: Optional, commit the run was started on. Default: ``None``.
595 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
596 :param parent: Optional, reference to the group of the commit's runs. Default: ``None``.
597 :raises TypeError: If parameter 'identifier' is not of type :class:`int`.
598 :raises TypeError: If parameter 'workflowID' is not of type :class:`int`.
599 :raises TypeError: If parameter 'path' is not of type :class:`str`.
600 :raises TypeError: If parameter 'runNumber' is not of type :class:`int`.
601 :raises TypeError: If parameter 'runAttempt' is not of type :class:`int`.
602 :raises TypeError: If parameter 'event' is not of type :class:`Event`.
603 :raises TypeError: If parameter 'gitReference' is not of type :class:`str`.
604 :raises TypeError: If parameter 'sha' is not of type :class:`str`.
605 """
606 for parameterName, number in (
607 ("identifier", identifier), ("workflowID", workflowID), ("runNumber", runNumber), ("runAttempt", runAttempt)
608 ):
609 if number is not None and not isinstance(number, int):
610 ex = TypeError(f"Parameter '{parameterName}' is not of type 'int'.")
611 ex.add_note(f"Got type '{getFullyQualifiedName(number)}'.")
612 raise ex
614 if event is not None and not isinstance(event, Event):
615 ex = TypeError("Parameter 'event' is not of type 'Event'.")
616 ex.add_note(f"Got type '{getFullyQualifiedName(event)}'.")
617 raise ex
619 for parameterName, text in (("path", path), ("gitReference", gitReference), ("sha", sha)):
620 if text is not None and not isinstance(text, str):
621 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
622 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.")
623 raise ex
625 super().__init__(
626 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs,
627 parent=parent
628 )
629 StatusMixin.__init__(self, status, conclusion, url)
631 self._id = identifier
632 self._workflowID = workflowID
633 self._path = path
634 self._runNumber = runNumber
635 self._runAttempt = runAttempt
636 self._event = event
637 self._gitReference = gitReference
638 self._sha = sha
640 @readonly
641 def ID(self) -> Nullable[int]:
642 """
643 Read-only property to access GitHub's identifier of the run (:attr:`_id`).
645 :returns: The identifier, or ``None`` if GitHub reported none.
646 """
647 return self._id
649 @readonly
650 def WorkflowID(self) -> Nullable[int]:
651 """
652 Read-only property to access GitHub's identifier of the workflow the run belongs to (:attr:`_workflowID`).
654 Every run of the same workflow file reports the same identifier, so it groups a workflow's runs over time,
655 where :attr:`ID` identifies the single run.
657 :returns: The identifier, or ``None`` if GitHub reported none.
658 """
659 return self._workflowID
661 @readonly
662 def Path(self) -> Nullable[str]:
663 """
664 Read-only property to access the path of the workflow's YAML file (:attr:`_path`).
666 GitHub reports it relative to the repository's root, e.g. ``.github/workflows/Pipeline.yml``.
668 :returns: The path, or ``None`` if GitHub reported none.
669 """
670 return self._path
672 @readonly
673 def RunNumber(self) -> Nullable[int]:
674 """
675 Read-only property to access the run's number within its workflow (:attr:`_runNumber`).
677 :returns: The number, or ``None`` if GitHub reported none.
678 """
679 return self._runNumber
681 @readonly
682 def RunAttempt(self) -> Nullable[int]:
683 """
684 Read-only property to access which attempt of the run this is (:attr:`_runAttempt`).
686 :returns: The attempt, starting at 1, or ``None`` if GitHub reported none.
687 """
688 return self._runAttempt
690 @readonly
691 def Event(self) -> Nullable[Event]:
692 """
693 Read-only property to access the event that triggered the run (:attr:`_event`).
695 :returns: The event, or ``None`` if GitHub reported none.
696 """
697 return self._event
699 @readonly
700 def GitReference(self) -> Nullable[str]:
701 """
702 Read-only property to access the branch or tag the run was started on (:attr:`_gitReference`).
704 GitHub reports this as ``head_branch`` and puts the **tag's** name there for a run started at a tag, with no
705 field saying which it is - a run of `pyTooling/MiKTeX` v1.6.0 reports ``main``, and the run at the tag of the
706 very same commit reports ``v1.6.0``. :attr:`Event` is the other half of telling them apart.
708 :returns: The branch's or tag's name, or ``None`` if GitHub reported none.
709 """
710 return self._gitReference
712 @readonly
713 def SHA(self) -> Nullable[str]:
714 """
715 Read-only property to access the commit the run was started on (:attr:`_sha`).
717 :returns: The commit's hash, or ``None`` if GitHub reported none.
718 """
719 return self._sha
721 @classmethod
722 def FromJSON(
723 cls,
724 run: JSONObject,
725 jobs: Nullable[Iterable[JSONObject]] = None,
726 *,
727 parent: Nullable[PipelineGroup] = None
728 ) -> Self:
729 """
730 Build a workflow run and its tree from the JSON objects the GitHub REST API answers with.
732 A job's name says where it sits, and is read back into the tree: ``Docs / Sphinx / HTML`` nests below a
733 :class:`~pyTooling.CI.Workflow` per prefix, and ``Unit Tests (ubuntu-26.04, 3.14)`` becomes a
734 :class:`MatrixJob` below a :class:`~pyTooling.CI.Matrix` named ``Unit Tests``. A prefix carrying
735 dimension values - ``Tests (3.14) / Unit``, a matrix of calls of a reusable workflow - becomes a
736 :class:`~pyTooling.CI.MatrixWorkflow` below a :class:`~pyTooling.CI.Matrix` named ``Tests``.
738 :param run: The workflow run, as returned by ``GET /repos/{owner}/{repo}/actions/runs/{run_id}``.
739 :param jobs: Optional, the run's jobs, as listed by ``GET .../actions/runs/{run_id}/jobs``.
740 :param parent: Optional, reference to the group of the commit's runs. Default: ``None``.
741 :returns: The workflow run, with its workflows, matrices, jobs and steps attached.
742 :raises TypeError: If parameter 'run' is not of type :class:`dict`.
743 :raises TypeError: If an element of parameter 'jobs' is not of type :class:`dict`.
744 :raises GitHubError: If field ``name`` is missing.
745 :raises GitHubError: If a field holds a value GitHub doesn't document.
746 """
747 if not isinstance(run, dict):
748 ex = TypeError("Parameter 'run' is not of type 'dict'.")
749 ex.add_note(f"Got type '{getFullyQualifiedName(run)}'.")
750 raise ex
752 if (name := run.get("name", None)) is None:
753 raise GitHubError("Field 'run.name' is missing.")
755 identifier = run.get("id", None)
756 status = Status.Parse(run.get("status", None))
757 conclusion = Conclusion.Parse(run.get("conclusion", None))
758 createdAt = _parseISO8601Timestamp(run.get("created_at", None), "run.created_at")
759 startedAt = _parseISO8601Timestamp(run.get("run_started_at", None), "run.run_started_at")
760 completedAt = _parseISO8601Timestamp(run.get("updated_at", None), "run.updated_at") \
761 if status is Status.Completed else None
762 url = _parseURL(run.get("html_url", None), "run.html_url")
763 workflowID = run.get("workflow_id", None)
764 path = run.get("path", None)
765 runNumber = run.get("run_number", None)
766 runAttempt = run.get("run_attempt", None)
767 event = Event.Parse(run.get("event", None))
768 gitReference = run.get("head_branch", None)
769 sha = run.get("head_sha", None)
771 pipeline = cls(
772 name, identifier, status, conclusion, createdAt, startedAt, completedAt, url, workflowID, path, runNumber,
773 runAttempt, event, gitReference, sha, parent=parent
774 )
776 if jobs is None:
777 return pipeline
779 for position, job in enumerate(jobs):
780 if not isinstance(job, dict):
781 ex = TypeError(f"Job {position} is not of type 'dict'.")
782 ex.add_note(f"Got type '{getFullyQualifiedName(job)}'.")
783 raise ex
785 path = f"jobs[{position}]"
786 if (fullName := job.get("name", None)) is None:
787 raise GitHubError(f"Field '{path}.name' is missing.")
789 *callers, leaf = fullName.split(" / ")
791 group: CIWorkflow = pipeline
792 for caller in callers:
793 if (calledWorkflow := group.Workflows.get(caller, None)) is None:
794 callerName, callerDimensions = _splitMatrixJobName(caller)
795 if callerDimensions is None:
796 calledWorkflow = CIWorkflow(caller, parent=group)
797 else:
798 if (matrix := group.Matrices.get(callerName, None)) is None:
799 matrix = CIMatrix(callerName, parent=group)
801 if matrix.ContainsElement(caller):
802 calledWorkflow = matrix.GetElement(caller)
803 else:
804 calledWorkflow = CIMatrixWorkflow(callerName, callerDimensions, parent=matrix)
806 group = calledWorkflow
808 jobName, dimensions = _splitMatrixJobName(leaf)
809 if dimensions is None:
810 Job.FromJSON(job, path, parent=group)
811 else:
812 if (matrix := group.Matrices.get(jobName, None)) is None:
813 matrix = CIMatrix(jobName, parent=group)
815 MatrixJob.FromJSON(job, path, jobName, dimensions, parent=matrix)
817 return pipeline
820@export
821class Job(CIJob, StatusMixin):
822 """A job of a workflow run, which ran on a runner and contains steps."""
824 _id: Nullable[int] #: GitHub's identifier of the job.
825 _labels: list[str] #: Labels the job requested its runner by, i.e. its ``runs-on``.
826 _runnerName: Nullable[str] #: Name of the runner the job ran on.
827 _runnerGroupName: Nullable[str] #: Name of the runner group the runner belongs to.
829 def __init__(
830 self,
831 name: str,
832 identifier: Nullable[int] = None,
833 status: Nullable[Status] = None,
834 conclusion: Nullable[Conclusion] = None,
835 createdAt: Nullable[datetime] = None,
836 startedAt: Nullable[datetime] = None,
837 completedAt: Nullable[datetime] = None,
838 url: Nullable[URL] = None,
839 labels: Nullable[Iterable[str]] = None,
840 runnerName: Nullable[str] = None,
841 runnerGroupName: Nullable[str] = None,
842 steps: Nullable[Iterable[Step]] = None,
843 *,
844 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
845 parent: Nullable[CIJobGroup] = None
846 ) -> None:
847 """
848 Initializes a job of a workflow run.
850 :param name: Name of the job, without the calling workflows' prefixes.
851 :param identifier: Optional, GitHub's identifier of the job. Default: ``None``.
852 :param status: Optional, state the job is in. Default: ``None``.
853 :param conclusion: Optional, how the job ended. Default: ``None``.
854 :param createdAt: Optional, time the job was created, i.e. queued for a runner. Default: ``None``.
855 :param startedAt: Optional, time the job started running on a runner. Default: ``None``.
856 :param completedAt: Optional, time the job completed. Default: ``None``.
857 :param url: Optional, URL of the job on github.com. Default: ``None``.
858 :param labels: Optional, labels the job requested its runner by. Default: ``None``.
859 :param runnerName: Optional, name of the runner the job ran on. Default: ``None``.
860 :param runnerGroupName: Optional, name of the runner group the runner belongs to. Default: ``None``.
861 :param steps: Optional, the job's steps, which are attached to it. Default: ``None``.
862 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
863 :param parent: Optional, reference to the group containing the job. Default: ``None``.
864 :raises TypeError: If parameter 'identifier' is not of type :class:`int`.
865 :raises TypeError: If an element of parameter 'steps' is not of type :class:`Step`.
866 :raises TypeError: If an element of parameter 'labels' is not of type :class:`str`.
867 :raises TypeError: If parameter 'runnerName' is not of type :class:`str`.
868 :raises TypeError: If parameter 'runnerGroupName' is not of type :class:`str`.
869 """
870 if identifier is not None and not isinstance(identifier, int):
871 ex = TypeError("Parameter 'identifier' is not of type 'int'.")
872 ex.add_note(f"Got type '{getFullyQualifiedName(identifier)}'.")
873 raise ex
875 for parameterName, value in (("runnerName", runnerName), ("runnerGroupName", runnerGroupName)):
876 if value is not None and not isinstance(value, str):
877 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
878 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
879 raise ex
881 self._labels = []
882 if labels is not None:
883 for label in labels:
884 if not isinstance(label, str):
885 ex = TypeError("An element of parameter 'labels' is not of type 'str'.")
886 ex.add_note(f"Got type '{getFullyQualifiedName(label)}'.")
887 raise ex
889 self._labels.append(label)
891 stepList = []
892 if steps is not None:
893 for step in steps:
894 if not isinstance(step, Step):
895 ex = TypeError("An element of parameter 'steps' is not of type 'Step'.")
896 ex.add_note(f"Got type '{getFullyQualifiedName(step)}'.")
897 raise ex
899 stepList.append(step)
901 super().__init__(
902 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs,
903 parent=parent
904 )
905 StatusMixin.__init__(self, status, conclusion, url)
907 self._id = identifier
908 self._runnerName = runnerName
909 self._runnerGroupName = runnerGroupName
911 for step in stepList:
912 step._parent = self
913 step._pipeline = self._pipeline
914 self._steps.append(step)
916 @readonly
917 def ID(self) -> Nullable[int]:
918 """
919 Read-only property to access GitHub's identifier of the job (:attr:`_id`).
921 :returns: The identifier, or ``None`` if GitHub reported none.
922 """
923 return self._id
925 @readonly
926 def Labels(self) -> list[str]:
927 """
928 Read-only property to access the labels the job requested its runner by (:attr:`_labels`).
930 These are the values of the workflow's ``runs-on``. For a hosted runner a label doubles as the image's name
931 (``ubuntu-26.04``); for a self-hosted runner they are the tags the runner was registered with.
933 :returns: The labels, empty if GitHub reported none.
934 """
935 return self._labels
937 @readonly
938 def RunnerName(self) -> Nullable[str]:
939 """
940 Read-only property to access the name of the runner the job ran on (:attr:`_runnerName`).
942 :returns: The runner's name, or ``None`` while the job hasn't started.
943 """
944 return self._runnerName
946 @readonly
947 def RunnerGroupName(self) -> Nullable[str]:
948 """
949 Read-only property to access the name of the runner group the runner belongs to (:attr:`_runnerGroupName`).
951 :returns: The group's name, or ``None`` if GitHub reported none.
952 """
953 return self._runnerGroupName
955 @readonly
956 def CreatedAt(self) -> Nullable[datetime]:
957 """
958 Read-only property to return the time the job was created, at the latest when it started.
960 :returns: The time, or ``None`` if GitHub reported none.
961 """
962 if self._createdAt is None or all(step.StartedAt is None for step in self._steps):
963 return self._createdAt
965 return min(self._createdAt, self.StartedAt)
967 @readonly
968 def StartedAt(self) -> Nullable[datetime]:
969 """
970 Read-only property to return the time the job started, at the latest when its first step started.
972 GitHub reports a job's times and its steps' times independently and in whole seconds, so a step is sometimes
973 reported as starting before the job containing it. The step really did run then, so the job is the timespan
974 that stretches - otherwise a consumer building a tree has a child outside its parent.
976 :returns: The time, or ``None`` while neither the job nor a step of it has started.
977 """
978 times = [step.StartedAt for step in self._steps if step.StartedAt is not None]
979 if len(times) == 0:
980 return self._startedAt
982 first = min(times)
984 return first if self._startedAt is None else min(self._startedAt, first)
986 @readonly
987 def CompletedAt(self) -> Nullable[datetime]:
988 """
989 Read-only property to return the time the job completed, at the earliest when its last step completed.
991 A job that hasn't completed reports no time, even when a step of it has - see :attr:`StartedAt` for why the
992 job is the timespan that stretches.
994 :returns: The time, or ``None`` while the job hasn't completed.
995 """
996 if self._completedAt is None or all(step.StartedAt is None for step in self._steps):
997 return self._completedAt
999 times = [step.CompletedAt for step in self._steps if step.CompletedAt is not None]
1000 if len(times) == 0: 1000 ↛ 1001line 1000 didn't jump to line 1001 because the condition on line 1000 was never true
1001 return self._completedAt
1003 return max(self._completedAt, max(times))
1005 @readonly
1006 def QueuedDuration(self) -> Nullable[float]:
1007 """
1008 Read-only property to return how long the job waited for a runner.
1010 :returns: Seconds from being created to starting, or ``None`` while either time is unknown.
1011 """
1012 if self._createdAt is None or self._startedAt is None:
1013 return None
1015 return (self._startedAt - self._createdAt).total_seconds()
1017 @classmethod
1018 def FromJSON(cls, json: JSONObject, path: str = "job", *, parent: Nullable[CIJobGroup] = None) -> Self:
1019 """
1020 Build a job and its steps from the JSON object the GitHub REST API answers with.
1022 The job's name is taken without the calling workflows' prefixes: a job reported as ``Caller / Build`` is named
1023 ``Build``, because the prefix describes where it sits, which the tree already says.
1025 :param json: The job, as listed by ``GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs``.
1026 :param path: Optional, position of the job, for an exception's message. Default: ``'job'``.
1027 :param parent: Optional, reference to the group containing the job. Default: ``None``.
1028 :returns: The job, with its steps attached.
1029 :raises TypeError: If parameter 'json' is not of type :class:`dict`.
1030 :raises GitHubError: If field ``name`` is missing.
1031 :raises GitHubError: If a field holds a value GitHub doesn't document.
1032 """
1033 if not isinstance(json, dict):
1034 ex = TypeError("Parameter 'json' is not of type 'dict'.")
1035 ex.add_note(f"Got type '{getFullyQualifiedName(json)}'.")
1036 raise ex
1038 if (fullName := json.get("name", None)) is None: 1038 ↛ 1039line 1038 didn't jump to line 1039 because the condition on line 1038 was never true
1039 raise GitHubError(f"Field '{path}.name' is missing.")
1041 name = fullName.rsplit(" / ", 1)[-1]
1042 identifier = json.get("id", None)
1043 status = Status.Parse(json.get("status", None))
1044 conclusion = Conclusion.Parse(json.get("conclusion", None))
1045 createdAt = _parseISO8601Timestamp(json.get("created_at", None), f"{path}.created_at")
1046 startedAt = _parseISO8601Timestamp(json.get("started_at", None), f"{path}.started_at")
1047 completedAt = _parseISO8601Timestamp(json.get("completed_at", None), f"{path}.completed_at")
1048 url = _parseURL(json.get("html_url", None), f"{path}.html_url")
1049 labels = json.get("labels", None)
1050 runnerName = json.get("runner_name", None)
1051 runnerGroupName = json.get("runner_group_name", None)
1053 if (jsonSteps := json.get("steps", None)) is None: 1053 ↛ 1054line 1053 didn't jump to line 1054 because the condition on line 1053 was never true
1054 steps = None
1055 else:
1056 steps = [Step.FromJSON(step, f"{path}.steps[{pos}]") for pos, step in enumerate(jsonSteps)]
1058 return cls(
1059 name, identifier, status, conclusion, createdAt, startedAt, completedAt, url, labels, runnerName,
1060 runnerGroupName, steps, parent=parent
1061 )
1064@export
1065class MatrixJob(Job, MatrixInstanceMixin):
1066 """
1067 One instance of a job produced by a matrix.
1069 GitHub reports a matrix instance as an ordinary job whose name carries the dimensions' values in brackets, e.g.
1070 ``Unit Tests (ubuntu-26.04, 3.14)``. :meth:`Pipeline.FromJSON` reads those back into :attr:`Dimensions` and groups
1071 the instances below a :class:`~pyTooling.CI.Matrix`. The values are in the order GitHub prints them. The
1072 job's payload names no dimension - only the workflow file does -, so a dimension's name is the position of its
1073 value, ``{"0": "ubuntu-26.04", "1": "3.14"}``, until the names are known.
1074 """
1076 _PARENT_TYPE: ClassVar[Nullable[type]] = CIMatrix #: A matrix instance is contained in a matrix.
1078 def __init__(
1079 self,
1080 name: str,
1081 dimensions: Nullable[Mapping[str, Any]] = None,
1082 identifier: Nullable[int] = None,
1083 status: Nullable[Status] = None,
1084 conclusion: Nullable[Conclusion] = None,
1085 createdAt: Nullable[datetime] = None,
1086 startedAt: Nullable[datetime] = None,
1087 completedAt: Nullable[datetime] = None,
1088 url: Nullable[URL] = None,
1089 labels: Nullable[Iterable[str]] = None,
1090 runnerName: Nullable[str] = None,
1091 runnerGroupName: Nullable[str] = None,
1092 steps: Nullable[Iterable[Step]] = None,
1093 *,
1094 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
1095 parent: Nullable[CIMatrix] = None
1096 ) -> None:
1097 """
1098 Initializes one instance of a job produced by a matrix.
1100 :param name: Name of the job, without the dimensions' values.
1101 :param dimensions: Optional, the dimensions' names and values this instance ran with. Default: ``None``.
1102 :param identifier: Optional, GitHub's identifier of the job. Default: ``None``.
1103 :param status: Optional, state the job is in. Default: ``None``.
1104 :param conclusion: Optional, how the job ended. Default: ``None``.
1105 :param createdAt: Optional, time the job was created, i.e. queued for a runner. Default: ``None``.
1106 :param startedAt: Optional, time the job started running on a runner. Default: ``None``.
1107 :param completedAt: Optional, time the job completed. Default: ``None``.
1108 :param url: Optional, URL of the job on github.com. Default: ``None``.
1109 :param labels: Optional, labels the job requested its runner by. Default: ``None``.
1110 :param runnerName: Optional, name of the runner the job ran on. Default: ``None``.
1111 :param runnerGroupName: Optional, name of the runner group the runner belongs to. Default: ``None``.
1112 :param steps: Optional, the job's steps, which are attached to it. Default: ``None``.
1113 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
1114 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``.
1115 """
1116 super().__init__(
1117 name, identifier, status, conclusion, createdAt, startedAt, completedAt, url, labels, runnerName,
1118 runnerGroupName, steps, keyValuePairs=keyValuePairs, parent=parent
1119 )
1120 MatrixInstanceMixin.__init__(self, dimensions)
1122 def __str__(self) -> str:
1123 """
1124 Return a string representation of the matrix instance.
1126 :returns: The job's name with its dimensions' values, as GitHub prints it.
1127 """
1128 if len(self._dimensions) == 0: 1128 ↛ 1129line 1128 didn't jump to line 1129 because the condition on line 1128 was never true
1129 return self._name
1131 return f"{self._name} ({', '.join(str(value) for value in self._dimensions.values())})"
1133 @classmethod
1134 def FromJSON(
1135 cls,
1136 json: JSONObject,
1137 path: str = "job",
1138 name: Nullable[str] = None,
1139 dimensions: Nullable[Mapping[str, Any]] = None,
1140 *,
1141 parent: Nullable[CIMatrix] = None
1142 ) -> Self:
1143 """
1144 Build a matrix instance and its steps from the JSON object the GitHub REST API answers with.
1146 :param json: The job, as listed by ``GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs``.
1147 :param path: Optional, position of the job, for an exception's message. Default: ``'job'``.
1148 :param name: Optional, the job's name without the dimensions' values. Default: read from the payload.
1149 :param dimensions: Optional, the dimensions' names and values. Default: read from the payload.
1150 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``.
1151 :returns: The matrix instance, with its steps attached.
1152 :raises TypeError: If parameter 'json' is not of type :class:`dict`.
1153 :raises GitHubError: If field ``name`` is missing.
1154 :raises GitHubError: If a field holds a value GitHub doesn't document.
1155 """
1156 if not isinstance(json, dict):
1157 ex = TypeError("Parameter 'json' is not of type 'dict'.")
1158 ex.add_note(f"Got type '{getFullyQualifiedName(json)}'.")
1159 raise ex
1161 if (fullName := json.get("name", None)) is None: 1161 ↛ 1162line 1161 didn't jump to line 1162 because the condition on line 1161 was never true
1162 raise GitHubError(f"Field '{path}.name' is missing.")
1164 if name is None: 1164 ↛ 1165line 1164 didn't jump to line 1165 because the condition on line 1164 was never true
1165 name, dimensions = _splitMatrixJobName(fullName.rsplit(" / ", 1)[-1])
1167 identifier = json.get("id", None)
1168 status = Status.Parse(json.get("status", None))
1169 conclusion = Conclusion.Parse(json.get("conclusion", None))
1170 createdAt = _parseISO8601Timestamp(json.get("created_at", None), f"{path}.created_at")
1171 startedAt = _parseISO8601Timestamp(json.get("started_at", None), f"{path}.started_at")
1172 completedAt = _parseISO8601Timestamp(json.get("completed_at", None), f"{path}.completed_at")
1173 url = _parseURL(json.get("html_url", None), f"{path}.html_url")
1174 labels = json.get("labels", None)
1175 runnerName = json.get("runner_name", None)
1176 runnerGroupName = json.get("runner_group_name", None)
1178 if (jsonSteps := json.get("steps", None)) is None: 1178 ↛ 1179line 1178 didn't jump to line 1179 because the condition on line 1178 was never true
1179 steps = None
1180 else:
1181 steps = [Step.FromJSON(step, f"{path}.steps[{pos}]") for pos, step in enumerate(jsonSteps)]
1183 return cls(
1184 name, dimensions, identifier, status, conclusion, createdAt, startedAt, completedAt, url, labels,
1185 runnerName, runnerGroupName, steps, parent=parent
1186 )
1189@export
1190class Step(CIStep, StatusMixin):
1191 """A step within a job."""
1193 _PARENT_TYPE: ClassVar[Nullable[type]] = Job #: A step is contained in a job of a workflow run.
1195 _number: Nullable[int] #: Position of the step within its job, starting at 1.
1197 def __init__(
1198 self,
1199 name: str,
1200 number: Nullable[int] = None,
1201 status: Nullable[Status] = None,
1202 conclusion: Nullable[Conclusion] = None,
1203 startedAt: Nullable[datetime] = None,
1204 completedAt: Nullable[datetime] = None,
1205 *,
1206 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
1207 parent: Nullable[Job] = None
1208 ) -> None:
1209 """
1210 Initializes a step within a job.
1212 :param name: Name of the step.
1213 :param number: Optional, position of the step within its job, starting at 1. Default: ``None``.
1214 :param status: Optional, state the step is in. Default: ``None``.
1215 :param conclusion: Optional, how the step ended. Default: ``None``.
1216 :param startedAt: Optional, time the step started running. Default: ``None``.
1217 :param completedAt: Optional, time the step completed. Default: ``None``.
1218 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
1219 :param parent: Optional, reference to the job containing the step. Default: ``None``.
1220 :raises TypeError: If parameter 'number' is not of type :class:`int`.
1221 :raises ValueError: If parameter 'number' is not positive.
1222 """
1223 if number is not None and not isinstance(number, int):
1224 ex = TypeError("Parameter 'number' is not of type 'int'.")
1225 ex.add_note(f"Got type '{getFullyQualifiedName(number)}'.")
1226 raise ex
1227 elif number is not None and number < 1:
1228 ex = ValueError("Parameter 'number' is not positive.")
1229 ex.add_note(f"Got value '{number}'.")
1230 raise ex
1232 super().__init__(
1233 name, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs, parent=parent
1234 )
1235 StatusMixin.__init__(self, status, conclusion)
1237 self._number = number
1239 @readonly
1240 def Number(self) -> Nullable[int]:
1241 """
1242 Read-only property to access the step's position within its job (:attr:`_number`).
1244 :returns: The position, starting at 1, or ``None`` if GitHub reported none.
1245 """
1246 return self._number
1248 @classmethod
1249 def FromJSON(cls, json: JSONObject, path: str = "step", *, parent: Nullable[Job] = None) -> Self:
1250 """
1251 Build a step from the JSON object the GitHub REST API answers with.
1253 :param json: The step, as it appears in a job's ``steps`` array.
1254 :param path: Optional, position of the step, for an exception's message. Default: ``'step'``.
1255 :param parent: Optional, reference to the job containing the step. Default: ``None``.
1256 :returns: The step.
1257 :raises TypeError: If parameter 'json' is not of type :class:`dict`.
1258 :raises GitHubError: If field ``name`` is missing.
1259 :raises GitHubError: If a field holds a value GitHub doesn't document.
1260 """
1261 if not isinstance(json, dict):
1262 ex = TypeError("Parameter 'json' is not of type 'dict'.")
1263 ex.add_note(f"Got type '{getFullyQualifiedName(json)}'.")
1264 raise ex
1266 if (name := json.get("name", None)) is None: 1266 ↛ 1267line 1266 didn't jump to line 1267 because the condition on line 1266 was never true
1267 raise GitHubError(f"Field '{path}.name' is missing.")
1269 number = json.get("number", None)
1270 status = Status.Parse(json.get("status", None))
1271 conclusion = Conclusion.Parse(json.get("conclusion", None))
1272 startedAt = _parseISO8601Timestamp(json.get("started_at", None), f"{path}.started_at")
1273 completedAt = _parseISO8601Timestamp(json.get("completed_at", None), f"{path}.completed_at")
1275 return cls(name, number, status, conclusion, startedAt, completedAt, parent=parent)