Coverage for pyTooling/GitHub/__init__.py: 96%
498 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-04 09:05 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-04 09:05 +0000
1# ==================================================================================================================== #
2# _____ _ _ ____ _ _ _ _ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___(_) |_| | | |_ _| |__ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` || | _| | __| |_| | | | | '_ \ #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| || |_| | | |_| _ | |_| | |_) | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____|_|\__|_| |_|\__,_|_.__/ #
7# |_| |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32A data model of a GitHub Actions workflow run.
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
61__author__ = "Patrick Lehmann"
62__email__ = "Paebbels@gmail.com"
63__copyright__ = "2026-2026, Patrick Lehmann"
64__license__ = "Apache License, Version 2.0"
65__version__ = "0.1.0"
66__keywords__ = ["GitHub", "GitHub Actions", "Workflow", "Pipeline", "CI", "Trace", "OpenTelemetry", "OTLP", "Sphinx"]
67__project_url__ = "https://github.com/pyTooling/pyTooling.GitHub"
68__documentation_url__ = "https://pyTooling.github.io/pyTooling.GitHub"
69__issue_tracker_url__ = "https://GitHub.com/pyTooling/pyTooling.GitHub/issues"
71from datetime import datetime, timezone
72from typing import Optional as Nullable, Any, ClassVar, Hashable, Iterable, Mapping, Self, Union
74from pyTooling.CI import CIError, JSONObject, MatrixInstanceMixin, Outcome
75from pyTooling.CI import Job as CIJob, JobGroup as CIJobGroup, Matrix as CIMatrix
76from pyTooling.CI import MatrixWorkflow as CIMatrixWorkflow, Pipeline as CIPipeline
77from pyTooling.CI import PipelineGroup as CIPipelineGroup, Step as CIStep, Workflow as CIWorkflow
78from pyTooling.Common import getFullyQualifiedName, parseISO8601Timestamp, StringEnum
79from pyTooling.Decorators import export, readonly
80from pyTooling.GenericPath.URL import URL
81from pyTooling.MetaClasses import ExtendedType
84__all__ = ["CONCLUSION_TO_OUTCOME"]
87@export
88class GitHubError(CIError):
89 """Base-exception of all exceptions raised by :mod:`pyTooling.GitHub`."""
92@export
93class Status(StringEnum):
94 """The state a workflow run, job or step is in."""
96 Queued = "queued" #: Waiting to be picked up.
97 InProgress = "in_progress" #: Running.
98 Completed = "completed" #: Finished, with a :class:`Conclusion`.
99 Waiting = "waiting" #: Held, e.g. for an environment's approval.
100 Requested = "requested" #: Requested, but not yet queued.
101 Pending = "pending" #: Blocked by a concurrency group.
103 @classmethod
104 def Parse(cls, value: Nullable[str]) -> Nullable[Status]:
105 """
106 Convert GitHub's ``status`` field to a member of this enumeration.
108 :param value: Optional, the field's value. Default: ``None``.
109 :returns: The matching member, or ``None`` if the field was absent or empty.
110 :raises TypeError: If parameter 'value' is not of type :class:`str`.
111 :raises GitHubError: If the value is not a status GitHub documents. |br|
112 The note lists the documented values.
113 """
114 try:
115 return super().Parse(value)
116 except ValueError as ex:
117 error = GitHubError(f"'{value}' is not a GitHub status.")
118 error.add_note(f"Known: {', '.join(member.value for member in cls)}.")
119 raise error from ex
122@export
123class Conclusion(StringEnum):
124 """How a completed workflow run, job or step ended."""
126 Success = "success" #: Succeeded.
127 Failure = "failure" #: Failed.
128 Cancelled = "cancelled" #: Cancelled before it finished.
129 Skipped = "skipped" #: Not run, because a condition excluded it.
130 TimedOut = "timed_out" #: Stopped by a timeout.
131 ActionRequired = "action_required" #: Waiting for a manual action.
132 Neutral = "neutral" #: Finished without a verdict.
133 Stale = "stale" #: Never ran, because the run was superseded.
134 StartupFailure = "startup_failure" #: The workflow file itself couldn't be started.
136 @classmethod
137 def Parse(cls, value: Nullable[str]) -> Nullable[Conclusion]:
138 """
139 Convert GitHub's ``conclusion`` field to a member of this enumeration.
141 :param value: Optional, the field's value. Default: ``None``.
142 :returns: The matching member, or ``None`` while it hasn't concluded.
143 :raises TypeError: If parameter 'value' is not of type :class:`str`.
144 :raises GitHubError: If the value is not a conclusion GitHub documents. |br|
145 The note lists the documented values.
146 """
147 try:
148 return super().Parse(value)
149 except ValueError as ex:
150 error = GitHubError(f"'{value}' is not a GitHub conclusion.")
151 error.add_note(f"Known: {', '.join(member.value for member in cls)}.")
152 raise error from ex
154 def ToOutcome(self) -> Outcome:
155 """
156 Return the service-independent outcome this conclusion corresponds to.
158 The conclusions with a counterpart of their own - e.g. :attr:`TimedOut`, :attr:`Skipped`, :attr:`Cancelled` - are
159 listed in :data:`CONCLUSION_TO_OUTCOME`; any other, e.g. :attr:`StartupFailure` or :attr:`Neutral`, is an
160 :attr:`~pyTooling.CI.Outcome.Error`.
162 :returns: The outcome.
163 """
164 return CONCLUSION_TO_OUTCOME.get(self, Outcome.Error)
167CONCLUSION_TO_OUTCOME = {
168 Conclusion.Success: Outcome.Success,
169 Conclusion.Failure: Outcome.Failure,
170 Conclusion.TimedOut: Outcome.Timeout,
171 Conclusion.Skipped: Outcome.Skip,
172 Conclusion.Cancelled: Outcome.Cancellation,
173}
174"""GitHub's conclusions with a service-independent outcome of their own."""
177@export
178class Event(StringEnum):
179 """The event that triggered a workflow run."""
181 CheckRun = "check_run" #: A check run was created or completed.
182 CheckSuite = "check_suite" #: A check suite was created or completed.
183 Create = "create" #: A branch or tag was created.
184 Delete = "delete" #: A branch or tag was deleted.
185 Deployment = "deployment" #: A deployment was created.
186 DeploymentStatus = "deployment_status" #: A deployment's status changed.
187 Discussion = "discussion" #: A discussion was touched.
188 DiscussionComment = "discussion_comment" #: A discussion was commented on.
189 Fork = "fork" #: The repository was forked.
190 Gollum = "gollum" #: A wiki page was created or updated.
191 IssueComment = "issue_comment" #: An issue or pull-request was commented on.
192 Issues = "issues" #: An issue was touched.
193 Label = "label" #: A label was touched.
194 MergeGroup = "merge_group" #: A merge group entered the merge queue.
195 Milestone = "milestone" #: A milestone was touched.
196 PageBuild = "page_build" #: GitHub Pages was built.
197 Public = "public" #: The repository was made public.
198 PullRequest = "pull_request" #: A pull-request was touched.
199 PullRequestComment = "pull_request_comment" #: A pull-request was commented on.
200 PullRequestReview = "pull_request_review" #: A pull-request was reviewed.
201 PullRequestReviewComment = "pull_request_review_comment" #: A review was commented on.
202 PullRequestTarget = "pull_request_target" #: A pull-request, run against its base.
203 Push = "push" #: A commit or tag was pushed.
204 RegistryPackage = "registry_package" #: A package was published or updated.
205 Release = "release" #: A release was touched.
206 RepositoryDispatch = "repository_dispatch" #: An external event was dispatched.
207 Schedule = "schedule" #: A cron schedule fired.
208 Status = "status" #: A commit's status changed.
209 Watch = "watch" #: The repository was starred.
210 WorkflowCall = "workflow_call" #: The workflow was called by another one.
211 WorkflowDispatch = "workflow_dispatch" #: The workflow was started by hand or by a token.
212 WorkflowRun = "workflow_run" #: Another workflow run completed.
213 Dynamic = "dynamic" #: GitHub started the run without a workflow file.
215 @classmethod
216 def Parse(cls, value: Nullable[str]) -> Nullable[Event]:
217 """
218 Convert GitHub's ``event`` field to a member of this enumeration.
220 :param value: Optional, the field's value. Default: ``None``.
221 :returns: The matching member, or ``None`` if the field was absent or empty.
222 :raises TypeError: If parameter 'value' is not of type :class:`str`.
223 :raises GitHubError: If the value is not an event GitHub documents. |br|
224 The note lists the documented values.
225 """
226 try:
227 return super().Parse(value)
228 except ValueError as ex:
229 error = GitHubError(f"'{value}' is not a GitHub event.")
230 error.add_note(f"Known: {', '.join(member.value for member in cls)}.")
231 raise error from ex
234def _parseISO8601Timestamp(value: Nullable[str], field: str) -> Nullable[datetime]:
235 """
236 Parse an ISO 8601 timestamp, as the GitHub REST API reports them.
238 A timestamp without a time zone is read as UTC, so every timestamp of a run can be compared with every other.
240 :param value: Optional, the field's value. Default: ``None``.
241 :param field: Name of the field, for the exception's message.
242 :returns: The timestamp, or ``None`` if the field was absent or empty.
243 :raises GitHubError: If the value isn't an ISO 8601 timestamp. |br|
244 The note reports the value that was read.
245 """
246 try:
247 return parseISO8601Timestamp(value, timezone.utc)
248 except ValueError as ex:
249 error = GitHubError(f"Field '{field}' isn't an ISO 8601 timestamp.")
250 error.add_note(f"Got '{value}'.")
251 raise error from ex
254def _parseURL(value: Nullable[str], field: str) -> Nullable[URL]:
255 """
256 Parse a URL, as the GitHub REST API reports them.
258 :param value: Optional, the field's value. Default: ``None``.
259 :param field: Name of the field, kept for symmetry with :func:`_parseISO8601Timestamp` and for the message this
260 will report once :meth:`~pyTooling.GenericPath.URL.URL.Parse` rejects what isn't a URL.
261 :returns: The URL, or ``None`` if the field was absent or empty.
262 """
263 if value is None or value == "":
264 return None
266 return URL.Parse(value)
269def _splitMatrixJobName(name: str) -> tuple[str, Nullable[dict[str, str]]]:
270 """
271 Split a job's name into the matrix' name and the dimensions, if it carries any.
273 GitHub appends the dimensions' values of a matrix instance to the job's name, as
274 ``Unit Tests (ubuntu-26.04, 3.14)``. That bracketed suffix is a naming convention of GitHub's own interface, not a
275 field of the payload, so a job genuinely named ``Build (fast)`` and produced by no matrix is indistinguishable from
276 one that was. A job whose workflow sets its own ``name:`` carries no values at all, and its matrix stays invisible.
278 The suffix carries no dimension names, so a dimension is named by the position of its value: ``"0"``, ``"1"``, ...
280 :param name: The job's name, without any calling workflows' prefixes.
281 :returns: The name without the suffix and the dimensions, or the name and ``None`` if it carries none.
282 """
283 if not name.endswith(")") or "(" not in name:
284 return name, None
286 base, _, values = name[:-1].rpartition("(")
287 base = base.rstrip()
288 if base == "":
289 return name, None
291 return base, {str(position): value.strip() for position, value in enumerate(values.split(","))}
294@export
295class StatusMixin(metaclass=ExtendedType, mixin=True, expects=("_outcome",)):
296 """
297 Mixin-class for the elements GitHub reports: a workflow run, a job and a step.
299 GitHub reports a :class:`Status` and, once completed, a :class:`Conclusion` for each of them, and a URL on
300 github.com for a run and a job. A called workflow and a matrix aren't reported, so they have none of it. The
301 conclusion is also the element's generic :attr:`~pyTooling.CI.Base.Outcome`.
302 """
304 _status: Nullable[Status] #: State the element is in.
305 _conclusion: Nullable[Conclusion] #: How the element ended.
306 _url: Nullable[URL] #: URL of the element on github.com.
308 def __init__(
309 self,
310 status: Nullable[Status] = None,
311 conclusion: Nullable[Conclusion] = None,
312 url: Nullable[URL] = None
313 ) -> None:
314 """
315 Initializes what GitHub reports about an element.
317 :param status: Optional, state the element is in. Default: ``None``.
318 :param conclusion: Optional, how the element ended. Default: ``None``.
319 :param url: Optional, URL of the element on github.com. Default: ``None``.
320 :raises TypeError: If parameter 'status' is not of type :class:`Status`.
321 :raises TypeError: If parameter 'conclusion' is not of type :class:`Conclusion`.
322 :raises TypeError: If parameter 'url' is not of type :class:`~pyTooling.GenericPath.URL.URL`.
323 """
324 if status is not None and not isinstance(status, Status):
325 ex = TypeError("Parameter 'status' is not of type 'Status'.")
326 ex.add_note(f"Got type '{getFullyQualifiedName(status)}'.")
327 raise ex
329 if conclusion is not None and not isinstance(conclusion, Conclusion):
330 ex = TypeError("Parameter 'conclusion' is not of type 'Conclusion'.")
331 ex.add_note(f"Got type '{getFullyQualifiedName(conclusion)}'.")
332 raise ex
334 if url is not None and not isinstance(url, URL):
335 ex = TypeError("Parameter 'url' is not of type 'URL'.")
336 ex.add_note(f"Got type '{getFullyQualifiedName(url)}'.")
337 raise ex
339 self._status = status
340 self._conclusion = conclusion
341 self._url = url
342 if conclusion is not None:
343 self._outcome = conclusion.ToOutcome()
345 @readonly
346 def Status(self) -> Nullable[Status]:
347 """
348 Read-only property to access the state the element is in (:attr:`_status`).
350 :returns: The state, or ``None`` if GitHub reported none.
351 """
352 return self._status
354 @readonly
355 def Conclusion(self) -> Nullable[Conclusion]:
356 """
357 Read-only property to access how the element ended (:attr:`_conclusion`).
359 The service-independent :attr:`~pyTooling.CI.Base.Outcome` is derived from it by
360 :meth:`Conclusion.ToOutcome`.
362 :returns: The conclusion, or ``None`` while the element hasn't concluded.
363 """
364 return self._conclusion
366 @readonly
367 def URL(self) -> Nullable[URL]:
368 """
369 Read-only property to access the element's URL on github.com (:attr:`_url`).
371 :returns: The URL, or ``None`` if GitHub reported none.
372 """
373 return self._url
376@export
377class PipelineGroup(CIPipelineGroup):
378 """
379 Every workflow run GitHub started for one commit, and the top of the tree.
381 A push starts one run per workflow file whose triggers match, so a commit has as many pipelines as the repository
382 has matching workflows - `pyTooling/Actions` answers a push with six.
384 **A run at a tag is in the group as well, and is not the same thing.** It carries the same commit, so the API
385 cannot separate it, but it was started later and for a different reason: a release pipeline tags its own commit,
386 and the run at that tag publishes the release. For `pyTooling/MiKTeX` v1.6.0 the two runs of commit ``2c36ead``
387 were
389 .. code-block:: text
391 event=push head_branch=main run_started_at=07:53:39 tags the commit
392 event=workflow_dispatch head_branch=v1.6.0 run_started_at=08:09:37 publishes the release
394 :meth:`ByGitReference` separates them, since :attr:`Pipeline.GitReference` holds the tag's name for the second.
396 The group has no times of its own and derives them from its pipelines - so its span covers the tag's run too, and
397 is wider than the time the commit's checks took.
398 """
400 def __init__(
401 self,
402 sha: str,
403 pipelines: Nullable[Iterable[Pipeline]] = None,
404 *,
405 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None
406 ) -> None:
407 """
408 Initializes a group of pipelines started for one commit.
410 :param sha: Commit every pipeline of the group was started on.
411 :param pipelines: Optional, the pipelines, which are attached to the group. Default: ``None``.
412 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
413 :raises ValueError: If parameter 'sha' is ``None``.
414 :raises TypeError: If parameter 'sha' is not of type :class:`str`.
415 :raises ValueError: If parameter 'sha' is empty.
416 :raises TypeError: If an element of parameter 'pipelines' is not of type :class:`Pipeline`.
417 """
418 if sha is None:
419 raise ValueError("Parameter 'sha' is None.")
420 elif not isinstance(sha, str):
421 ex = TypeError("Parameter 'sha' is not of type 'str'.")
422 ex.add_note(f"Got type '{getFullyQualifiedName(sha)}'.")
423 raise ex
424 elif sha == "":
425 raise ValueError("Parameter 'sha' is empty.")
427 super().__init__(sha, pipelines, keyValuePairs=keyValuePairs)
429 @readonly
430 def SHA(self) -> str:
431 """
432 Read-only property to access the commit every pipeline of the group was started on (:attr:`_name`).
434 :returns: The commit's hash.
435 """
436 return self._name
438 @readonly
439 def Conclusion(self) -> Nullable[Conclusion]:
440 """
441 Read-only property to return how the commit's pipelines ended, taken together.
443 The worst conclusion wins, so one failed pipeline makes the commit's verdict a failure, as a branch protection
444 rule would. The order is:
446 #. :attr:`~Conclusion.Failure`
447 #. :attr:`~Conclusion.TimedOut`
448 #. :attr:`~Conclusion.StartupFailure`
449 #. :attr:`~Conclusion.ActionRequired`
450 #. :attr:`~Conclusion.Cancelled`
451 #. :attr:`~Conclusion.Stale`
452 #. :attr:`~Conclusion.Neutral`
453 #. :attr:`~Conclusion.Skipped`
454 #. :attr:`~Conclusion.Success`
456 :returns: The worst conclusion of the group's pipelines, or ``None`` while one hasn't concluded.
457 """
458 if len(self._pipelines) == 0: 458 ↛ 459line 458 didn't jump to line 459 because the condition on line 458 was never true
459 return None
461 conclusions = set()
462 for pipeline in self._pipelines:
463 if pipeline.Conclusion is None:
464 return None
466 conclusions.add(pipeline.Conclusion)
468 for conclusion in ( 468 ↛ 475line 468 didn't jump to line 475 because the loop on line 468 didn't complete
469 Conclusion.Failure, Conclusion.TimedOut, Conclusion.StartupFailure, Conclusion.ActionRequired,
470 Conclusion.Cancelled, Conclusion.Stale, Conclusion.Neutral, Conclusion.Skipped, Conclusion.Success
471 ):
472 if conclusion in conclusions:
473 return conclusion
475 return None
477 def ByGitReference(self) -> dict[Nullable[str], list[Pipeline]]:
478 """
479 Group the commit's pipelines by the branch or tag they were started on.
481 A commit pushed to a branch and later tagged has its pipelines under two keys - the branch's name and the
482 tag's - which is what separates the checks of a commit from the run that published its release.
484 :returns: Dictionary of a reference's name to the pipelines started on it, in the order they were reported.
485 """
486 byReference: dict[Nullable[str], list[Pipeline]] = {}
487 for pipeline in self._pipelines:
488 if (pipelines := byReference.get(pipeline.GitReference, None)) is None: 488 ↛ 492line 488 didn't jump to line 492 because the condition on line 488 was always true
489 pipelines = []
490 byReference[pipeline.GitReference] = pipelines
492 pipelines.append(pipeline)
494 return byReference
496 @classmethod
497 def FromJSON(
498 cls,
499 runs: Union[JSONObject, Iterable[JSONObject]],
500 jobs: Nullable[dict[int, Iterable[JSONObject]]] = None,
501 sha: Nullable[str] = None
502 ) -> Self:
503 """
504 Build a group of pipelines from the JSON objects the GitHub REST API answers with.
506 :param runs: The runs, as returned by ``GET /repos/{owner}/{repo}/actions/runs?head_sha=...`` - either
507 the answer itself or its ``workflow_runs`` array.
508 :param jobs: Optional, the jobs of each run, by the run's identifier. Default: ``None``.
509 :param sha: Optional, the commit. Default: the ``head_sha`` the runs report.
510 :returns: The group, with its pipelines attached.
511 :raises GitHubError: If the runs report different commits.
512 :raises GitHubError: If no run reports a commit and parameter 'sha' wasn't given.
513 """
514 if not isinstance(runs, dict):
515 workflowRuns = runs
516 elif (workflowRuns := runs.get("workflow_runs", None)) is None: 516 ↛ 517line 516 didn't jump to line 517 because the condition on line 516 was never true
517 workflowRuns = []
519 pipelines = []
520 shas = set()
521 for run in workflowRuns:
522 runJobs = None
523 if jobs is not None:
524 runJobs = jobs.get(run.get("id", None), None)
526 pipeline = Pipeline.FromJSON(run, runJobs)
527 pipelines.append(pipeline)
528 if pipeline.SHA is not None:
529 shas.add(pipeline.SHA)
531 if sha is None:
532 if (commits := len(shas)) == 0:
533 raise GitHubError("None of the runs reports a 'head_sha', and parameter 'sha' wasn't given.")
534 elif commits > 1:
535 error = GitHubError("The runs report different commits.")
536 error.add_note(f"Got {', '.join(sorted(shas))}.")
537 raise error
539 sha = shas.pop()
541 return cls(sha, pipelines)
544@export
545class Pipeline(CIPipeline, StatusMixin):
546 """
547 A workflow run.
549 A run contains its own jobs, a :class:`~pyTooling.CI.Matrix` for every matrix, and a
550 :class:`~pyTooling.CI.Workflow` for every workflow it called. Unlike a called workflow, a run reports its
551 own status, conclusion and times. Several runs of one commit are held by a :class:`PipelineGroup`, which is the
552 top of the tree.
553 """
555 _PARENT_TYPE: ClassVar[Nullable[type]] = PipelineGroup #: A workflow run is contained in a pipeline group.
557 _id: Nullable[int] #: GitHub's identifier of the run.
558 _workflowID: Nullable[int] #: GitHub's identifier of the workflow the run belongs to.
559 _path: Nullable[str] #: Path of the workflow's YAML file in the repository.
560 _runNumber: Nullable[int] #: Number of the run within its workflow.
561 _runAttempt: Nullable[int] #: Attempt of the run, starting at 1.
562 _event: Nullable[Event] #: Event that triggered the run.
563 _gitReference: Nullable[str] #: Branch or tag the run was started on.
564 _sha: Nullable[str] #: Commit the run was started on.
566 def __init__(
567 self,
568 name: str,
569 identifier: Nullable[int] = None,
570 status: Nullable[Status] = None,
571 conclusion: Nullable[Conclusion] = None,
572 createdAt: Nullable[datetime] = None,
573 startedAt: Nullable[datetime] = None,
574 completedAt: Nullable[datetime] = None,
575 url: Nullable[URL] = None,
576 workflowID: Nullable[int] = None,
577 path: Nullable[str] = None,
578 runNumber: Nullable[int] = None,
579 runAttempt: Nullable[int] = None,
580 event: Nullable[Event] = None,
581 gitReference: Nullable[str] = None,
582 sha: Nullable[str] = None,
583 *,
584 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
585 parent: Nullable[PipelineGroup] = None
586 ) -> None:
587 """
588 Initializes a workflow run.
590 :param name: Name of the workflow.
591 :param identifier: Optional, GitHub's identifier of the run. Default: ``None``.
592 :param status: Optional, state the run is in. Default: ``None``.
593 :param conclusion: Optional, how the run ended. Default: ``None``.
594 :param createdAt: Optional, time the run was created. Default: ``None``.
595 :param startedAt: Optional, time the run started. Default: ``None``.
596 :param completedAt: Optional, time the run was last updated, once completed. Default: ``None``.
597 :param url: Optional, URL of the run on github.com. Default: ``None``.
598 :param workflowID: Optional, GitHub's identifier of the workflow the run belongs to. Default: ``None``.
599 :param path: Optional, path of the workflow's YAML file in the repository. Default: ``None``.
600 :param runNumber: Optional, number of the run within its workflow. Default: ``None``.
601 :param runAttempt: Optional, attempt of the run, starting at 1. Default: ``None``.
602 :param event: Optional, event that triggered the run. Default: ``None``.
603 :param gitReference: Optional, branch or tag the run was started on. Default: ``None``.
604 :param sha: Optional, commit the run was started on. Default: ``None``.
605 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
606 :param parent: Optional, reference to the group of the commit's runs. Default: ``None``.
607 :raises TypeError: If parameter 'identifier' is not of type :class:`int`.
608 :raises TypeError: If parameter 'workflowID' is not of type :class:`int`.
609 :raises TypeError: If parameter 'path' is not of type :class:`str`.
610 :raises TypeError: If parameter 'runNumber' is not of type :class:`int`.
611 :raises TypeError: If parameter 'runAttempt' is not of type :class:`int`.
612 :raises TypeError: If parameter 'event' is not of type :class:`Event`.
613 :raises TypeError: If parameter 'gitReference' is not of type :class:`str`.
614 :raises TypeError: If parameter 'sha' is not of type :class:`str`.
615 """
616 for parameterName, number in (
617 ("identifier", identifier), ("workflowID", workflowID), ("runNumber", runNumber), ("runAttempt", runAttempt)
618 ):
619 if number is not None and not isinstance(number, int):
620 ex = TypeError(f"Parameter '{parameterName}' is not of type 'int'.")
621 ex.add_note(f"Got type '{getFullyQualifiedName(number)}'.")
622 raise ex
624 if event is not None and not isinstance(event, Event):
625 ex = TypeError("Parameter 'event' is not of type 'Event'.")
626 ex.add_note(f"Got type '{getFullyQualifiedName(event)}'.")
627 raise ex
629 for parameterName, text in (("path", path), ("gitReference", gitReference), ("sha", sha)):
630 if text is not None and not isinstance(text, str):
631 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
632 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.")
633 raise ex
635 super().__init__(
636 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs,
637 parent=parent
638 )
639 StatusMixin.__init__(self, status, conclusion, url)
641 self._id = identifier
642 self._workflowID = workflowID
643 self._path = path
644 self._runNumber = runNumber
645 self._runAttempt = runAttempt
646 self._event = event
647 self._gitReference = gitReference
648 self._sha = sha
650 @readonly
651 def ID(self) -> Nullable[int]:
652 """
653 Read-only property to access GitHub's identifier of the run (:attr:`_id`).
655 :returns: The identifier, or ``None`` if GitHub reported none.
656 """
657 return self._id
659 @readonly
660 def WorkflowID(self) -> Nullable[int]:
661 """
662 Read-only property to access GitHub's identifier of the workflow the run belongs to (:attr:`_workflowID`).
664 Every run of the same workflow file reports the same identifier, so it groups a workflow's runs over time,
665 where :attr:`ID` identifies the single run.
667 :returns: The identifier, or ``None`` if GitHub reported none.
668 """
669 return self._workflowID
671 @readonly
672 def Path(self) -> Nullable[str]:
673 """
674 Read-only property to access the path of the workflow's YAML file (:attr:`_path`).
676 GitHub reports it relative to the repository's root, e.g. ``.github/workflows/Pipeline.yml``.
678 :returns: The path, or ``None`` if GitHub reported none.
679 """
680 return self._path
682 @readonly
683 def RunNumber(self) -> Nullable[int]:
684 """
685 Read-only property to access the run's number within its workflow (:attr:`_runNumber`).
687 :returns: The number, or ``None`` if GitHub reported none.
688 """
689 return self._runNumber
691 @readonly
692 def RunAttempt(self) -> Nullable[int]:
693 """
694 Read-only property to access which attempt of the run this is (:attr:`_runAttempt`).
696 :returns: The attempt, starting at 1, or ``None`` if GitHub reported none.
697 """
698 return self._runAttempt
700 @readonly
701 def Event(self) -> Nullable[Event]:
702 """
703 Read-only property to access the event that triggered the run (:attr:`_event`).
705 :returns: The event, or ``None`` if GitHub reported none.
706 """
707 return self._event
709 @readonly
710 def GitReference(self) -> Nullable[str]:
711 """
712 Read-only property to access the branch or tag the run was started on (:attr:`_gitReference`).
714 GitHub reports this as ``head_branch`` and puts the **tag's** name there for a run started at a tag, with no
715 field saying which it is - a run of `pyTooling/MiKTeX` v1.6.0 reports ``main``, and the run at the tag of the
716 very same commit reports ``v1.6.0``. :attr:`Event` is the other half of telling them apart.
718 :returns: The branch's or tag's name, or ``None`` if GitHub reported none.
719 """
720 return self._gitReference
722 @readonly
723 def SHA(self) -> Nullable[str]:
724 """
725 Read-only property to access the commit the run was started on (:attr:`_sha`).
727 :returns: The commit's hash, or ``None`` if GitHub reported none.
728 """
729 return self._sha
731 @classmethod
732 def FromJSON(
733 cls,
734 run: JSONObject,
735 jobs: Nullable[Iterable[JSONObject]] = None,
736 *,
737 parent: Nullable[PipelineGroup] = None
738 ) -> Self:
739 """
740 Build a workflow run and its tree from the JSON objects the GitHub REST API answers with.
742 A job's name says where it sits, and is read back into the tree: ``Docs / Sphinx / HTML`` nests below a
743 :class:`~pyTooling.CI.Workflow` per prefix, and ``Unit Tests (ubuntu-26.04, 3.14)`` becomes a
744 :class:`MatrixJob` below a :class:`~pyTooling.CI.Matrix` named ``Unit Tests``. A prefix carrying
745 dimension values - ``Tests (3.14) / Unit``, a matrix of calls of a reusable workflow - becomes a
746 :class:`~pyTooling.CI.MatrixWorkflow` below a :class:`~pyTooling.CI.Matrix` named ``Tests``.
748 :param run: The workflow run, as returned by ``GET /repos/{owner}/{repo}/actions/runs/{run_id}``.
749 :param jobs: Optional, the run's jobs, as listed by ``GET .../actions/runs/{run_id}/jobs``.
750 :param parent: Optional, reference to the group of the commit's runs. Default: ``None``.
751 :returns: The workflow run, with its workflows, matrices, jobs and steps attached.
752 :raises TypeError: If parameter 'run' is not of type :class:`dict`.
753 :raises TypeError: If an element of parameter 'jobs' is not of type :class:`dict`.
754 :raises GitHubError: If field ``name`` is missing.
755 :raises GitHubError: If a field holds a value GitHub doesn't document.
756 """
757 if not isinstance(run, dict):
758 ex = TypeError("Parameter 'run' is not of type 'dict'.")
759 ex.add_note(f"Got type '{getFullyQualifiedName(run)}'.")
760 raise ex
762 if (name := run.get("name", None)) is None:
763 raise GitHubError("Field 'run.name' is missing.")
765 identifier = run.get("id", None)
766 status = Status.Parse(run.get("status", None))
767 conclusion = Conclusion.Parse(run.get("conclusion", None))
768 createdAt = _parseISO8601Timestamp(run.get("created_at", None), "run.created_at")
769 startedAt = _parseISO8601Timestamp(run.get("run_started_at", None), "run.run_started_at")
770 completedAt = _parseISO8601Timestamp(run.get("updated_at", None), "run.updated_at") \
771 if status is Status.Completed else None
772 url = _parseURL(run.get("html_url", None), "run.html_url")
773 workflowID = run.get("workflow_id", None)
774 path = run.get("path", None)
775 runNumber = run.get("run_number", None)
776 runAttempt = run.get("run_attempt", None)
777 event = Event.Parse(run.get("event", None))
778 gitReference = run.get("head_branch", None)
779 sha = run.get("head_sha", None)
781 pipeline = cls(
782 name, identifier, status, conclusion, createdAt, startedAt, completedAt, url, workflowID, path, runNumber,
783 runAttempt, event, gitReference, sha, parent=parent
784 )
786 if jobs is None:
787 return pipeline
789 for position, job in enumerate(jobs):
790 if not isinstance(job, dict):
791 ex = TypeError(f"Job {position} is not of type 'dict'.")
792 ex.add_note(f"Got type '{getFullyQualifiedName(job)}'.")
793 raise ex
795 path = f"jobs[{position}]"
796 if (fullName := job.get("name", None)) is None:
797 raise GitHubError(f"Field '{path}.name' is missing.")
799 *callers, leaf = fullName.split(" / ")
801 group: CIWorkflow = pipeline
802 for caller in callers:
803 if (calledWorkflow := group.Workflows.get(caller, None)) is None:
804 callerName, callerDimensions = _splitMatrixJobName(caller)
805 if callerDimensions is None:
806 calledWorkflow = CIWorkflow(caller, parent=group)
807 else:
808 if (matrix := group.Matrices.get(callerName, None)) is None:
809 matrix = CIMatrix(callerName, parent=group)
811 if matrix.ContainsElement(caller):
812 calledWorkflow = matrix.GetElement(caller)
813 else:
814 calledWorkflow = CIMatrixWorkflow(callerName, callerDimensions, parent=matrix)
816 group = calledWorkflow
818 jobName, dimensions = _splitMatrixJobName(leaf)
819 if dimensions is None:
820 Job.FromJSON(job, path, parent=group)
821 else:
822 if (matrix := group.Matrices.get(jobName, None)) is None:
823 matrix = CIMatrix(jobName, parent=group)
825 MatrixJob.FromJSON(job, path, jobName, dimensions, parent=matrix)
827 return pipeline
830@export
831class Job(CIJob, StatusMixin):
832 """A job of a workflow run, which ran on a runner and contains steps."""
834 _id: Nullable[int] #: GitHub's identifier of the job.
835 _labels: list[str] #: Labels the job requested its runner by, i.e. its ``runs-on``.
836 _runnerName: Nullable[str] #: Name of the runner the job ran on.
837 _runnerGroupName: Nullable[str] #: Name of the runner group the runner belongs to.
839 def __init__(
840 self,
841 name: str,
842 identifier: Nullable[int] = None,
843 status: Nullable[Status] = None,
844 conclusion: Nullable[Conclusion] = None,
845 createdAt: Nullable[datetime] = None,
846 startedAt: Nullable[datetime] = None,
847 completedAt: Nullable[datetime] = None,
848 url: Nullable[URL] = None,
849 labels: Nullable[Iterable[str]] = None,
850 runnerName: Nullable[str] = None,
851 runnerGroupName: Nullable[str] = None,
852 steps: Nullable[Iterable[Step]] = None,
853 *,
854 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
855 parent: Nullable[CIJobGroup] = None
856 ) -> None:
857 """
858 Initializes a job of a workflow run.
860 :param name: Name of the job, without the calling workflows' prefixes.
861 :param identifier: Optional, GitHub's identifier of the job. Default: ``None``.
862 :param status: Optional, state the job is in. Default: ``None``.
863 :param conclusion: Optional, how the job ended. Default: ``None``.
864 :param createdAt: Optional, time the job was created, i.e. queued for a runner. Default: ``None``.
865 :param startedAt: Optional, time the job started running on a runner. Default: ``None``.
866 :param completedAt: Optional, time the job completed. Default: ``None``.
867 :param url: Optional, URL of the job on github.com. Default: ``None``.
868 :param labels: Optional, labels the job requested its runner by. Default: ``None``.
869 :param runnerName: Optional, name of the runner the job ran on. Default: ``None``.
870 :param runnerGroupName: Optional, name of the runner group the runner belongs to. Default: ``None``.
871 :param steps: Optional, the job's steps, which are attached to it. Default: ``None``.
872 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
873 :param parent: Optional, reference to the group containing the job. Default: ``None``.
874 :raises TypeError: If parameter 'identifier' is not of type :class:`int`.
875 :raises TypeError: If an element of parameter 'steps' is not of type :class:`Step`.
876 :raises TypeError: If an element of parameter 'labels' is not of type :class:`str`.
877 :raises TypeError: If parameter 'runnerName' is not of type :class:`str`.
878 :raises TypeError: If parameter 'runnerGroupName' is not of type :class:`str`.
879 """
880 if identifier is not None and not isinstance(identifier, int):
881 ex = TypeError("Parameter 'identifier' is not of type 'int'.")
882 ex.add_note(f"Got type '{getFullyQualifiedName(identifier)}'.")
883 raise ex
885 for parameterName, value in (("runnerName", runnerName), ("runnerGroupName", runnerGroupName)):
886 if value is not None and not isinstance(value, str):
887 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
888 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
889 raise ex
891 self._labels = []
892 if labels is not None:
893 for label in labels:
894 if not isinstance(label, str):
895 ex = TypeError("An element of parameter 'labels' is not of type 'str'.")
896 ex.add_note(f"Got type '{getFullyQualifiedName(label)}'.")
897 raise ex
899 self._labels.append(label)
901 stepList = []
902 if steps is not None:
903 for step in steps:
904 if not isinstance(step, Step):
905 ex = TypeError("An element of parameter 'steps' is not of type 'Step'.")
906 ex.add_note(f"Got type '{getFullyQualifiedName(step)}'.")
907 raise ex
909 stepList.append(step)
911 super().__init__(
912 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs,
913 parent=parent
914 )
915 StatusMixin.__init__(self, status, conclusion, url)
917 self._id = identifier
918 self._runnerName = runnerName
919 self._runnerGroupName = runnerGroupName
921 for step in stepList:
922 step._parent = self
923 step._pipeline = self._pipeline
924 self._steps.append(step)
926 @readonly
927 def ID(self) -> Nullable[int]:
928 """
929 Read-only property to access GitHub's identifier of the job (:attr:`_id`).
931 :returns: The identifier, or ``None`` if GitHub reported none.
932 """
933 return self._id
935 @readonly
936 def Labels(self) -> list[str]:
937 """
938 Read-only property to access the labels the job requested its runner by (:attr:`_labels`).
940 These are the values of the workflow's ``runs-on``. For a hosted runner a label doubles as the image's name
941 (``ubuntu-26.04``); for a self-hosted runner they are the tags the runner was registered with.
943 :returns: The labels, empty if GitHub reported none.
944 """
945 return self._labels
947 @readonly
948 def RunnerName(self) -> Nullable[str]:
949 """
950 Read-only property to access the name of the runner the job ran on (:attr:`_runnerName`).
952 :returns: The runner's name, or ``None`` while the job hasn't started.
953 """
954 return self._runnerName
956 @readonly
957 def RunnerGroupName(self) -> Nullable[str]:
958 """
959 Read-only property to access the name of the runner group the runner belongs to (:attr:`_runnerGroupName`).
961 :returns: The group's name, or ``None`` if GitHub reported none.
962 """
963 return self._runnerGroupName
965 @readonly
966 def CreatedAt(self) -> Nullable[datetime]:
967 """
968 Read-only property to return the time the job was created, at the latest when it started.
970 :returns: The time, or ``None`` if GitHub reported none.
971 """
972 if self._createdAt is None or all(step.StartedAt is None for step in self._steps):
973 return self._createdAt
975 return min(self._createdAt, self.StartedAt)
977 @readonly
978 def StartedAt(self) -> Nullable[datetime]:
979 """
980 Read-only property to return the time the job started, at the latest when its first step started.
982 GitHub reports a job's times and its steps' times independently and in whole seconds, so a step is sometimes
983 reported as starting before the job containing it. The step really did run then, so the job is the timespan
984 that stretches - otherwise a consumer building a tree has a child outside its parent.
986 :returns: The time, or ``None`` while neither the job nor a step of it has started.
987 """
988 times = [step.StartedAt for step in self._steps if step.StartedAt is not None]
989 if len(times) == 0:
990 return self._startedAt
992 first = min(times)
994 return first if self._startedAt is None else min(self._startedAt, first)
996 @readonly
997 def CompletedAt(self) -> Nullable[datetime]:
998 """
999 Read-only property to return the time the job completed, at the earliest when its last step completed.
1001 A job that hasn't completed reports no time, even when a step of it has - see :attr:`StartedAt` for why the
1002 job is the timespan that stretches.
1004 :returns: The time, or ``None`` while the job hasn't completed.
1005 """
1006 if self._completedAt is None or all(step.StartedAt is None for step in self._steps):
1007 return self._completedAt
1009 times = [step.CompletedAt for step in self._steps if step.CompletedAt is not None]
1010 if len(times) == 0: 1010 ↛ 1011line 1010 didn't jump to line 1011 because the condition on line 1010 was never true
1011 return self._completedAt
1013 return max(self._completedAt, max(times))
1015 @readonly
1016 def QueuedDuration(self) -> Nullable[float]:
1017 """
1018 Read-only property to return how long the job waited for a runner.
1020 :returns: Seconds from being created to starting, or ``None`` while either time is unknown.
1021 """
1022 if self._createdAt is None or self._startedAt is None:
1023 return None
1025 return (self._startedAt - self._createdAt).total_seconds()
1027 @classmethod
1028 def FromJSON(cls, json: JSONObject, path: str = "job", *, parent: Nullable[CIJobGroup] = None) -> Self:
1029 """
1030 Build a job and its steps from the JSON object the GitHub REST API answers with.
1032 The job's name is taken without the calling workflows' prefixes: a job reported as ``Caller / Build`` is named
1033 ``Build``, because the prefix describes where it sits, which the tree already says.
1035 :param json: The job, as listed by ``GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs``.
1036 :param path: Optional, position of the job, for an exception's message. Default: ``'job'``.
1037 :param parent: Optional, reference to the group containing the job. Default: ``None``.
1038 :returns: The job, with its steps attached.
1039 :raises TypeError: If parameter 'json' is not of type :class:`dict`.
1040 :raises GitHubError: If field ``name`` is missing.
1041 :raises GitHubError: If a field holds a value GitHub doesn't document.
1042 """
1043 if not isinstance(json, dict):
1044 ex = TypeError("Parameter 'json' is not of type 'dict'.")
1045 ex.add_note(f"Got type '{getFullyQualifiedName(json)}'.")
1046 raise ex
1048 if (fullName := json.get("name", None)) is None: 1048 ↛ 1049line 1048 didn't jump to line 1049 because the condition on line 1048 was never true
1049 raise GitHubError(f"Field '{path}.name' is missing.")
1051 name = fullName.rsplit(" / ", 1)[-1]
1052 identifier = json.get("id", None)
1053 status = Status.Parse(json.get("status", None))
1054 conclusion = Conclusion.Parse(json.get("conclusion", None))
1055 createdAt = _parseISO8601Timestamp(json.get("created_at", None), f"{path}.created_at")
1056 startedAt = _parseISO8601Timestamp(json.get("started_at", None), f"{path}.started_at")
1057 completedAt = _parseISO8601Timestamp(json.get("completed_at", None), f"{path}.completed_at")
1058 url = _parseURL(json.get("html_url", None), f"{path}.html_url")
1059 labels = json.get("labels", None)
1060 runnerName = json.get("runner_name", None)
1061 runnerGroupName = json.get("runner_group_name", None)
1063 if (jsonSteps := json.get("steps", None)) is None: 1063 ↛ 1064line 1063 didn't jump to line 1064 because the condition on line 1063 was never true
1064 steps = None
1065 else:
1066 steps = [Step.FromJSON(step, f"{path}.steps[{pos}]") for pos, step in enumerate(jsonSteps)]
1068 return cls(
1069 name, identifier, status, conclusion, createdAt, startedAt, completedAt, url, labels, runnerName,
1070 runnerGroupName, steps, parent=parent
1071 )
1074@export
1075class MatrixJob(Job, MatrixInstanceMixin):
1076 """
1077 One instance of a job produced by a matrix.
1079 GitHub reports a matrix instance as an ordinary job whose name carries the dimensions' values in brackets, e.g.
1080 ``Unit Tests (ubuntu-26.04, 3.14)``. :meth:`Pipeline.FromJSON` reads those back into :attr:`Dimensions` and groups
1081 the instances below a :class:`~pyTooling.CI.Matrix`. The values are in the order GitHub prints them. The
1082 job's payload names no dimension - only the workflow file does -, so a dimension's name is the position of its
1083 value, ``{"0": "ubuntu-26.04", "1": "3.14"}``, until the names are known.
1084 """
1086 _PARENT_TYPE: ClassVar[Nullable[type]] = CIMatrix #: A matrix instance is contained in a matrix.
1088 def __init__(
1089 self,
1090 name: str,
1091 dimensions: Nullable[Mapping[str, Any]] = None,
1092 identifier: Nullable[int] = None,
1093 status: Nullable[Status] = None,
1094 conclusion: Nullable[Conclusion] = None,
1095 createdAt: Nullable[datetime] = None,
1096 startedAt: Nullable[datetime] = None,
1097 completedAt: Nullable[datetime] = None,
1098 url: Nullable[URL] = None,
1099 labels: Nullable[Iterable[str]] = None,
1100 runnerName: Nullable[str] = None,
1101 runnerGroupName: Nullable[str] = None,
1102 steps: Nullable[Iterable[Step]] = None,
1103 *,
1104 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
1105 parent: Nullable[CIMatrix] = None
1106 ) -> None:
1107 """
1108 Initializes one instance of a job produced by a matrix.
1110 :param name: Name of the job, without the dimensions' values.
1111 :param dimensions: Optional, the dimensions' names and values this instance ran with. Default: ``None``.
1112 :param identifier: Optional, GitHub's identifier of the job. Default: ``None``.
1113 :param status: Optional, state the job is in. Default: ``None``.
1114 :param conclusion: Optional, how the job ended. Default: ``None``.
1115 :param createdAt: Optional, time the job was created, i.e. queued for a runner. Default: ``None``.
1116 :param startedAt: Optional, time the job started running on a runner. Default: ``None``.
1117 :param completedAt: Optional, time the job completed. Default: ``None``.
1118 :param url: Optional, URL of the job on github.com. Default: ``None``.
1119 :param labels: Optional, labels the job requested its runner by. Default: ``None``.
1120 :param runnerName: Optional, name of the runner the job ran on. Default: ``None``.
1121 :param runnerGroupName: Optional, name of the runner group the runner belongs to. Default: ``None``.
1122 :param steps: Optional, the job's steps, which are attached to it. Default: ``None``.
1123 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
1124 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``.
1125 """
1126 super().__init__(
1127 name, identifier, status, conclusion, createdAt, startedAt, completedAt, url, labels, runnerName,
1128 runnerGroupName, steps, keyValuePairs=keyValuePairs, parent=parent
1129 )
1130 MatrixInstanceMixin.__init__(self, dimensions)
1132 def __str__(self) -> str:
1133 """
1134 Return a string representation of the matrix instance.
1136 :returns: The job's name with its dimensions' values, as GitHub prints it.
1137 """
1138 if len(self._dimensions) == 0: 1138 ↛ 1139line 1138 didn't jump to line 1139 because the condition on line 1138 was never true
1139 return self._name
1141 return f"{self._name} ({', '.join(str(value) for value in self._dimensions.values())})"
1143 @classmethod
1144 def FromJSON(
1145 cls,
1146 json: JSONObject,
1147 path: str = "job",
1148 name: Nullable[str] = None,
1149 dimensions: Nullable[Mapping[str, Any]] = None,
1150 *,
1151 parent: Nullable[CIMatrix] = None
1152 ) -> Self:
1153 """
1154 Build a matrix instance and its steps from the JSON object the GitHub REST API answers with.
1156 :param json: The job, as listed by ``GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs``.
1157 :param path: Optional, position of the job, for an exception's message. Default: ``'job'``.
1158 :param name: Optional, the job's name without the dimensions' values. Default: read from the payload.
1159 :param dimensions: Optional, the dimensions' names and values. Default: read from the payload.
1160 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``.
1161 :returns: The matrix instance, with its steps attached.
1162 :raises TypeError: If parameter 'json' is not of type :class:`dict`.
1163 :raises GitHubError: If field ``name`` is missing.
1164 :raises GitHubError: If a field holds a value GitHub doesn't document.
1165 """
1166 if not isinstance(json, dict):
1167 ex = TypeError("Parameter 'json' is not of type 'dict'.")
1168 ex.add_note(f"Got type '{getFullyQualifiedName(json)}'.")
1169 raise ex
1171 if (fullName := json.get("name", None)) is None: 1171 ↛ 1172line 1171 didn't jump to line 1172 because the condition on line 1171 was never true
1172 raise GitHubError(f"Field '{path}.name' is missing.")
1174 if name is None: 1174 ↛ 1175line 1174 didn't jump to line 1175 because the condition on line 1174 was never true
1175 name, dimensions = _splitMatrixJobName(fullName.rsplit(" / ", 1)[-1])
1177 identifier = json.get("id", None)
1178 status = Status.Parse(json.get("status", None))
1179 conclusion = Conclusion.Parse(json.get("conclusion", None))
1180 createdAt = _parseISO8601Timestamp(json.get("created_at", None), f"{path}.created_at")
1181 startedAt = _parseISO8601Timestamp(json.get("started_at", None), f"{path}.started_at")
1182 completedAt = _parseISO8601Timestamp(json.get("completed_at", None), f"{path}.completed_at")
1183 url = _parseURL(json.get("html_url", None), f"{path}.html_url")
1184 labels = json.get("labels", None)
1185 runnerName = json.get("runner_name", None)
1186 runnerGroupName = json.get("runner_group_name", None)
1188 if (jsonSteps := json.get("steps", None)) is None: 1188 ↛ 1189line 1188 didn't jump to line 1189 because the condition on line 1188 was never true
1189 steps = None
1190 else:
1191 steps = [Step.FromJSON(step, f"{path}.steps[{pos}]") for pos, step in enumerate(jsonSteps)]
1193 return cls(
1194 name, dimensions, identifier, status, conclusion, createdAt, startedAt, completedAt, url, labels,
1195 runnerName, runnerGroupName, steps, parent=parent
1196 )
1199@export
1200class Step(CIStep, StatusMixin):
1201 """A step within a job."""
1203 _PARENT_TYPE: ClassVar[Nullable[type]] = Job #: A step is contained in a job of a workflow run.
1205 _number: Nullable[int] #: Position of the step within its job, starting at 1.
1207 def __init__(
1208 self,
1209 name: str,
1210 number: Nullable[int] = None,
1211 status: Nullable[Status] = None,
1212 conclusion: Nullable[Conclusion] = None,
1213 startedAt: Nullable[datetime] = None,
1214 completedAt: Nullable[datetime] = None,
1215 *,
1216 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
1217 parent: Nullable[Job] = None
1218 ) -> None:
1219 """
1220 Initializes a step within a job.
1222 :param name: Name of the step.
1223 :param number: Optional, position of the step within its job, starting at 1. Default: ``None``.
1224 :param status: Optional, state the step is in. Default: ``None``.
1225 :param conclusion: Optional, how the step ended. Default: ``None``.
1226 :param startedAt: Optional, time the step started running. Default: ``None``.
1227 :param completedAt: Optional, time the step completed. Default: ``None``.
1228 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
1229 :param parent: Optional, reference to the job containing the step. Default: ``None``.
1230 :raises TypeError: If parameter 'number' is not of type :class:`int`.
1231 :raises ValueError: If parameter 'number' is not positive.
1232 """
1233 if number is not None and not isinstance(number, int):
1234 ex = TypeError("Parameter 'number' is not of type 'int'.")
1235 ex.add_note(f"Got type '{getFullyQualifiedName(number)}'.")
1236 raise ex
1237 elif number is not None and number < 1:
1238 ex = ValueError("Parameter 'number' is not positive.")
1239 ex.add_note(f"Got value '{number}'.")
1240 raise ex
1242 super().__init__(
1243 name, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs, parent=parent
1244 )
1245 StatusMixin.__init__(self, status, conclusion)
1247 self._number = number
1249 @readonly
1250 def Number(self) -> Nullable[int]:
1251 """
1252 Read-only property to access the step's position within its job (:attr:`_number`).
1254 :returns: The position, starting at 1, or ``None`` if GitHub reported none.
1255 """
1256 return self._number
1258 @classmethod
1259 def FromJSON(cls, json: JSONObject, path: str = "step", *, parent: Nullable[Job] = None) -> Self:
1260 """
1261 Build a step from the JSON object the GitHub REST API answers with.
1263 :param json: The step, as it appears in a job's ``steps`` array.
1264 :param path: Optional, position of the step, for an exception's message. Default: ``'step'``.
1265 :param parent: Optional, reference to the job containing the step. Default: ``None``.
1266 :returns: The step.
1267 :raises TypeError: If parameter 'json' is not of type :class:`dict`.
1268 :raises GitHubError: If field ``name`` is missing.
1269 :raises GitHubError: If a field holds a value GitHub doesn't document.
1270 """
1271 if not isinstance(json, dict):
1272 ex = TypeError("Parameter 'json' is not of type 'dict'.")
1273 ex.add_note(f"Got type '{getFullyQualifiedName(json)}'.")
1274 raise ex
1276 if (name := json.get("name", None)) is None: 1276 ↛ 1277line 1276 didn't jump to line 1277 because the condition on line 1276 was never true
1277 raise GitHubError(f"Field '{path}.name' is missing.")
1279 number = json.get("number", None)
1280 status = Status.Parse(json.get("status", None))
1281 conclusion = Conclusion.Parse(json.get("conclusion", None))
1282 startedAt = _parseISO8601Timestamp(json.get("started_at", None), f"{path}.started_at")
1283 completedAt = _parseISO8601Timestamp(json.get("completed_at", None), f"{path}.completed_at")
1285 return cls(name, number, status, conclusion, startedAt, completedAt, parent=parent)