Coverage for pyTooling/GitHub/__init__.py: 97%
525 statements
« prev ^ index » next coverage.py v7.17.3, created at 2026-11-14 02:08 +0000
« prev ^ index » next coverage.py v7.17.3, created at 2026-11-14 02: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.. tree::
39 - :class:`PipelineGroup` | every run started for one commit
40 - :class:`Pipeline` | a workflow run
41 - :class:`~pyTooling.CI.Workflow` | a called (reusable) workflow, grouping the jobs it contains
42 - :class:`~pyTooling.CI.Workflow` | a workflow called by that workflow
43 - :class:`~pyTooling.CI.Matrix` | a matrix, grouping the job instances it produced
44 - :class:`MatrixJob`
45 - :class:`Job`
46 - :class:`~pyTooling.CI.Matrix`
47 - :class:`Job` | a job that ran on a runner
48 - :class:`Step` | a step of that job
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, MatrixInstanceMixin, Outcome, Job as CIJob, JobGroup as CIJobGroup, \
75 Matrix as CIMatrix, MatrixWorkflow as CIMatrixWorkflow, Pipeline as CIPipeline, \
76 PipelineGroup as CIPipelineGroup, Step as CIStep, Workflow as CIWorkflow
77from pyTooling.Common import getFullyQualifiedName, parseISO8601Timestamp, StringEnum
78from pyTooling.Decorators import export, readonly
79from pyTooling.URI import URL
80from pyTooling.MetaClasses import ExtendedType
81from pyTooling.REST import JSONObject, RESTClient
84__all__ = ["CONCLUSION_TO_OUTCOME", "GITHUB_API_URL"]
87@export
88class GitHubError(CIError):
89 """Base-exception of all exceptions raised by :mod:`pyTooling.GitHub`."""
92GITHUB_API_URL = URL.Parse("https://api.github.com")
93"""Base URL of the GitHub REST API."""
95_GITHUB_HEADERS = {
96 "Accept": "application/vnd.github+json",
97 "X-GitHub-Api-Version": "2022-11-28"
98}
99"""Headers every request to the GitHub REST API carries."""
102@export
103class RepositoryClient(RESTClient):
104 """
105 A client of the GitHub REST API for one repository.
107 A reader of a repository's data derives from it, e.g. :class:`~pyTooling.GitHub.Tracing.WorkflowRunReader`. The
108 requests themselves - the retries, the pagination and the token's boundary - are
109 :class:`~pyTooling.REST.RESTClient`'s.
110 """
112 _owner: str #: Owner of the repository, a user or an organization.
113 _repository: str #: Name of the repository.
115 def __init__(
116 self,
117 owner: str,
118 repository: str,
119 token: Nullable[str] = None,
120 *,
121 apiURL: Union[str, URL] = GITHUB_API_URL,
122 timeout: float = 30.0,
123 retries: int = 3,
124 retryDelay: float = 2.0
125 ) -> None:
126 """
127 Initializes a client for one repository.
129 :param owner: The owner of the repository, a user or an organization.
130 :param repository: The name of the repository.
131 :param token: Optional, token authorizing the requests. Default: anonymous requests.
132 :param apiURL: Optional, base URL of the GitHub REST API, e.g. of a GitHub Enterprise Server.
133 Default: :data:`GITHUB_API_URL`.
134 :param timeout: Optional, timeout of a single request in seconds. Default: ``30.0``.
135 :param retries: Optional, how often a transiently failing request is tried again. ``0`` tries once.
136 Default: ``3``.
137 :param retryDelay: Optional, pause in seconds before a request is tried again the first time. The pause doubles
138 with every further attempt. Default: ``2.0``.
139 :raises ValueError: If parameter 'owner' is ``None``.
140 :raises TypeError: If parameter 'owner' is not of type :class:`str`.
141 :raises ValueError: If parameter 'owner' is empty or contains a ``/``.
142 :raises ValueError: If parameter 'repository' is ``None``.
143 :raises TypeError: If parameter 'repository' is not of type :class:`str`.
144 :raises ValueError: If parameter 'repository' is empty or contains a ``/``.
145 :raises TypeError: If a parameter of :class:`~pyTooling.REST.RESTClient` has the wrong type.
146 :raises ValueError: If a parameter of :class:`~pyTooling.REST.RESTClient` has an invalid value.
147 """
148 super().__init__(apiURL, token, headers=_GITHUB_HEADERS, timeout=timeout, retries=retries, retryDelay=retryDelay)
150 for parameterName, text in (("owner", owner), ("repository", repository)):
151 if text is None:
152 raise ValueError(f"Parameter '{parameterName}' is None.")
153 elif not isinstance(text, str):
154 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
155 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.")
156 raise ex
157 elif text == "" or "/" in text:
158 ex = ValueError(f"Parameter '{parameterName}' is empty or contains a '/'.")
159 ex.add_note(f"Got value '{text}'.")
160 raise ex
162 self._owner = owner
163 self._repository = repository
165 @readonly
166 def Owner(self) -> str:
167 """
168 Read-only property to access the repository's owner (:attr:`_owner`).
170 :returns: The name of the user or organization owning the repository.
171 """
172 return self._owner
174 @readonly
175 def Repository(self) -> str:
176 """
177 Read-only property to access the repository's name (:attr:`_repository`).
179 :returns: The name of the repository, without its owner.
180 """
181 return self._repository
184@export
185class Status(StringEnum):
186 """The state a workflow run, job or step is in."""
188 Queued = "queued" #: Waiting to be picked up.
189 InProgress = "in_progress" #: Running.
190 Completed = "completed" #: Finished, with a :class:`Conclusion`.
191 Waiting = "waiting" #: Held, e.g. for an environment's approval.
192 Requested = "requested" #: Requested, but not yet queued.
193 Pending = "pending" #: Blocked by a concurrency group.
195 @classmethod
196 def Parse(cls, value: Nullable[str]) -> Nullable[Status]:
197 """
198 Convert GitHub's ``status`` field to a member of this enumeration.
200 :param value: Optional, the field's value. Default: ``None``.
201 :returns: The matching member, or ``None`` if the field was absent or empty.
202 :raises TypeError: If parameter 'value' is not of type :class:`str`.
203 :raises GitHubError: If the value is not a status GitHub documents. |br|
204 The note lists the documented values.
205 """
206 try:
207 return super().Parse(value)
208 except ValueError as ex:
209 error = GitHubError(f"'{value}' is not a GitHub status.")
210 error.add_note(f"Known: {', '.join(member.value for member in cls)}.")
211 raise error from ex
214@export
215class Conclusion(StringEnum):
216 """How a completed workflow run, job or step ended."""
218 Success = "success" #: Succeeded.
219 Failure = "failure" #: Failed.
220 Cancelled = "cancelled" #: Cancelled before it finished.
221 Skipped = "skipped" #: Not run, because a condition excluded it.
222 TimedOut = "timed_out" #: Stopped by a timeout.
223 ActionRequired = "action_required" #: Waiting for a manual action.
224 Neutral = "neutral" #: Finished without a verdict.
225 Stale = "stale" #: Never ran, because the run was superseded.
226 StartupFailure = "startup_failure" #: The workflow file itself couldn't be started.
228 @classmethod
229 def Parse(cls, value: Nullable[str]) -> Nullable[Conclusion]:
230 """
231 Convert GitHub's ``conclusion`` field to a member of this enumeration.
233 :param value: Optional, the field's value. Default: ``None``.
234 :returns: The matching member, or ``None`` while it hasn't concluded.
235 :raises TypeError: If parameter 'value' is not of type :class:`str`.
236 :raises GitHubError: If the value is not a conclusion GitHub documents. |br|
237 The note lists the documented values.
238 """
239 try:
240 return super().Parse(value)
241 except ValueError as ex:
242 error = GitHubError(f"'{value}' is not a GitHub conclusion.")
243 error.add_note(f"Known: {', '.join(member.value for member in cls)}.")
244 raise error from ex
246 def ToOutcome(self) -> Outcome:
247 """
248 Return the service-independent outcome this conclusion corresponds to.
250 The conclusions with a counterpart of their own - e.g. :attr:`TimedOut`, :attr:`Skipped`, :attr:`Cancelled` - are
251 listed in :data:`CONCLUSION_TO_OUTCOME`; any other, e.g. :attr:`StartupFailure` or :attr:`Neutral`, is an
252 :attr:`~pyTooling.CI.Outcome.Error`.
254 :returns: The outcome.
255 """
256 return CONCLUSION_TO_OUTCOME.get(self, Outcome.Error)
259CONCLUSION_TO_OUTCOME = {
260 Conclusion.Success: Outcome.Success,
261 Conclusion.Failure: Outcome.Failure,
262 Conclusion.TimedOut: Outcome.Timeout,
263 Conclusion.Skipped: Outcome.Skip,
264 Conclusion.Cancelled: Outcome.Cancellation,
265}
266"""GitHub's conclusions with a service-independent outcome of their own."""
269@export
270class Event(StringEnum):
271 """The event that triggered a workflow run."""
273 CheckRun = "check_run" #: A check run was created or completed.
274 CheckSuite = "check_suite" #: A check suite was created or completed.
275 Create = "create" #: A branch or tag was created.
276 Delete = "delete" #: A branch or tag was deleted.
277 Deployment = "deployment" #: A deployment was created.
278 DeploymentStatus = "deployment_status" #: A deployment's status changed.
279 Discussion = "discussion" #: A discussion was touched.
280 DiscussionComment = "discussion_comment" #: A discussion was commented on.
281 Fork = "fork" #: The repository was forked.
282 Gollum = "gollum" #: A wiki page was created or updated.
283 IssueComment = "issue_comment" #: An issue or pull-request was commented on.
284 Issues = "issues" #: An issue was touched.
285 Label = "label" #: A label was touched.
286 MergeGroup = "merge_group" #: A merge group entered the merge queue.
287 Milestone = "milestone" #: A milestone was touched.
288 PageBuild = "page_build" #: GitHub Pages was built.
289 Public = "public" #: The repository was made public.
290 PullRequest = "pull_request" #: A pull-request was touched.
291 PullRequestComment = "pull_request_comment" #: A pull-request was commented on.
292 PullRequestReview = "pull_request_review" #: A pull-request was reviewed.
293 PullRequestReviewComment = "pull_request_review_comment" #: A review was commented on.
294 PullRequestTarget = "pull_request_target" #: A pull-request, run against its base.
295 Push = "push" #: A commit or tag was pushed.
296 RegistryPackage = "registry_package" #: A package was published or updated.
297 Release = "release" #: A release was touched.
298 RepositoryDispatch = "repository_dispatch" #: An external event was dispatched.
299 Schedule = "schedule" #: A cron schedule fired.
300 Status = "status" #: A commit's status changed.
301 Watch = "watch" #: The repository was starred.
302 WorkflowCall = "workflow_call" #: The workflow was called by another one.
303 WorkflowDispatch = "workflow_dispatch" #: The workflow was started by hand or by a token.
304 WorkflowRun = "workflow_run" #: Another workflow run completed.
305 Dynamic = "dynamic" #: GitHub started the run without a workflow file.
307 @classmethod
308 def Parse(cls, value: Nullable[str]) -> Nullable[Event]:
309 """
310 Convert GitHub's ``event`` field to a member of this enumeration.
312 :param value: Optional, the field's value. Default: ``None``.
313 :returns: The matching member, or ``None`` if the field was absent or empty.
314 :raises TypeError: If parameter 'value' is not of type :class:`str`.
315 :raises GitHubError: If the value is not an event GitHub documents. |br|
316 The note lists the documented values.
317 """
318 try:
319 return super().Parse(value)
320 except ValueError as ex:
321 error = GitHubError(f"'{value}' is not a GitHub event.")
322 error.add_note(f"Known: {', '.join(member.value for member in cls)}.")
323 raise error from ex
326def _parseISO8601Timestamp(value: Nullable[str], field: str) -> Nullable[datetime]:
327 """
328 Parse an ISO 8601 timestamp, as the GitHub REST API reports them.
330 A timestamp without a time zone is read as UTC, so every timestamp of a run can be compared with every other.
332 :param value: Optional, the field's value. Default: ``None``.
333 :param field: Name of the field, for the exception's message.
334 :returns: The timestamp, or ``None`` if the field was absent or empty.
335 :raises GitHubError: If the value isn't an ISO 8601 timestamp. |br|
336 The note reports the value that was read.
337 """
338 try:
339 return parseISO8601Timestamp(value, timezone.utc)
340 except ValueError as ex:
341 error = GitHubError(f"Field '{field}' isn't an ISO 8601 timestamp.")
342 error.add_note(f"Got '{value}'.")
343 raise error from ex
346def _parseURL(value: Nullable[str], field: str) -> Nullable[URL]:
347 """
348 Parse a URL, as the GitHub REST API reports them.
350 :param value: Optional, the field's value. Default: ``None``.
351 :param field: Name of the field, kept for symmetry with :func:`_parseISO8601Timestamp` and for the message this
352 will report once :meth:`~pyTooling.URI.URL.Parse` rejects what isn't a URL.
353 :returns: The URL, or ``None`` if the field was absent or empty.
354 """
355 if value is None or value == "":
356 return None
358 return URL.Parse(value)
361def _splitMatrixJobName(name: str) -> tuple[str, Nullable[dict[str, str]]]:
362 """
363 Split a job's name into the matrix' name and the dimensions, if it carries any.
365 GitHub appends the dimensions' values of a matrix instance to the job's name, as
366 ``Unit Tests (ubuntu-26.04, 3.14)``. That bracketed suffix is a naming convention of GitHub's own interface, not a
367 field of the payload, so a job genuinely named ``Build (fast)`` and produced by no matrix is indistinguishable from
368 one that was. A job whose workflow sets its own ``name:`` carries no values at all, and its matrix stays invisible.
370 The suffix carries no dimension names, so a dimension is named by the position of its value: ``"0"``, ``"1"``, ...
372 :param name: The job's name, without any calling workflows' prefixes.
373 :returns: The name without the suffix and the dimensions, or the name and ``None`` if it carries none.
374 """
375 if not name.endswith(")") or "(" not in name:
376 return name, None
378 base, _, values = name[:-1].rpartition("(")
379 base = base.rstrip()
380 if base == "":
381 return name, None
383 return base, {str(position): value.strip() for position, value in enumerate(values.split(","))}
386@export
387class StatusMixin(metaclass=ExtendedType, mixin=True, expects=("_outcome",)):
388 """
389 Mixin-class for the elements GitHub reports: a workflow run, a job and a step.
391 GitHub reports a :class:`Status` and, once completed, a :class:`Conclusion` for each of them, and a URL on
392 github.com for a run and a job. A called workflow and a matrix aren't reported, so they have none of it. The
393 conclusion is also the element's generic :attr:`~pyTooling.CI.Base.Outcome`.
394 """
396 _status: Nullable[Status] #: State the element is in.
397 _conclusion: Nullable[Conclusion] #: How the element ended.
398 _url: Nullable[URL] #: URL of the element on github.com.
400 def __init__(
401 self,
402 status: Nullable[Status] = None,
403 conclusion: Nullable[Conclusion] = None,
404 url: Nullable[URL] = None
405 ) -> None:
406 """
407 Initializes what GitHub reports about an element.
409 :param status: Optional, state the element is in. Default: ``None``.
410 :param conclusion: Optional, how the element ended. Default: ``None``.
411 :param url: Optional, URL of the element on github.com. Default: ``None``.
412 :raises TypeError: If parameter 'status' is not of type :class:`Status`.
413 :raises TypeError: If parameter 'conclusion' is not of type :class:`Conclusion`.
414 :raises TypeError: If parameter 'url' is not of type :class:`~pyTooling.URI.URL`.
415 """
416 if status is not None and not isinstance(status, Status):
417 ex = TypeError("Parameter 'status' is not of type 'Status'.")
418 ex.add_note(f"Got type '{getFullyQualifiedName(status)}'.")
419 raise ex
421 if conclusion is not None and not isinstance(conclusion, Conclusion):
422 ex = TypeError("Parameter 'conclusion' is not of type 'Conclusion'.")
423 ex.add_note(f"Got type '{getFullyQualifiedName(conclusion)}'.")
424 raise ex
426 if url is not None and not isinstance(url, URL):
427 ex = TypeError("Parameter 'url' is not of type 'URL'.")
428 ex.add_note(f"Got type '{getFullyQualifiedName(url)}'.")
429 raise ex
431 self._status = status
432 self._conclusion = conclusion
433 self._url = url
434 if conclusion is not None:
435 self._outcome = conclusion.ToOutcome()
437 @readonly
438 def Status(self) -> Nullable[Status]:
439 """
440 Read-only property to access the state the element is in (:attr:`_status`).
442 :returns: The state, or ``None`` if GitHub reported none.
443 """
444 return self._status
446 @readonly
447 def Conclusion(self) -> Nullable[Conclusion]:
448 """
449 Read-only property to access how the element ended (:attr:`_conclusion`).
451 The service-independent :attr:`~pyTooling.CI.Base.Outcome` is derived from it by
452 :meth:`Conclusion.ToOutcome`.
454 :returns: The conclusion, or ``None`` while the element hasn't concluded.
455 """
456 return self._conclusion
458 @readonly
459 def URL(self) -> Nullable[URL]:
460 """
461 Read-only property to access the element's URL on github.com (:attr:`_url`).
463 :returns: The URL, or ``None`` if GitHub reported none.
464 """
465 return self._url
468@export
469class PipelineGroup(CIPipelineGroup):
470 """
471 Every workflow run GitHub started for one commit, and the top of the tree.
473 A push starts one run per workflow file whose triggers match, so a commit has as many pipelines as the repository
474 has matching workflows - `pyTooling/Actions` answers a push with six.
476 **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
477 cannot separate it, but it was started later and for a different reason: a release pipeline tags its own commit,
478 and the run at that tag publishes the release. For `pyTooling/MiKTeX` v1.6.0 the two runs of commit ``2c36ead``
479 were
481 .. code-block:: text
483 event=push head_branch=main run_started_at=07:53:39 tags the commit
484 event=workflow_dispatch head_branch=v1.6.0 run_started_at=08:09:37 publishes the release
486 :meth:`ByGitReference` separates them, since :attr:`Pipeline.GitReference` holds the tag's name for the second.
488 The group has no times of its own and derives them from its pipelines - so its span covers the tag's run too, and
489 is wider than the time the commit's checks took.
490 """
492 def __init__(
493 self,
494 sha: str,
495 pipelines: Nullable[Iterable[Pipeline]] = None,
496 *,
497 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None
498 ) -> None:
499 """
500 Initializes a group of pipelines started for one commit.
502 :param sha: Commit every pipeline of the group was started on.
503 :param pipelines: Optional, the pipelines, which are attached to the group. Default: ``None``.
504 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
505 :raises ValueError: If parameter 'sha' is ``None``.
506 :raises TypeError: If parameter 'sha' is not of type :class:`str`.
507 :raises ValueError: If parameter 'sha' is empty.
508 :raises TypeError: If an element of parameter 'pipelines' is not of type :class:`Pipeline`.
509 """
510 if sha is None:
511 raise ValueError("Parameter 'sha' is None.")
512 elif not isinstance(sha, str):
513 ex = TypeError("Parameter 'sha' is not of type 'str'.")
514 ex.add_note(f"Got type '{getFullyQualifiedName(sha)}'.")
515 raise ex
516 elif sha == "":
517 raise ValueError("Parameter 'sha' is empty.")
519 super().__init__(sha, pipelines, keyValuePairs=keyValuePairs)
521 @readonly
522 def SHA(self) -> str:
523 """
524 Read-only property to access the commit every pipeline of the group was started on (:attr:`_name`).
526 :returns: The commit's hash.
527 """
528 return self._name
530 @readonly
531 def Conclusion(self) -> Nullable[Conclusion]:
532 """
533 Read-only property to return how the commit's pipelines ended, taken together.
535 The worst conclusion wins, so one failed pipeline makes the commit's verdict a failure, as a branch protection
536 rule would. The order is:
538 #. :attr:`~Conclusion.Failure`
539 #. :attr:`~Conclusion.TimedOut`
540 #. :attr:`~Conclusion.StartupFailure`
541 #. :attr:`~Conclusion.ActionRequired`
542 #. :attr:`~Conclusion.Cancelled`
543 #. :attr:`~Conclusion.Stale`
544 #. :attr:`~Conclusion.Neutral`
545 #. :attr:`~Conclusion.Skipped`
546 #. :attr:`~Conclusion.Success`
548 :returns: The worst conclusion of the group's pipelines, or ``None`` while one hasn't concluded.
549 """
550 if len(self._pipelines) == 0: 550 ↛ 551line 550 didn't jump to line 551 because the condition on line 550 was never true
551 return None
553 conclusions = set()
554 for pipeline in self._pipelines:
555 if pipeline.Conclusion is None:
556 return None
558 conclusions.add(pipeline.Conclusion)
560 for conclusion in ( 560 ↛ 567line 560 didn't jump to line 567 because the loop on line 560 didn't complete
561 Conclusion.Failure, Conclusion.TimedOut, Conclusion.StartupFailure, Conclusion.ActionRequired,
562 Conclusion.Cancelled, Conclusion.Stale, Conclusion.Neutral, Conclusion.Skipped, Conclusion.Success
563 ):
564 if conclusion in conclusions:
565 return conclusion
567 return None
569 def ByGitReference(self) -> dict[Nullable[str], list[Pipeline]]:
570 """
571 Group the commit's pipelines by the branch or tag they were started on.
573 A commit pushed to a branch and later tagged has its pipelines under two keys - the branch's name and the
574 tag's - which is what separates the checks of a commit from the run that published its release.
576 :returns: Dictionary of a reference's name to the pipelines started on it, in the order they were reported.
577 """
578 byReference: dict[Nullable[str], list[Pipeline]] = {}
579 for pipeline in self._pipelines:
580 if (pipelines := byReference.get(pipeline.GitReference, None)) is None: 580 ↛ 584line 580 didn't jump to line 584 because the condition on line 580 was always true
581 pipelines = []
582 byReference[pipeline.GitReference] = pipelines
584 pipelines.append(pipeline)
586 return byReference
588 @classmethod
589 def Parse(
590 cls,
591 runs: Union[JSONObject, Iterable[JSONObject]],
592 jobs: Nullable[dict[int, Iterable[JSONObject]]] = None,
593 sha: Nullable[str] = None
594 ) -> Self:
595 """
596 Build a group of pipelines from the JSON objects the GitHub REST API answers with.
598 :param runs: The runs, as returned by ``GET /repos/{owner}/{repo}/actions/runs?head_sha=...`` - either
599 the answer itself or its ``workflow_runs`` array.
600 :param jobs: Optional, the jobs of each run, by the run's identifier. Default: ``None``.
601 :param sha: Optional, the commit. Default: the ``head_sha`` the runs report.
602 :returns: The group, with its pipelines attached.
603 :raises GitHubError: If the runs report different commits.
604 :raises GitHubError: If no run reports a commit and parameter 'sha' wasn't given.
605 """
606 if not isinstance(runs, dict):
607 workflowRuns = runs
608 elif (workflowRuns := runs.get("workflow_runs", None)) is None: 608 ↛ 609line 608 didn't jump to line 609 because the condition on line 608 was never true
609 workflowRuns = []
611 pipelines = []
612 shas = set()
613 for run in workflowRuns:
614 runJobs = None
615 if jobs is not None:
616 runJobs = jobs.get(run.get("id", None), None)
618 pipeline = Pipeline.Parse(run, runJobs)
619 pipelines.append(pipeline)
620 if pipeline.SHA is not None:
621 shas.add(pipeline.SHA)
623 if sha is None:
624 if (commits := len(shas)) == 0:
625 raise GitHubError("None of the runs reports a 'head_sha', and parameter 'sha' wasn't given.")
626 elif commits > 1:
627 error = GitHubError("The runs report different commits.")
628 error.add_note(f"Got {', '.join(sorted(shas))}.")
629 raise error
631 sha = shas.pop()
633 return cls(sha, pipelines)
636@export
637class Pipeline(CIPipeline, StatusMixin):
638 """
639 A workflow run.
641 A run contains its own jobs, a :class:`~pyTooling.CI.Matrix` for every matrix, and a
642 :class:`~pyTooling.CI.Workflow` for every workflow it called. Unlike a called workflow, a run reports its
643 own status, conclusion and times. Several runs of one commit are held by a :class:`PipelineGroup`, which is the
644 top of the tree.
645 """
647 _PARENT_TYPE: ClassVar[Nullable[type]] = PipelineGroup #: A workflow run is contained in a pipeline group.
649 _id: Nullable[int] #: GitHub's identifier of the run.
650 _workflowID: Nullable[int] #: GitHub's identifier of the workflow the run belongs to.
651 _path: Nullable[str] #: Path of the workflow's YAML file in the repository.
652 _runNumber: Nullable[int] #: Number of the run within its workflow.
653 _runAttempt: Nullable[int] #: Attempt of the run, starting at 1.
654 _event: Nullable[Event] #: Event that triggered the run.
655 _gitReference: Nullable[str] #: Branch or tag the run was started on.
656 _sha: Nullable[str] #: Commit the run was started on.
658 def __init__(
659 self,
660 name: str,
661 identifier: Nullable[int] = None,
662 status: Nullable[Status] = None,
663 conclusion: Nullable[Conclusion] = None,
664 createdAt: Nullable[datetime] = None,
665 startedAt: Nullable[datetime] = None,
666 completedAt: Nullable[datetime] = None,
667 url: Nullable[URL] = None,
668 workflowID: Nullable[int] = None,
669 path: Nullable[str] = None,
670 runNumber: Nullable[int] = None,
671 runAttempt: Nullable[int] = None,
672 event: Nullable[Event] = None,
673 gitReference: Nullable[str] = None,
674 sha: Nullable[str] = None,
675 *,
676 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
677 parent: Nullable[PipelineGroup] = None
678 ) -> None:
679 """
680 Initializes a workflow run.
682 :param name: Name of the workflow.
683 :param identifier: Optional, GitHub's identifier of the run. Default: ``None``.
684 :param status: Optional, state the run is in. Default: ``None``.
685 :param conclusion: Optional, how the run ended. Default: ``None``.
686 :param createdAt: Optional, time the run was created. Default: ``None``.
687 :param startedAt: Optional, time the run started. Default: ``None``.
688 :param completedAt: Optional, time the run was last updated, once completed. Default: ``None``.
689 :param url: Optional, URL of the run on github.com. Default: ``None``.
690 :param workflowID: Optional, GitHub's identifier of the workflow the run belongs to. Default: ``None``.
691 :param path: Optional, path of the workflow's YAML file in the repository. Default: ``None``.
692 :param runNumber: Optional, number of the run within its workflow. Default: ``None``.
693 :param runAttempt: Optional, attempt of the run, starting at 1. Default: ``None``.
694 :param event: Optional, event that triggered the run. Default: ``None``.
695 :param gitReference: Optional, branch or tag the run was started on. Default: ``None``.
696 :param sha: Optional, commit the run was started on. Default: ``None``.
697 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
698 :param parent: Optional, reference to the group of the commit's runs. Default: ``None``.
699 :raises TypeError: If parameter 'identifier' is not of type :class:`int`.
700 :raises TypeError: If parameter 'workflowID' is not of type :class:`int`.
701 :raises TypeError: If parameter 'path' is not of type :class:`str`.
702 :raises TypeError: If parameter 'runNumber' is not of type :class:`int`.
703 :raises TypeError: If parameter 'runAttempt' is not of type :class:`int`.
704 :raises TypeError: If parameter 'event' is not of type :class:`Event`.
705 :raises TypeError: If parameter 'gitReference' is not of type :class:`str`.
706 :raises TypeError: If parameter 'sha' is not of type :class:`str`.
707 """
708 for parameterName, number in (
709 ("identifier", identifier), ("workflowID", workflowID), ("runNumber", runNumber), ("runAttempt", runAttempt)
710 ):
711 if number is not None and not isinstance(number, int):
712 ex = TypeError(f"Parameter '{parameterName}' is not of type 'int'.")
713 ex.add_note(f"Got type '{getFullyQualifiedName(number)}'.")
714 raise ex
716 if event is not None and not isinstance(event, Event):
717 ex = TypeError("Parameter 'event' is not of type 'Event'.")
718 ex.add_note(f"Got type '{getFullyQualifiedName(event)}'.")
719 raise ex
721 for parameterName, text in (("path", path), ("gitReference", gitReference), ("sha", sha)):
722 if text is not None and not isinstance(text, str):
723 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
724 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.")
725 raise ex
727 super().__init__(
728 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs,
729 parent=parent
730 )
731 StatusMixin.__init__(self, status, conclusion, url)
733 self._id = identifier
734 self._workflowID = workflowID
735 self._path = path
736 self._runNumber = runNumber
737 self._runAttempt = runAttempt
738 self._event = event
739 self._gitReference = gitReference
740 self._sha = sha
742 @readonly
743 def ID(self) -> Nullable[int]:
744 """
745 Read-only property to access GitHub's identifier of the run (:attr:`_id`).
747 :returns: The identifier, or ``None`` if GitHub reported none.
748 """
749 return self._id
751 @readonly
752 def WorkflowID(self) -> Nullable[int]:
753 """
754 Read-only property to access GitHub's identifier of the workflow the run belongs to (:attr:`_workflowID`).
756 Every run of the same workflow file reports the same identifier, so it groups a workflow's runs over time,
757 where :attr:`ID` identifies the single run.
759 :returns: The identifier, or ``None`` if GitHub reported none.
760 """
761 return self._workflowID
763 @readonly
764 def Path(self) -> Nullable[str]:
765 """
766 Read-only property to access the path of the workflow's YAML file (:attr:`_path`).
768 GitHub reports it relative to the repository's root, e.g. ``.github/workflows/Pipeline.yml``.
770 :returns: The path, or ``None`` if GitHub reported none.
771 """
772 return self._path
774 @readonly
775 def RunNumber(self) -> Nullable[int]:
776 """
777 Read-only property to access the run's number within its workflow (:attr:`_runNumber`).
779 :returns: The number, or ``None`` if GitHub reported none.
780 """
781 return self._runNumber
783 @readonly
784 def RunAttempt(self) -> Nullable[int]:
785 """
786 Read-only property to access which attempt of the run this is (:attr:`_runAttempt`).
788 :returns: The attempt, starting at 1, or ``None`` if GitHub reported none.
789 """
790 return self._runAttempt
792 @readonly
793 def Event(self) -> Nullable[Event]:
794 """
795 Read-only property to access the event that triggered the run (:attr:`_event`).
797 :returns: The event, or ``None`` if GitHub reported none.
798 """
799 return self._event
801 @readonly
802 def GitReference(self) -> Nullable[str]:
803 """
804 Read-only property to access the branch or tag the run was started on (:attr:`_gitReference`).
806 GitHub reports this as ``head_branch`` and puts the **tag's** name there for a run started at a tag, with no
807 field saying which it is - a run of `pyTooling/MiKTeX` v1.6.0 reports ``main``, and the run at the tag of the
808 very same commit reports ``v1.6.0``. :attr:`Event` is the other half of telling them apart.
810 :returns: The branch's or tag's name, or ``None`` if GitHub reported none.
811 """
812 return self._gitReference
814 @readonly
815 def SHA(self) -> Nullable[str]:
816 """
817 Read-only property to access the commit the run was started on (:attr:`_sha`).
819 :returns: The commit's hash, or ``None`` if GitHub reported none.
820 """
821 return self._sha
823 @classmethod
824 def Parse(
825 cls,
826 run: JSONObject,
827 jobs: Nullable[Iterable[JSONObject]] = None,
828 *,
829 parent: Nullable[PipelineGroup] = None
830 ) -> Self:
831 """
832 Build a workflow run and its tree from the JSON objects the GitHub REST API answers with.
834 A job's name says where it sits, and is read back into the tree: ``Docs / Sphinx / HTML`` nests below a
835 :class:`~pyTooling.CI.Workflow` per prefix, and ``Unit Tests (ubuntu-26.04, 3.14)`` becomes a
836 :class:`MatrixJob` below a :class:`~pyTooling.CI.Matrix` named ``Unit Tests``. A prefix carrying
837 dimension values - ``Tests (3.14) / Unit``, a matrix of calls of a reusable workflow - becomes a
838 :class:`~pyTooling.CI.MatrixWorkflow` below a :class:`~pyTooling.CI.Matrix` named ``Tests``.
840 :param run: The workflow run, as returned by ``GET /repos/{owner}/{repo}/actions/runs/{run_id}``.
841 :param jobs: Optional, the run's jobs, as listed by ``GET .../actions/runs/{run_id}/jobs``.
842 :param parent: Optional, reference to the group of the commit's runs. Default: ``None``.
843 :returns: The workflow run, with its workflows, matrices, jobs and steps attached.
844 :raises TypeError: If parameter 'run' is not of type :class:`dict`.
845 :raises TypeError: If an element of parameter 'jobs' is not of type :class:`dict`.
846 :raises GitHubError: If field ``name`` is missing.
847 :raises GitHubError: If a field holds a value GitHub doesn't document.
848 """
849 if not isinstance(run, dict):
850 ex = TypeError("Parameter 'run' is not of type 'dict'.")
851 ex.add_note(f"Got type '{getFullyQualifiedName(run)}'.")
852 raise ex
854 if (name := run.get("name", None)) is None:
855 raise GitHubError("Field 'run.name' is missing.")
857 identifier = run.get("id", None)
858 status = Status.Parse(run.get("status", None))
859 conclusion = Conclusion.Parse(run.get("conclusion", None))
860 createdAt = _parseISO8601Timestamp(run.get("created_at", None), "run.created_at")
861 startedAt = _parseISO8601Timestamp(run.get("run_started_at", None), "run.run_started_at")
862 completedAt = _parseISO8601Timestamp(run.get("updated_at", None), "run.updated_at") \
863 if status is Status.Completed else None
864 url = _parseURL(run.get("html_url", None), "run.html_url")
865 workflowID = run.get("workflow_id", None)
866 path = run.get("path", None)
867 runNumber = run.get("run_number", None)
868 runAttempt = run.get("run_attempt", None)
869 event = Event.Parse(run.get("event", None))
870 gitReference = run.get("head_branch", None)
871 sha = run.get("head_sha", None)
873 pipeline = cls(
874 name, identifier, status, conclusion, createdAt, startedAt, completedAt, url, workflowID, path, runNumber,
875 runAttempt, event, gitReference, sha, parent=parent
876 )
878 if jobs is None:
879 return pipeline
881 for position, job in enumerate(jobs):
882 if not isinstance(job, dict):
883 ex = TypeError(f"Job {position} is not of type 'dict'.")
884 ex.add_note(f"Got type '{getFullyQualifiedName(job)}'.")
885 raise ex
887 path = f"jobs[{position}]"
888 if (fullName := job.get("name", None)) is None:
889 raise GitHubError(f"Field '{path}.name' is missing.")
891 *callers, leaf = fullName.split(" / ")
893 group: CIWorkflow = pipeline
894 for caller in callers:
895 if (calledWorkflow := group.Workflows.get(caller, None)) is None:
896 callerName, callerDimensions = _splitMatrixJobName(caller)
897 if callerDimensions is None:
898 calledWorkflow = CIWorkflow(caller, parent=group)
899 else:
900 if (matrix := group.Matrices.get(callerName, None)) is None:
901 matrix = CIMatrix(callerName, parent=group)
903 if matrix.ContainsElement(caller):
904 calledWorkflow = matrix.GetElement(caller)
905 else:
906 calledWorkflow = CIMatrixWorkflow(callerName, callerDimensions, parent=matrix)
908 group = calledWorkflow
910 jobName, dimensions = _splitMatrixJobName(leaf)
911 if dimensions is None:
912 Job.Parse(job, path, parent=group)
913 else:
914 if (matrix := group.Matrices.get(jobName, None)) is None:
915 matrix = CIMatrix(jobName, parent=group)
917 MatrixJob.Parse(job, path, jobName, dimensions, parent=matrix)
919 return pipeline
922@export
923class Job(CIJob, StatusMixin):
924 """A job of a workflow run, which ran on a runner and contains steps."""
926 _id: Nullable[int] #: GitHub's identifier of the job.
927 _labels: list[str] #: Labels the job requested its runner by, i.e. its ``runs-on``.
928 _runnerName: Nullable[str] #: Name of the runner the job ran on.
929 _runnerGroupName: Nullable[str] #: Name of the runner group the runner belongs to.
931 def __init__(
932 self,
933 name: str,
934 identifier: Nullable[int] = None,
935 status: Nullable[Status] = None,
936 conclusion: Nullable[Conclusion] = None,
937 createdAt: Nullable[datetime] = None,
938 startedAt: Nullable[datetime] = None,
939 completedAt: Nullable[datetime] = None,
940 url: Nullable[URL] = None,
941 labels: Nullable[Iterable[str]] = None,
942 runnerName: Nullable[str] = None,
943 runnerGroupName: Nullable[str] = None,
944 steps: Nullable[Iterable[Step]] = None,
945 *,
946 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
947 parent: Nullable[CIJobGroup] = None
948 ) -> None:
949 """
950 Initializes a job of a workflow run.
952 :param name: Name of the job, without the calling workflows' prefixes.
953 :param identifier: Optional, GitHub's identifier of the job. Default: ``None``.
954 :param status: Optional, state the job is in. Default: ``None``.
955 :param conclusion: Optional, how the job ended. Default: ``None``.
956 :param createdAt: Optional, time the job was created, i.e. queued for a runner. Default: ``None``.
957 :param startedAt: Optional, time the job started running on a runner. Default: ``None``.
958 :param completedAt: Optional, time the job completed. Default: ``None``.
959 :param url: Optional, URL of the job on github.com. Default: ``None``.
960 :param labels: Optional, labels the job requested its runner by. Default: ``None``.
961 :param runnerName: Optional, name of the runner the job ran on. Default: ``None``.
962 :param runnerGroupName: Optional, name of the runner group the runner belongs to. Default: ``None``.
963 :param steps: Optional, the job's steps, which are attached to it. Default: ``None``.
964 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
965 :param parent: Optional, reference to the group containing the job. Default: ``None``.
966 :raises TypeError: If parameter 'identifier' is not of type :class:`int`.
967 :raises TypeError: If an element of parameter 'labels' is not of type :class:`str`.
968 :raises TypeError: If parameter 'runnerName' is not of type :class:`str`.
969 :raises TypeError: If parameter 'runnerGroupName' is not of type :class:`str`.
970 :raises TypeError: If an element of parameter 'steps' is not of type :class:`Step`.
971 """
972 if identifier is not None and not isinstance(identifier, int):
973 ex = TypeError("Parameter 'identifier' is not of type 'int'.")
974 ex.add_note(f"Got type '{getFullyQualifiedName(identifier)}'.")
975 raise ex
977 for parameterName, value in (("runnerName", runnerName), ("runnerGroupName", runnerGroupName)):
978 if value is not None and not isinstance(value, str):
979 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
980 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
981 raise ex
983 self._labels = labelList = []
984 if labels is not None:
985 for label in labels:
986 if not isinstance(label, str):
987 ex = TypeError("An element of parameter 'labels' is not of type 'str'.")
988 ex.add_note(f"Got type '{getFullyQualifiedName(label)}'.")
989 raise ex
991 labelList.append(label)
993 stepList = []
994 if steps is not None:
995 for step in steps:
996 if not isinstance(step, Step):
997 ex = TypeError("An element of parameter 'steps' is not of type 'Step'.")
998 ex.add_note(f"Got type '{getFullyQualifiedName(step)}'.")
999 raise ex
1001 stepList.append(step)
1003 super().__init__(
1004 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs,
1005 parent=parent
1006 )
1007 StatusMixin.__init__(self, status, conclusion, url)
1009 self._id = identifier
1010 self._runnerName = runnerName
1011 self._runnerGroupName = runnerGroupName
1013 for step in stepList:
1014 step._parent = self
1015 step._pipeline = self._pipeline
1016 self._steps.append(step)
1018 @readonly
1019 def ID(self) -> Nullable[int]:
1020 """
1021 Read-only property to access GitHub's identifier of the job (:attr:`_id`).
1023 :returns: The identifier, or ``None`` if GitHub reported none.
1024 """
1025 return self._id
1027 @readonly
1028 def Labels(self) -> list[str]:
1029 """
1030 Read-only property to access the labels the job requested its runner by (:attr:`_labels`).
1032 These are the values of the workflow's ``runs-on``. For a hosted runner a label doubles as the image's name
1033 (``ubuntu-26.04``); for a self-hosted runner they are the tags the runner was registered with.
1035 :returns: The labels, empty if GitHub reported none.
1036 """
1037 return self._labels
1039 @readonly
1040 def RunnerName(self) -> Nullable[str]:
1041 """
1042 Read-only property to access the name of the runner the job ran on (:attr:`_runnerName`).
1044 :returns: The runner's name, or ``None`` while the job hasn't started.
1045 """
1046 return self._runnerName
1048 @readonly
1049 def RunnerGroupName(self) -> Nullable[str]:
1050 """
1051 Read-only property to access the name of the runner group the runner belongs to (:attr:`_runnerGroupName`).
1053 :returns: The group's name, or ``None`` if GitHub reported none.
1054 """
1055 return self._runnerGroupName
1057 @readonly
1058 def CreatedAt(self) -> Nullable[datetime]:
1059 """
1060 Read-only property to return the time the job was created, at the latest when it started.
1062 :returns: The time, or ``None`` if GitHub reported none.
1063 """
1064 if self._createdAt is None or all(step.StartedAt is None for step in self._steps):
1065 return self._createdAt
1067 return min(self._createdAt, self.StartedAt)
1069 @readonly
1070 def StartedAt(self) -> Nullable[datetime]:
1071 """
1072 Read-only property to return the time the job started, at the latest when its first step started.
1074 GitHub reports a job's times and its steps' times independently and in whole seconds, so a step is sometimes
1075 reported as starting before the job containing it. The step really did run then, so the job is the timespan
1076 that stretches - otherwise a consumer building a tree has a child outside its parent.
1078 :returns: The time, or ``None`` while neither the job nor a step of it has started.
1079 """
1080 times = [step.StartedAt for step in self._steps if step.StartedAt is not None]
1081 if len(times) == 0:
1082 return self._startedAt
1084 first = min(times)
1086 return first if self._startedAt is None else min(self._startedAt, first)
1088 @readonly
1089 def CompletedAt(self) -> Nullable[datetime]:
1090 """
1091 Read-only property to return the time the job completed, at the earliest when its last step completed.
1093 A job that hasn't completed reports no time, even when a step of it has - see :attr:`StartedAt` for why the
1094 job is the timespan that stretches.
1096 :returns: The time, or ``None`` while the job hasn't completed.
1097 """
1098 if self._completedAt is None or all(step.StartedAt is None for step in self._steps):
1099 return self._completedAt
1101 times = [step.CompletedAt for step in self._steps if step.CompletedAt is not None]
1102 if len(times) == 0: 1102 ↛ 1103line 1102 didn't jump to line 1103 because the condition on line 1102 was never true
1103 return self._completedAt
1105 return max(self._completedAt, max(times))
1107 @readonly
1108 def QueuedDuration(self) -> Nullable[float]:
1109 """
1110 Read-only property to return how long the job waited for a runner.
1112 :returns: Seconds from being created to starting, or ``None`` while either time is unknown.
1113 """
1114 if self._createdAt is None or self._startedAt is None:
1115 return None
1117 return (self._startedAt - self._createdAt).total_seconds()
1119 @classmethod
1120 def Parse(cls, json: JSONObject, path: str = "job", *, parent: Nullable[CIJobGroup] = None) -> Self:
1121 """
1122 Build a job and its steps from the JSON object the GitHub REST API answers with.
1124 The job's name is taken without the calling workflows' prefixes: a job reported as ``Caller / Build`` is named
1125 ``Build``, because the prefix describes where it sits, which the tree already says.
1127 :param json: The job, as listed by ``GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs``.
1128 :param path: Optional, position of the job, for an exception's message. Default: ``'job'``.
1129 :param parent: Optional, reference to the group containing the job. Default: ``None``.
1130 :returns: The job, with its steps attached.
1131 :raises TypeError: If parameter 'json' is not of type :class:`dict`.
1132 :raises GitHubError: If field ``name`` is missing.
1133 :raises GitHubError: If a field holds a value GitHub doesn't document.
1134 """
1135 if not isinstance(json, dict):
1136 ex = TypeError("Parameter 'json' is not of type 'dict'.")
1137 ex.add_note(f"Got type '{getFullyQualifiedName(json)}'.")
1138 raise ex
1140 if (fullName := json.get("name", None)) is None: 1140 ↛ 1141line 1140 didn't jump to line 1141 because the condition on line 1140 was never true
1141 raise GitHubError(f"Field '{path}.name' is missing.")
1143 name = fullName.rsplit(" / ", 1)[-1]
1144 identifier = json.get("id", None)
1145 status = Status.Parse(json.get("status", None))
1146 conclusion = Conclusion.Parse(json.get("conclusion", None))
1147 createdAt = _parseISO8601Timestamp(json.get("created_at", None), f"{path}.created_at")
1148 startedAt = _parseISO8601Timestamp(json.get("started_at", None), f"{path}.started_at")
1149 completedAt = _parseISO8601Timestamp(json.get("completed_at", None), f"{path}.completed_at")
1150 url = _parseURL(json.get("html_url", None), f"{path}.html_url")
1151 labels = json.get("labels", None)
1152 runnerName = json.get("runner_name", None)
1153 runnerGroupName = json.get("runner_group_name", None)
1155 if (jsonSteps := json.get("steps", None)) is None: 1155 ↛ 1156line 1155 didn't jump to line 1156 because the condition on line 1155 was never true
1156 steps = None
1157 else:
1158 steps = [Step.Parse(step, f"{path}.steps[{pos}]") for pos, step in enumerate(jsonSteps)]
1160 return cls(
1161 name, identifier, status, conclusion, createdAt, startedAt, completedAt, url, labels, runnerName,
1162 runnerGroupName, steps, parent=parent
1163 )
1166@export
1167class MatrixJob(Job, MatrixInstanceMixin):
1168 """
1169 One instance of a job produced by a matrix.
1171 GitHub reports a matrix instance as an ordinary job whose name carries the dimensions' values in brackets, e.g.
1172 ``Unit Tests (ubuntu-26.04, 3.14)``. :meth:`Pipeline.Parse` reads those back into :attr:`Dimensions` and groups
1173 the instances below a :class:`~pyTooling.CI.Matrix`. The values are in the order GitHub prints them. The
1174 job's payload names no dimension - only the workflow file does -, so a dimension's name is the position of its
1175 value, ``{"0": "ubuntu-26.04", "1": "3.14"}``, until the names are known.
1176 """
1178 _PARENT_TYPE: ClassVar[Nullable[type]] = CIMatrix #: A matrix instance is contained in a matrix.
1180 def __init__(
1181 self,
1182 name: str,
1183 dimensions: Nullable[Mapping[str, Any]] = None,
1184 identifier: Nullable[int] = None,
1185 status: Nullable[Status] = None,
1186 conclusion: Nullable[Conclusion] = None,
1187 createdAt: Nullable[datetime] = None,
1188 startedAt: Nullable[datetime] = None,
1189 completedAt: Nullable[datetime] = None,
1190 url: Nullable[URL] = None,
1191 labels: Nullable[Iterable[str]] = None,
1192 runnerName: Nullable[str] = None,
1193 runnerGroupName: Nullable[str] = None,
1194 steps: Nullable[Iterable[Step]] = None,
1195 *,
1196 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
1197 parent: Nullable[CIMatrix] = None
1198 ) -> None:
1199 """
1200 Initializes one instance of a job produced by a matrix.
1202 :param name: Name of the job, without the dimensions' values.
1203 :param dimensions: Optional, the dimensions' names and values this instance ran with. Default: ``None``.
1204 :param identifier: Optional, GitHub's identifier of the job. Default: ``None``.
1205 :param status: Optional, state the job is in. Default: ``None``.
1206 :param conclusion: Optional, how the job ended. Default: ``None``.
1207 :param createdAt: Optional, time the job was created, i.e. queued for a runner. Default: ``None``.
1208 :param startedAt: Optional, time the job started running on a runner. Default: ``None``.
1209 :param completedAt: Optional, time the job completed. Default: ``None``.
1210 :param url: Optional, URL of the job on github.com. Default: ``None``.
1211 :param labels: Optional, labels the job requested its runner by. Default: ``None``.
1212 :param runnerName: Optional, name of the runner the job ran on. Default: ``None``.
1213 :param runnerGroupName: Optional, name of the runner group the runner belongs to. Default: ``None``.
1214 :param steps: Optional, the job's steps, which are attached to it. Default: ``None``.
1215 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
1216 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``.
1217 """
1218 super().__init__(
1219 name, identifier, status, conclusion, createdAt, startedAt, completedAt, url, labels, runnerName,
1220 runnerGroupName, steps, keyValuePairs=keyValuePairs, parent=parent
1221 )
1222 MatrixInstanceMixin.__init__(self, dimensions)
1224 def __str__(self) -> str:
1225 """
1226 Return a string representation of the matrix instance.
1228 :returns: The job's name with its dimensions' values, as GitHub prints it.
1229 """
1230 if len(self._dimensions) == 0: 1230 ↛ 1231line 1230 didn't jump to line 1231 because the condition on line 1230 was never true
1231 return self._name
1233 return f"{self._name} ({', '.join(str(value) for value in self._dimensions.values())})"
1235 @classmethod
1236 def Parse(
1237 cls,
1238 json: JSONObject,
1239 path: str = "job",
1240 name: Nullable[str] = None,
1241 dimensions: Nullable[Mapping[str, Any]] = None,
1242 *,
1243 parent: Nullable[CIMatrix] = None
1244 ) -> Self:
1245 """
1246 Build a matrix instance and its steps from the JSON object the GitHub REST API answers with.
1248 :param json: The job, as listed by ``GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs``.
1249 :param path: Optional, position of the job, for an exception's message. Default: ``'job'``.
1250 :param name: Optional, the job's name without the dimensions' values. Default: read from the payload.
1251 :param dimensions: Optional, the dimensions' names and values. Default: read from the payload.
1252 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``.
1253 :returns: The matrix instance, with its steps attached.
1254 :raises TypeError: If parameter 'json' is not of type :class:`dict`.
1255 :raises GitHubError: If field ``name`` is missing.
1256 :raises GitHubError: If a field holds a value GitHub doesn't document.
1257 """
1258 if not isinstance(json, dict):
1259 ex = TypeError("Parameter 'json' is not of type 'dict'.")
1260 ex.add_note(f"Got type '{getFullyQualifiedName(json)}'.")
1261 raise ex
1263 if (fullName := json.get("name", None)) is None: 1263 ↛ 1264line 1263 didn't jump to line 1264 because the condition on line 1263 was never true
1264 raise GitHubError(f"Field '{path}.name' is missing.")
1266 if name is None: 1266 ↛ 1267line 1266 didn't jump to line 1267 because the condition on line 1266 was never true
1267 name, dimensions = _splitMatrixJobName(fullName.rsplit(" / ", 1)[-1])
1269 identifier = json.get("id", None)
1270 status = Status.Parse(json.get("status", None))
1271 conclusion = Conclusion.Parse(json.get("conclusion", None))
1272 createdAt = _parseISO8601Timestamp(json.get("created_at", None), f"{path}.created_at")
1273 startedAt = _parseISO8601Timestamp(json.get("started_at", None), f"{path}.started_at")
1274 completedAt = _parseISO8601Timestamp(json.get("completed_at", None), f"{path}.completed_at")
1275 url = _parseURL(json.get("html_url", None), f"{path}.html_url")
1276 labels = json.get("labels", None)
1277 runnerName = json.get("runner_name", None)
1278 runnerGroupName = json.get("runner_group_name", None)
1280 if (jsonSteps := json.get("steps", None)) is None: 1280 ↛ 1281line 1280 didn't jump to line 1281 because the condition on line 1280 was never true
1281 steps = None
1282 else:
1283 steps = [Step.Parse(step, f"{path}.steps[{pos}]") for pos, step in enumerate(jsonSteps)]
1285 return cls(
1286 name, dimensions, identifier, status, conclusion, createdAt, startedAt, completedAt, url, labels,
1287 runnerName, runnerGroupName, steps, parent=parent
1288 )
1291@export
1292class Step(CIStep, StatusMixin):
1293 """A step within a job."""
1295 _PARENT_TYPE: ClassVar[Nullable[type]] = Job #: A step is contained in a job of a workflow run.
1297 _number: Nullable[int] #: Position of the step within its job, starting at 1.
1299 def __init__(
1300 self,
1301 name: str,
1302 number: Nullable[int] = None,
1303 status: Nullable[Status] = None,
1304 conclusion: Nullable[Conclusion] = None,
1305 startedAt: Nullable[datetime] = None,
1306 completedAt: Nullable[datetime] = None,
1307 *,
1308 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
1309 parent: Nullable[Job] = None
1310 ) -> None:
1311 """
1312 Initializes a step within a job.
1314 :param name: Name of the step.
1315 :param number: Optional, position of the step within its job, starting at 1. Default: ``None``.
1316 :param status: Optional, state the step is in. Default: ``None``.
1317 :param conclusion: Optional, how the step ended. Default: ``None``.
1318 :param startedAt: Optional, time the step started running. Default: ``None``.
1319 :param completedAt: Optional, time the step completed. Default: ``None``.
1320 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
1321 :param parent: Optional, reference to the job containing the step. Default: ``None``.
1322 :raises TypeError: If parameter 'number' is not of type :class:`int`.
1323 :raises ValueError: If parameter 'number' is not positive.
1324 """
1325 if number is not None and not isinstance(number, int):
1326 ex = TypeError("Parameter 'number' is not of type 'int'.")
1327 ex.add_note(f"Got type '{getFullyQualifiedName(number)}'.")
1328 raise ex
1329 elif number is not None and number < 1:
1330 ex = ValueError("Parameter 'number' is not positive.")
1331 ex.add_note(f"Got value '{number}'.")
1332 raise ex
1334 super().__init__(
1335 name, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs, parent=parent
1336 )
1337 StatusMixin.__init__(self, status, conclusion)
1339 self._number = number
1341 @readonly
1342 def Number(self) -> Nullable[int]:
1343 """
1344 Read-only property to access the step's position within its job (:attr:`_number`).
1346 :returns: The position, starting at 1, or ``None`` if GitHub reported none.
1347 """
1348 return self._number
1350 @classmethod
1351 def Parse(cls, json: JSONObject, path: str = "step", *, parent: Nullable[Job] = None) -> Self:
1352 """
1353 Build a step from the JSON object the GitHub REST API answers with.
1355 :param json: The step, as it appears in a job's ``steps`` array.
1356 :param path: Optional, position of the step, for an exception's message. Default: ``'step'``.
1357 :param parent: Optional, reference to the job containing the step. Default: ``None``.
1358 :returns: The step.
1359 :raises TypeError: If parameter 'json' is not of type :class:`dict`.
1360 :raises GitHubError: If field ``name`` is missing.
1361 :raises GitHubError: If a field holds a value GitHub doesn't document.
1362 """
1363 if not isinstance(json, dict):
1364 ex = TypeError("Parameter 'json' is not of type 'dict'.")
1365 ex.add_note(f"Got type '{getFullyQualifiedName(json)}'.")
1366 raise ex
1368 if (name := json.get("name", None)) is None: 1368 ↛ 1369line 1368 didn't jump to line 1369 because the condition on line 1368 was never true
1369 raise GitHubError(f"Field '{path}.name' is missing.")
1371 number = json.get("number", None)
1372 status = Status.Parse(json.get("status", None))
1373 conclusion = Conclusion.Parse(json.get("conclusion", None))
1374 startedAt = _parseISO8601Timestamp(json.get("started_at", None), f"{path}.started_at")
1375 completedAt = _parseISO8601Timestamp(json.get("completed_at", None), f"{path}.completed_at")
1377 return cls(name, number, status, conclusion, startedAt, completedAt, parent=parent)