Coverage for pyTooling/CI/GitHub.py: 96%
489 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-09-29 07:08 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-09-29 07:08 +0000
1# ==================================================================================================================== #
2# _____ _ _ ____ ___ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___|_ _| #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` || | | | #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| || |___ | | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____|___| #
7# |_| |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32A data model of a GitHub Actions workflow run.
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, 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__(self, sha: str, pipelines: Nullable[Iterable[Pipeline]] = None) -> None:
391 """
392 Initializes a group of pipelines started for one commit.
394 :param sha: Commit every pipeline of the group was started on.
395 :param pipelines: Optional, the pipelines, which are attached to the group. Default: ``None``.
396 :raises ValueError: If parameter 'sha' is ``None``.
397 :raises TypeError: If parameter 'sha' is not of type :class:`str`.
398 :raises ValueError: If parameter 'sha' is empty.
399 :raises TypeError: If an element of parameter 'pipelines' is not of type :class:`Pipeline`.
400 """
401 if sha is None:
402 raise ValueError("Parameter 'sha' is None.")
403 elif not isinstance(sha, str):
404 ex = TypeError("Parameter 'sha' is not of type 'str'.")
405 ex.add_note(f"Got type '{getFullyQualifiedName(sha)}'.")
406 raise ex
407 elif sha == "":
408 raise ValueError("Parameter 'sha' is empty.")
410 super().__init__(sha, pipelines)
412 @readonly
413 def SHA(self) -> str:
414 """
415 Read-only property to access the commit every pipeline of the group was started on (:attr:`_name`).
417 :returns: The commit's hash.
418 """
419 return self._name
421 @readonly
422 def Conclusion(self) -> Nullable[Conclusion]:
423 """
424 Read-only property to return how the commit's pipelines ended, taken together.
426 The worst conclusion wins, so one failed pipeline makes the commit's verdict a failure, as a branch protection
427 rule would. The order is:
429 #. :attr:`~Conclusion.Failure`
430 #. :attr:`~Conclusion.TimedOut`
431 #. :attr:`~Conclusion.StartupFailure`
432 #. :attr:`~Conclusion.ActionRequired`
433 #. :attr:`~Conclusion.Cancelled`
434 #. :attr:`~Conclusion.Stale`
435 #. :attr:`~Conclusion.Neutral`
436 #. :attr:`~Conclusion.Skipped`
437 #. :attr:`~Conclusion.Success`
439 :returns: The worst conclusion of the group's pipelines, or ``None`` while one hasn't concluded.
440 """
441 if len(self._pipelines) == 0: 441 ↛ 442line 441 didn't jump to line 442 because the condition on line 441 was never true
442 return None
444 conclusions = set()
445 for pipeline in self._pipelines:
446 if pipeline.Conclusion is None:
447 return None
449 conclusions.add(pipeline.Conclusion)
451 for conclusion in ( 451 ↛ 458line 451 didn't jump to line 458 because the loop on line 451 didn't complete
452 Conclusion.Failure, Conclusion.TimedOut, Conclusion.StartupFailure, Conclusion.ActionRequired,
453 Conclusion.Cancelled, Conclusion.Stale, Conclusion.Neutral, Conclusion.Skipped, Conclusion.Success
454 ):
455 if conclusion in conclusions:
456 return conclusion
458 return None
460 def ByGitReference(self) -> dict[Nullable[str], list[Pipeline]]:
461 """
462 Group the commit's pipelines by the branch or tag they were started on.
464 A commit pushed to a branch and later tagged has its pipelines under two keys - the branch's name and the
465 tag's - which is what separates the checks of a commit from the run that published its release.
467 :returns: Dictionary of a reference's name to the pipelines started on it, in the order they were reported.
468 """
469 byReference: dict[Nullable[str], list[Pipeline]] = {}
470 for pipeline in self._pipelines:
471 if (pipelines := byReference.get(pipeline.GitReference, None)) is None: 471 ↛ 475line 471 didn't jump to line 475 because the condition on line 471 was always true
472 pipelines = []
473 byReference[pipeline.GitReference] = pipelines
475 pipelines.append(pipeline)
477 return byReference
479 @classmethod
480 def FromJSON(
481 cls,
482 runs: Union[JSONObject, Iterable[JSONObject]],
483 jobs: Nullable[dict[int, Iterable[JSONObject]]] = None,
484 sha: Nullable[str] = None
485 ) -> Self:
486 """
487 Build a group of pipelines from the JSON objects the GitHub REST API answers with.
489 :param runs: The runs, as returned by ``GET /repos/{owner}/{repo}/actions/runs?head_sha=...`` - either
490 the answer itself or its ``workflow_runs`` array.
491 :param jobs: Optional, the jobs of each run, by the run's identifier. Default: ``None``.
492 :param sha: Optional, the commit. Default: the ``head_sha`` the runs report.
493 :returns: The group, with its pipelines attached.
494 :raises GitHubError: If the runs report different commits.
495 :raises GitHubError: If no run reports a commit and parameter 'sha' wasn't given.
496 """
497 if not isinstance(runs, dict):
498 workflowRuns = runs
499 elif (workflowRuns := runs.get("workflow_runs", None)) is None: 499 ↛ 500line 499 didn't jump to line 500 because the condition on line 499 was never true
500 workflowRuns = []
502 pipelines = []
503 shas = set()
504 for run in workflowRuns:
505 runJobs = None
506 if jobs is not None:
507 runJobs = jobs.get(run.get("id", None), None)
509 pipeline = Pipeline.FromJSON(run, runJobs)
510 pipelines.append(pipeline)
511 if pipeline.SHA is not None:
512 shas.add(pipeline.SHA)
514 if sha is None:
515 if (commits := len(shas)) == 0:
516 raise GitHubError("None of the runs reports a 'head_sha', and parameter 'sha' wasn't given.")
517 elif commits > 1:
518 error = GitHubError("The runs report different commits.")
519 error.add_note(f"Got {', '.join(sorted(shas))}.")
520 raise error
522 sha = shas.pop()
524 return cls(sha, pipelines)
527@export
528class Pipeline(CIPipeline, StatusMixin):
529 """
530 A workflow run.
532 A run contains its own jobs, a :class:`~pyTooling.CI.Matrix` for every matrix, and a
533 :class:`~pyTooling.CI.Workflow` for every workflow it called. Unlike a called workflow, a run reports its
534 own status, conclusion and times. Several runs of one commit are held by a :class:`PipelineGroup`, which is the
535 top of the tree.
536 """
538 _PARENT_TYPE: ClassVar[Nullable[type]] = PipelineGroup #: A workflow run is contained in a pipeline group.
540 _id: Nullable[int] #: GitHub's identifier of the run.
541 _workflowID: Nullable[int] #: GitHub's identifier of the workflow the run belongs to.
542 _path: Nullable[str] #: Path of the workflow's YAML file in the repository.
543 _runNumber: Nullable[int] #: Number of the run within its workflow.
544 _runAttempt: Nullable[int] #: Attempt of the run, starting at 1.
545 _event: Nullable[Event] #: Event that triggered the run.
546 _gitReference: Nullable[str] #: Branch or tag the run was started on.
547 _sha: Nullable[str] #: Commit the run was started on.
549 def __init__(
550 self,
551 name: str,
552 identifier: Nullable[int] = None,
553 status: Nullable[Status] = None,
554 conclusion: Nullable[Conclusion] = None,
555 createdAt: Nullable[datetime] = None,
556 startedAt: Nullable[datetime] = None,
557 completedAt: Nullable[datetime] = None,
558 url: Nullable[URL] = None,
559 workflowID: Nullable[int] = None,
560 path: Nullable[str] = None,
561 runNumber: Nullable[int] = None,
562 runAttempt: Nullable[int] = None,
563 event: Nullable[Event] = None,
564 gitReference: Nullable[str] = None,
565 sha: Nullable[str] = None,
566 *,
567 parent: Nullable[PipelineGroup] = None
568 ) -> None:
569 """
570 Initializes a workflow run.
572 :param name: Name of the workflow.
573 :param identifier: Optional, GitHub's identifier of the run. Default: ``None``.
574 :param status: Optional, state the run is in. Default: ``None``.
575 :param conclusion: Optional, how the run ended. Default: ``None``.
576 :param createdAt: Optional, time the run was created. Default: ``None``.
577 :param startedAt: Optional, time the run started. Default: ``None``.
578 :param completedAt: Optional, time the run was last updated, once completed. Default: ``None``.
579 :param url: Optional, URL of the run on github.com. Default: ``None``.
580 :param workflowID: Optional, GitHub's identifier of the workflow the run belongs to. Default: ``None``.
581 :param path: Optional, path of the workflow's YAML file in the repository. Default: ``None``.
582 :param runNumber: Optional, number of the run within its workflow. Default: ``None``.
583 :param runAttempt: Optional, attempt of the run, starting at 1. Default: ``None``.
584 :param event: Optional, event that triggered the run. Default: ``None``.
585 :param gitReference: Optional, branch or tag the run was started on. Default: ``None``.
586 :param sha: Optional, commit the run was started on. Default: ``None``.
587 :param parent: Optional, reference to the group of the commit's runs. Default: ``None``.
588 :raises TypeError: If parameter 'identifier' is not of type :class:`int`.
589 :raises TypeError: If parameter 'workflowID' is not of type :class:`int`.
590 :raises TypeError: If parameter 'path' is not of type :class:`str`.
591 :raises TypeError: If parameter 'runNumber' is not of type :class:`int`.
592 :raises TypeError: If parameter 'runAttempt' is not of type :class:`int`.
593 :raises TypeError: If parameter 'event' is not of type :class:`Event`.
594 :raises TypeError: If parameter 'gitReference' is not of type :class:`str`.
595 :raises TypeError: If parameter 'sha' is not of type :class:`str`.
596 """
597 for parameterName, number in (
598 ("identifier", identifier), ("workflowID", workflowID), ("runNumber", runNumber), ("runAttempt", runAttempt)
599 ):
600 if number is not None and not isinstance(number, int):
601 ex = TypeError(f"Parameter '{parameterName}' is not of type 'int'.")
602 ex.add_note(f"Got type '{getFullyQualifiedName(number)}'.")
603 raise ex
605 if event is not None and not isinstance(event, Event):
606 ex = TypeError("Parameter 'event' is not of type 'Event'.")
607 ex.add_note(f"Got type '{getFullyQualifiedName(event)}'.")
608 raise ex
610 for parameterName, text in (("path", path), ("gitReference", gitReference), ("sha", sha)):
611 if text is not None and not isinstance(text, str):
612 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
613 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.")
614 raise ex
616 super().__init__(
617 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, parent=parent
618 )
619 StatusMixin.__init__(self, status, conclusion, url)
621 self._id = identifier
622 self._workflowID = workflowID
623 self._path = path
624 self._runNumber = runNumber
625 self._runAttempt = runAttempt
626 self._event = event
627 self._gitReference = gitReference
628 self._sha = sha
630 @readonly
631 def ID(self) -> Nullable[int]:
632 """
633 Read-only property to access GitHub's identifier of the run (:attr:`_id`).
635 :returns: The identifier, or ``None`` if GitHub reported none.
636 """
637 return self._id
639 @readonly
640 def WorkflowID(self) -> Nullable[int]:
641 """
642 Read-only property to access GitHub's identifier of the workflow the run belongs to (:attr:`_workflowID`).
644 Every run of the same workflow file reports the same identifier, so it groups a workflow's runs over time,
645 where :attr:`ID` identifies the single run.
647 :returns: The identifier, or ``None`` if GitHub reported none.
648 """
649 return self._workflowID
651 @readonly
652 def Path(self) -> Nullable[str]:
653 """
654 Read-only property to access the path of the workflow's YAML file (:attr:`_path`).
656 GitHub reports it relative to the repository's root, e.g. ``.github/workflows/Pipeline.yml``.
658 :returns: The path, or ``None`` if GitHub reported none.
659 """
660 return self._path
662 @readonly
663 def RunNumber(self) -> Nullable[int]:
664 """
665 Read-only property to access the run's number within its workflow (:attr:`_runNumber`).
667 :returns: The number, or ``None`` if GitHub reported none.
668 """
669 return self._runNumber
671 @readonly
672 def RunAttempt(self) -> Nullable[int]:
673 """
674 Read-only property to access which attempt of the run this is (:attr:`_runAttempt`).
676 :returns: The attempt, starting at 1, or ``None`` if GitHub reported none.
677 """
678 return self._runAttempt
680 @readonly
681 def Event(self) -> Nullable[Event]:
682 """
683 Read-only property to access the event that triggered the run (:attr:`_event`).
685 :returns: The event, or ``None`` if GitHub reported none.
686 """
687 return self._event
689 @readonly
690 def GitReference(self) -> Nullable[str]:
691 """
692 Read-only property to access the branch or tag the run was started on (:attr:`_gitReference`).
694 GitHub reports this as ``head_branch`` and puts the **tag's** name there for a run started at a tag, with no
695 field saying which it is - a run of `pyTooling/MiKTeX` v1.6.0 reports ``main``, and the run at the tag of the
696 very same commit reports ``v1.6.0``. :attr:`Event` is the other half of telling them apart.
698 :returns: The branch's or tag's name, or ``None`` if GitHub reported none.
699 """
700 return self._gitReference
702 @readonly
703 def SHA(self) -> Nullable[str]:
704 """
705 Read-only property to access the commit the run was started on (:attr:`_sha`).
707 :returns: The commit's hash, or ``None`` if GitHub reported none.
708 """
709 return self._sha
711 @classmethod
712 def FromJSON(
713 cls,
714 run: JSONObject,
715 jobs: Nullable[Iterable[JSONObject]] = None,
716 *,
717 parent: Nullable[PipelineGroup] = None
718 ) -> Self:
719 """
720 Build a workflow run and its tree from the JSON objects the GitHub REST API answers with.
722 A job's name says where it sits, and is read back into the tree: ``Docs / Sphinx / HTML`` nests below a
723 :class:`~pyTooling.CI.Workflow` per prefix, and ``Unit Tests (ubuntu-26.04, 3.14)`` becomes a
724 :class:`MatrixJob` below a :class:`~pyTooling.CI.Matrix` named ``Unit Tests``. A prefix carrying
725 dimension values - ``Tests (3.14) / Unit``, a matrix of calls of a reusable workflow - becomes a
726 :class:`~pyTooling.CI.MatrixWorkflow` below a :class:`~pyTooling.CI.Matrix` named ``Tests``.
728 :param run: The workflow run, as returned by ``GET /repos/{owner}/{repo}/actions/runs/{run_id}``.
729 :param jobs: Optional, the run's jobs, as listed by ``GET .../actions/runs/{run_id}/jobs``.
730 :param parent: Optional, reference to the group of the commit's runs. Default: ``None``.
731 :returns: The workflow run, with its workflows, matrices, jobs and steps attached.
732 :raises TypeError: If parameter 'run' is not of type :class:`dict`.
733 :raises TypeError: If an element of parameter 'jobs' is not of type :class:`dict`.
734 :raises GitHubError: If field ``name`` is missing.
735 :raises GitHubError: If a field holds a value GitHub doesn't document.
736 """
737 if not isinstance(run, dict):
738 ex = TypeError("Parameter 'run' is not of type 'dict'.")
739 ex.add_note(f"Got type '{getFullyQualifiedName(run)}'.")
740 raise ex
742 if (name := run.get("name", None)) is None:
743 raise GitHubError("Field 'run.name' is missing.")
745 identifier = run.get("id", None)
746 status = Status.Parse(run.get("status", None))
747 conclusion = Conclusion.Parse(run.get("conclusion", None))
748 createdAt = _parseISO8601Timestamp(run.get("created_at", None), "run.created_at")
749 startedAt = _parseISO8601Timestamp(run.get("run_started_at", None), "run.run_started_at")
750 completedAt = _parseISO8601Timestamp(run.get("updated_at", None), "run.updated_at") \
751 if status is Status.Completed else None
752 url = _parseURL(run.get("html_url", None), "run.html_url")
753 workflowID = run.get("workflow_id", None)
754 path = run.get("path", None)
755 runNumber = run.get("run_number", None)
756 runAttempt = run.get("run_attempt", None)
757 event = Event.Parse(run.get("event", None))
758 gitReference = run.get("head_branch", None)
759 sha = run.get("head_sha", None)
761 pipeline = cls(
762 name, identifier, status, conclusion, createdAt, startedAt, completedAt, url, workflowID, path, runNumber,
763 runAttempt, event, gitReference, sha, parent=parent
764 )
766 if jobs is None:
767 return pipeline
769 for position, job in enumerate(jobs):
770 if not isinstance(job, dict):
771 ex = TypeError(f"Job {position} is not of type 'dict'.")
772 ex.add_note(f"Got type '{getFullyQualifiedName(job)}'.")
773 raise ex
775 path = f"jobs[{position}]"
776 if (fullName := job.get("name", None)) is None:
777 raise GitHubError(f"Field '{path}.name' is missing.")
779 *callers, leaf = fullName.split(" / ")
781 group: CIWorkflow = pipeline
782 for caller in callers:
783 if (calledWorkflow := group.Workflows.get(caller, None)) is None:
784 callerName, callerDimensions = _splitMatrixJobName(caller)
785 if callerDimensions is None:
786 calledWorkflow = CIWorkflow(caller, parent=group)
787 else:
788 if (matrix := group.Matrices.get(callerName, None)) is None:
789 matrix = CIMatrix(callerName, parent=group)
791 if caller in matrix:
792 calledWorkflow = matrix[caller]
793 else:
794 calledWorkflow = CIMatrixWorkflow(callerName, callerDimensions, parent=matrix)
796 group = calledWorkflow
798 jobName, dimensions = _splitMatrixJobName(leaf)
799 if dimensions is None:
800 Job.FromJSON(job, path, parent=group)
801 else:
802 if (matrix := group.Matrices.get(jobName, None)) is None:
803 matrix = CIMatrix(jobName, parent=group)
805 MatrixJob.FromJSON(job, path, jobName, dimensions, parent=matrix)
807 return pipeline
810@export
811class Job(CIJob, StatusMixin):
812 """A job of a workflow run, which ran on a runner and contains steps."""
814 _id: Nullable[int] #: GitHub's identifier of the job.
815 _labels: list[str] #: Labels the job requested its runner by, i.e. its ``runs-on``.
816 _runnerName: Nullable[str] #: Name of the runner the job ran on.
817 _runnerGroupName: Nullable[str] #: Name of the runner group the runner belongs to.
819 def __init__(
820 self,
821 name: str,
822 identifier: Nullable[int] = None,
823 status: Nullable[Status] = None,
824 conclusion: Nullable[Conclusion] = None,
825 createdAt: Nullable[datetime] = None,
826 startedAt: Nullable[datetime] = None,
827 completedAt: Nullable[datetime] = None,
828 url: Nullable[URL] = None,
829 labels: Nullable[Iterable[str]] = None,
830 runnerName: Nullable[str] = None,
831 runnerGroupName: Nullable[str] = None,
832 steps: Nullable[Iterable[Step]] = None,
833 *,
834 parent: Nullable[CIJobGroup] = None
835 ) -> None:
836 """
837 Initializes a job of a workflow run.
839 :param name: Name of the job, without the calling workflows' prefixes.
840 :param identifier: Optional, GitHub's identifier of the job. Default: ``None``.
841 :param status: Optional, state the job is in. Default: ``None``.
842 :param conclusion: Optional, how the job ended. Default: ``None``.
843 :param createdAt: Optional, time the job was created, i.e. queued for a runner. Default: ``None``.
844 :param startedAt: Optional, time the job started running on a runner. Default: ``None``.
845 :param completedAt: Optional, time the job completed. Default: ``None``.
846 :param url: Optional, URL of the job on github.com. Default: ``None``.
847 :param labels: Optional, labels the job requested its runner by. Default: ``None``.
848 :param runnerName: Optional, name of the runner the job ran on. Default: ``None``.
849 :param runnerGroupName: Optional, name of the runner group the runner belongs to. Default: ``None``.
850 :param steps: Optional, the job's steps, which are attached to it. Default: ``None``.
851 :param parent: Optional, reference to the group containing the job. Default: ``None``.
852 :raises TypeError: If parameter 'identifier' is not of type :class:`int`.
853 :raises TypeError: If an element of parameter 'steps' is not of type :class:`Step`.
854 :raises TypeError: If an element of parameter 'labels' is not of type :class:`str`.
855 :raises TypeError: If parameter 'runnerName' is not of type :class:`str`.
856 :raises TypeError: If parameter 'runnerGroupName' is not of type :class:`str`.
857 """
858 if identifier is not None and not isinstance(identifier, int):
859 ex = TypeError("Parameter 'identifier' is not of type 'int'.")
860 ex.add_note(f"Got type '{getFullyQualifiedName(identifier)}'.")
861 raise ex
863 for parameterName, value in (("runnerName", runnerName), ("runnerGroupName", runnerGroupName)):
864 if value is not None and not isinstance(value, str):
865 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
866 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
867 raise ex
869 self._labels = []
870 if labels is not None:
871 for label in labels:
872 if not isinstance(label, str):
873 ex = TypeError("An element of parameter 'labels' is not of type 'str'.")
874 ex.add_note(f"Got type '{getFullyQualifiedName(label)}'.")
875 raise ex
877 self._labels.append(label)
879 stepList = []
880 if steps is not None:
881 for step in steps:
882 if not isinstance(step, Step):
883 ex = TypeError("An element of parameter 'steps' is not of type 'Step'.")
884 ex.add_note(f"Got type '{getFullyQualifiedName(step)}'.")
885 raise ex
887 stepList.append(step)
889 super().__init__(
890 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, parent=parent
891 )
892 StatusMixin.__init__(self, status, conclusion, url)
894 self._id = identifier
895 self._runnerName = runnerName
896 self._runnerGroupName = runnerGroupName
898 for step in stepList:
899 step._parent = self
900 step._pipeline = self._pipeline
901 self._steps.append(step)
903 @readonly
904 def ID(self) -> Nullable[int]:
905 """
906 Read-only property to access GitHub's identifier of the job (:attr:`_id`).
908 :returns: The identifier, or ``None`` if GitHub reported none.
909 """
910 return self._id
912 @readonly
913 def Labels(self) -> list[str]:
914 """
915 Read-only property to access the labels the job requested its runner by (:attr:`_labels`).
917 These are the values of the workflow's ``runs-on``. For a hosted runner a label doubles as the image's name
918 (``ubuntu-26.04``); for a self-hosted runner they are the tags the runner was registered with.
920 :returns: The labels, empty if GitHub reported none.
921 """
922 return self._labels
924 @readonly
925 def RunnerName(self) -> Nullable[str]:
926 """
927 Read-only property to access the name of the runner the job ran on (:attr:`_runnerName`).
929 :returns: The runner's name, or ``None`` while the job hasn't started.
930 """
931 return self._runnerName
933 @readonly
934 def RunnerGroupName(self) -> Nullable[str]:
935 """
936 Read-only property to access the name of the runner group the runner belongs to (:attr:`_runnerGroupName`).
938 :returns: The group's name, or ``None`` if GitHub reported none.
939 """
940 return self._runnerGroupName
942 @readonly
943 def CreatedAt(self) -> Nullable[datetime]:
944 """
945 Read-only property to return the time the job was created, at the latest when it started.
947 :returns: The time, or ``None`` if GitHub reported none.
948 """
949 if self._createdAt is None or all(step.StartedAt is None for step in self._steps):
950 return self._createdAt
952 return min(self._createdAt, self.StartedAt)
954 @readonly
955 def StartedAt(self) -> Nullable[datetime]:
956 """
957 Read-only property to return the time the job started, at the latest when its first step started.
959 GitHub reports a job's times and its steps' times independently and in whole seconds, so a step is sometimes
960 reported as starting before the job containing it. The step really did run then, so the job is the timespan
961 that stretches - otherwise a consumer building a tree has a child outside its parent.
963 :returns: The time, or ``None`` while neither the job nor a step of it has started.
964 """
965 times = [step.StartedAt for step in self._steps if step.StartedAt is not None]
966 if len(times) == 0:
967 return self._startedAt
969 first = min(times)
971 return first if self._startedAt is None else min(self._startedAt, first)
973 @readonly
974 def CompletedAt(self) -> Nullable[datetime]:
975 """
976 Read-only property to return the time the job completed, at the earliest when its last step completed.
978 A job that hasn't completed reports no time, even when a step of it has - see :attr:`StartedAt` for why the
979 job is the timespan that stretches.
981 :returns: The time, or ``None`` while the job hasn't completed.
982 """
983 if self._completedAt is None or all(step.StartedAt is None for step in self._steps):
984 return self._completedAt
986 times = [step.CompletedAt for step in self._steps if step.CompletedAt is not None]
987 if len(times) == 0: 987 ↛ 988line 987 didn't jump to line 988 because the condition on line 987 was never true
988 return self._completedAt
990 return max(self._completedAt, max(times))
992 @readonly
993 def QueuedDuration(self) -> Nullable[float]:
994 """
995 Read-only property to return how long the job waited for a runner.
997 :returns: Seconds from being created to starting, or ``None`` while either time is unknown.
998 """
999 if self._createdAt is None or self._startedAt is None:
1000 return None
1002 return (self._startedAt - self._createdAt).total_seconds()
1004 @classmethod
1005 def FromJSON(cls, json: JSONObject, path: str = "job", *, parent: Nullable[CIJobGroup] = None) -> Self:
1006 """
1007 Build a job and its steps from the JSON object the GitHub REST API answers with.
1009 The job's name is taken without the calling workflows' prefixes: a job reported as ``Caller / Build`` is named
1010 ``Build``, because the prefix describes where it sits, which the tree already says.
1012 :param json: The job, as listed by ``GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs``.
1013 :param path: Optional, position of the job, for an exception's message. Default: ``'job'``.
1014 :param parent: Optional, reference to the group containing the job. Default: ``None``.
1015 :returns: The job, with its steps attached.
1016 :raises TypeError: If parameter 'json' is not of type :class:`dict`.
1017 :raises GitHubError: If field ``name`` is missing.
1018 :raises GitHubError: If a field holds a value GitHub doesn't document.
1019 """
1020 if not isinstance(json, dict):
1021 ex = TypeError("Parameter 'json' is not of type 'dict'.")
1022 ex.add_note(f"Got type '{getFullyQualifiedName(json)}'.")
1023 raise ex
1025 if (fullName := json.get("name", None)) is None: 1025 ↛ 1026line 1025 didn't jump to line 1026 because the condition on line 1025 was never true
1026 raise GitHubError(f"Field '{path}.name' is missing.")
1028 name = fullName.rsplit(" / ", 1)[-1]
1029 identifier = json.get("id", None)
1030 status = Status.Parse(json.get("status", None))
1031 conclusion = Conclusion.Parse(json.get("conclusion", None))
1032 createdAt = _parseISO8601Timestamp(json.get("created_at", None), f"{path}.created_at")
1033 startedAt = _parseISO8601Timestamp(json.get("started_at", None), f"{path}.started_at")
1034 completedAt = _parseISO8601Timestamp(json.get("completed_at", None), f"{path}.completed_at")
1035 url = _parseURL(json.get("html_url", None), f"{path}.html_url")
1036 labels = json.get("labels", None)
1037 runnerName = json.get("runner_name", None)
1038 runnerGroupName = json.get("runner_group_name", None)
1040 if (jsonSteps := json.get("steps", None)) is None: 1040 ↛ 1041line 1040 didn't jump to line 1041 because the condition on line 1040 was never true
1041 steps = None
1042 else:
1043 steps = [Step.FromJSON(step, f"{path}.steps[{pos}]") for pos, step in enumerate(jsonSteps)]
1045 return cls(
1046 name, identifier, status, conclusion, createdAt, startedAt, completedAt, url, labels, runnerName,
1047 runnerGroupName, steps, parent=parent
1048 )
1051@export
1052class MatrixJob(Job, MatrixInstanceMixin):
1053 """
1054 One instance of a job produced by a matrix.
1056 GitHub reports a matrix instance as an ordinary job whose name carries the dimensions' values in brackets, e.g.
1057 ``Unit Tests (ubuntu-26.04, 3.14)``. :meth:`Pipeline.FromJSON` reads those back into :attr:`Dimensions` and groups
1058 the instances below a :class:`~pyTooling.CI.Matrix`. The values are in the order GitHub prints them. The
1059 job's payload names no dimension - only the workflow file does -, so a dimension's name is the position of its
1060 value, ``{"0": "ubuntu-26.04", "1": "3.14"}``, until the names are known.
1061 """
1063 _PARENT_TYPE: ClassVar[Nullable[type]] = CIMatrix #: A matrix instance is contained in a matrix.
1065 def __init__(
1066 self,
1067 name: str,
1068 dimensions: Nullable[Mapping[str, Any]] = None,
1069 identifier: Nullable[int] = None,
1070 status: Nullable[Status] = None,
1071 conclusion: Nullable[Conclusion] = None,
1072 createdAt: Nullable[datetime] = None,
1073 startedAt: Nullable[datetime] = None,
1074 completedAt: Nullable[datetime] = None,
1075 url: Nullable[URL] = None,
1076 labels: Nullable[Iterable[str]] = None,
1077 runnerName: Nullable[str] = None,
1078 runnerGroupName: Nullable[str] = None,
1079 steps: Nullable[Iterable[Step]] = None,
1080 *,
1081 parent: Nullable[CIMatrix] = None
1082 ) -> None:
1083 """
1084 Initializes one instance of a job produced by a matrix.
1086 :param name: Name of the job, without the dimensions' values.
1087 :param dimensions: Optional, the dimensions' names and values this instance ran with. Default: ``None``.
1088 :param identifier: Optional, GitHub's identifier of the job. Default: ``None``.
1089 :param status: Optional, state the job is in. Default: ``None``.
1090 :param conclusion: Optional, how the job ended. Default: ``None``.
1091 :param createdAt: Optional, time the job was created, i.e. queued for a runner. Default: ``None``.
1092 :param startedAt: Optional, time the job started running on a runner. Default: ``None``.
1093 :param completedAt: Optional, time the job completed. Default: ``None``.
1094 :param url: Optional, URL of the job on github.com. Default: ``None``.
1095 :param labels: Optional, labels the job requested its runner by. Default: ``None``.
1096 :param runnerName: Optional, name of the runner the job ran on. Default: ``None``.
1097 :param runnerGroupName: Optional, name of the runner group the runner belongs to. Default: ``None``.
1098 :param steps: Optional, the job's steps, which are attached to it. Default: ``None``.
1099 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``.
1100 """
1101 super().__init__(
1102 name, identifier, status, conclusion, createdAt, startedAt, completedAt, url, labels, runnerName,
1103 runnerGroupName, steps, parent=parent
1104 )
1105 MatrixInstanceMixin.__init__(self, dimensions)
1107 def __str__(self) -> str:
1108 """
1109 Return a string representation of the matrix instance.
1111 :returns: The job's name with its dimensions' values, as GitHub prints it.
1112 """
1113 if len(self._dimensions) == 0: 1113 ↛ 1114line 1113 didn't jump to line 1114 because the condition on line 1113 was never true
1114 return self._name
1116 return f"{self._name} ({', '.join(str(value) for value in self._dimensions.values())})"
1118 @classmethod
1119 def FromJSON(
1120 cls,
1121 json: JSONObject,
1122 path: str = "job",
1123 name: Nullable[str] = None,
1124 dimensions: Nullable[Mapping[str, Any]] = None,
1125 *,
1126 parent: Nullable[CIMatrix] = None
1127 ) -> Self:
1128 """
1129 Build a matrix instance and its steps from the JSON object the GitHub REST API answers with.
1131 :param json: The job, as listed by ``GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs``.
1132 :param path: Optional, position of the job, for an exception's message. Default: ``'job'``.
1133 :param name: Optional, the job's name without the dimensions' values. Default: read from the payload.
1134 :param dimensions: Optional, the dimensions' names and values. Default: read from the payload.
1135 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``.
1136 :returns: The matrix instance, with its steps attached.
1137 :raises TypeError: If parameter 'json' is not of type :class:`dict`.
1138 :raises GitHubError: If field ``name`` is missing.
1139 :raises GitHubError: If a field holds a value GitHub doesn't document.
1140 """
1141 if not isinstance(json, dict):
1142 ex = TypeError("Parameter 'json' is not of type 'dict'.")
1143 ex.add_note(f"Got type '{getFullyQualifiedName(json)}'.")
1144 raise ex
1146 if (fullName := json.get("name", None)) is None: 1146 ↛ 1147line 1146 didn't jump to line 1147 because the condition on line 1146 was never true
1147 raise GitHubError(f"Field '{path}.name' is missing.")
1149 if name is None: 1149 ↛ 1150line 1149 didn't jump to line 1150 because the condition on line 1149 was never true
1150 name, dimensions = _splitMatrixJobName(fullName.rsplit(" / ", 1)[-1])
1152 identifier = json.get("id", None)
1153 status = Status.Parse(json.get("status", None))
1154 conclusion = Conclusion.Parse(json.get("conclusion", None))
1155 createdAt = _parseISO8601Timestamp(json.get("created_at", None), f"{path}.created_at")
1156 startedAt = _parseISO8601Timestamp(json.get("started_at", None), f"{path}.started_at")
1157 completedAt = _parseISO8601Timestamp(json.get("completed_at", None), f"{path}.completed_at")
1158 url = _parseURL(json.get("html_url", None), f"{path}.html_url")
1159 labels = json.get("labels", None)
1160 runnerName = json.get("runner_name", None)
1161 runnerGroupName = json.get("runner_group_name", None)
1163 if (jsonSteps := json.get("steps", None)) is None: 1163 ↛ 1164line 1163 didn't jump to line 1164 because the condition on line 1163 was never true
1164 steps = None
1165 else:
1166 steps = [Step.FromJSON(step, f"{path}.steps[{pos}]") for pos, step in enumerate(jsonSteps)]
1168 return cls(
1169 name, dimensions, identifier, status, conclusion, createdAt, startedAt, completedAt, url, labels,
1170 runnerName, runnerGroupName, steps, parent=parent
1171 )
1174@export
1175class Step(CIStep, StatusMixin):
1176 """A step within a job."""
1178 _PARENT_TYPE: ClassVar[Nullable[type]] = Job #: A step is contained in a job of a workflow run.
1180 _number: Nullable[int] #: Position of the step within its job, starting at 1.
1182 def __init__(
1183 self,
1184 name: str,
1185 number: Nullable[int] = None,
1186 status: Nullable[Status] = None,
1187 conclusion: Nullable[Conclusion] = None,
1188 startedAt: Nullable[datetime] = None,
1189 completedAt: Nullable[datetime] = None,
1190 *,
1191 parent: Nullable[Job] = None
1192 ) -> None:
1193 """
1194 Initializes a step within a job.
1196 :param name: Name of the step.
1197 :param number: Optional, position of the step within its job, starting at 1. Default: ``None``.
1198 :param status: Optional, state the step is in. Default: ``None``.
1199 :param conclusion: Optional, how the step ended. Default: ``None``.
1200 :param startedAt: Optional, time the step started running. Default: ``None``.
1201 :param completedAt: Optional, time the step completed. Default: ``None``.
1202 :param parent: Optional, reference to the job containing the step. Default: ``None``.
1203 :raises TypeError: If parameter 'number' is not of type :class:`int`.
1204 :raises ValueError: If parameter 'number' is not positive.
1205 """
1206 if number is not None and not isinstance(number, int):
1207 ex = TypeError("Parameter 'number' is not of type 'int'.")
1208 ex.add_note(f"Got type '{getFullyQualifiedName(number)}'.")
1209 raise ex
1210 elif number is not None and number < 1:
1211 ex = ValueError("Parameter 'number' is not positive.")
1212 ex.add_note(f"Got value '{number}'.")
1213 raise ex
1215 super().__init__(
1216 name, startedAt=startedAt, completedAt=completedAt, parent=parent
1217 )
1218 StatusMixin.__init__(self, status, conclusion)
1220 self._number = number
1222 @readonly
1223 def Number(self) -> Nullable[int]:
1224 """
1225 Read-only property to access the step's position within its job (:attr:`_number`).
1227 :returns: The position, starting at 1, or ``None`` if GitHub reported none.
1228 """
1229 return self._number
1231 @classmethod
1232 def FromJSON(cls, json: JSONObject, path: str = "step", *, parent: Nullable[Job] = None) -> Self:
1233 """
1234 Build a step from the JSON object the GitHub REST API answers with.
1236 :param json: The step, as it appears in a job's ``steps`` array.
1237 :param path: Optional, position of the step, for an exception's message. Default: ``'step'``.
1238 :param parent: Optional, reference to the job containing the step. Default: ``None``.
1239 :returns: The step.
1240 :raises TypeError: If parameter 'json' is not of type :class:`dict`.
1241 :raises GitHubError: If field ``name`` is missing.
1242 :raises GitHubError: If a field holds a value GitHub doesn't document.
1243 """
1244 if not isinstance(json, dict):
1245 ex = TypeError("Parameter 'json' is not of type 'dict'.")
1246 ex.add_note(f"Got type '{getFullyQualifiedName(json)}'.")
1247 raise ex
1249 if (name := json.get("name", None)) is None: 1249 ↛ 1250line 1249 didn't jump to line 1250 because the condition on line 1249 was never true
1250 raise GitHubError(f"Field '{path}.name' is missing.")
1252 number = json.get("number", None)
1253 status = Status.Parse(json.get("status", None))
1254 conclusion = Conclusion.Parse(json.get("conclusion", None))
1255 startedAt = _parseISO8601Timestamp(json.get("started_at", None), f"{path}.started_at")
1256 completedAt = _parseISO8601Timestamp(json.get("completed_at", None), f"{path}.completed_at")
1258 return cls(name, number, status, conclusion, startedAt, completedAt, parent=parent)