Coverage for pyTooling/GitHub/__init__.py: 97%
525 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 00:39 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 00:39 +0000
1# ==================================================================================================================== #
2# _____ _ _ ____ _ _ _ _ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___(_) |_| | | |_ _| |__ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` || | _| | __| |_| | | | | '_ \ #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| || |_| | | |_| _ | |_| | |_) | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____|_|\__|_| |_|\__,_|_.__/ #
7# |_| |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32A data model of a GitHub Actions workflow run.
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.0rc3"
66__keywords__ = ["GitHub", "GitHub Actions", "Workflow", "Pipeline", "CI", "Trace", "OpenTelemetry", "OTLP", "Sphinx"]
67__project_url__ = "https://github.com/pyTooling/pyTooling.GitHub"
68__documentation_url__ = "https://pyTooling.github.io/pyTooling.GitHub"
69__issue_tracker_url__ = "https://GitHub.com/pyTooling/pyTooling.GitHub/issues"
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.GenericPath.URL 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' or 'repository' is ``None``.
140 :raises TypeError: If parameter 'owner' or 'repository' is not of type :class:`str`.
141 :raises ValueError: If parameter 'owner' or 'repository' is empty or contains a ``/``.
142 :raises TypeError: If a parameter of :class:`~pyTooling.REST.RESTClient` has the wrong type.
143 :raises ValueError: If a parameter of :class:`~pyTooling.REST.RESTClient` has an invalid value.
144 """
145 super().__init__(apiURL, token, headers=_GITHUB_HEADERS, timeout=timeout, retries=retries, retryDelay=retryDelay)
147 for parameterName, text in (("owner", owner), ("repository", repository)):
148 if text is None:
149 raise ValueError(f"Parameter '{parameterName}' is None.")
150 elif not isinstance(text, str):
151 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
152 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.")
153 raise ex
154 elif text == "" or "/" in text:
155 ex = ValueError(f"Parameter '{parameterName}' is empty or contains a '/'.")
156 ex.add_note(f"Got value '{text}'.")
157 raise ex
159 self._owner = owner
160 self._repository = repository
162 @readonly
163 def Owner(self) -> str:
164 """
165 Read-only property to access the repository's owner (:attr:`_owner`).
167 :returns: The name of the user or organization owning the repository.
168 """
169 return self._owner
171 @readonly
172 def Repository(self) -> str:
173 """
174 Read-only property to access the repository's name (:attr:`_repository`).
176 :returns: The name of the repository, without its owner.
177 """
178 return self._repository
181@export
182class Status(StringEnum):
183 """The state a workflow run, job or step is in."""
185 Queued = "queued" #: Waiting to be picked up.
186 InProgress = "in_progress" #: Running.
187 Completed = "completed" #: Finished, with a :class:`Conclusion`.
188 Waiting = "waiting" #: Held, e.g. for an environment's approval.
189 Requested = "requested" #: Requested, but not yet queued.
190 Pending = "pending" #: Blocked by a concurrency group.
192 @classmethod
193 def Parse(cls, value: Nullable[str]) -> Nullable[Status]:
194 """
195 Convert GitHub's ``status`` field to a member of this enumeration.
197 :param value: Optional, the field's value. Default: ``None``.
198 :returns: The matching member, or ``None`` if the field was absent or empty.
199 :raises TypeError: If parameter 'value' is not of type :class:`str`.
200 :raises GitHubError: If the value is not a status GitHub documents. |br|
201 The note lists the documented values.
202 """
203 try:
204 return super().Parse(value)
205 except ValueError as ex:
206 error = GitHubError(f"'{value}' is not a GitHub status.")
207 error.add_note(f"Known: {', '.join(member.value for member in cls)}.")
208 raise error from ex
211@export
212class Conclusion(StringEnum):
213 """How a completed workflow run, job or step ended."""
215 Success = "success" #: Succeeded.
216 Failure = "failure" #: Failed.
217 Cancelled = "cancelled" #: Cancelled before it finished.
218 Skipped = "skipped" #: Not run, because a condition excluded it.
219 TimedOut = "timed_out" #: Stopped by a timeout.
220 ActionRequired = "action_required" #: Waiting for a manual action.
221 Neutral = "neutral" #: Finished without a verdict.
222 Stale = "stale" #: Never ran, because the run was superseded.
223 StartupFailure = "startup_failure" #: The workflow file itself couldn't be started.
225 @classmethod
226 def Parse(cls, value: Nullable[str]) -> Nullable[Conclusion]:
227 """
228 Convert GitHub's ``conclusion`` field to a member of this enumeration.
230 :param value: Optional, the field's value. Default: ``None``.
231 :returns: The matching member, or ``None`` while it hasn't concluded.
232 :raises TypeError: If parameter 'value' is not of type :class:`str`.
233 :raises GitHubError: If the value is not a conclusion GitHub documents. |br|
234 The note lists the documented values.
235 """
236 try:
237 return super().Parse(value)
238 except ValueError as ex:
239 error = GitHubError(f"'{value}' is not a GitHub conclusion.")
240 error.add_note(f"Known: {', '.join(member.value for member in cls)}.")
241 raise error from ex
243 def ToOutcome(self) -> Outcome:
244 """
245 Return the service-independent outcome this conclusion corresponds to.
247 The conclusions with a counterpart of their own - e.g. :attr:`TimedOut`, :attr:`Skipped`, :attr:`Cancelled` - are
248 listed in :data:`CONCLUSION_TO_OUTCOME`; any other, e.g. :attr:`StartupFailure` or :attr:`Neutral`, is an
249 :attr:`~pyTooling.CI.Outcome.Error`.
251 :returns: The outcome.
252 """
253 return CONCLUSION_TO_OUTCOME.get(self, Outcome.Error)
256CONCLUSION_TO_OUTCOME = {
257 Conclusion.Success: Outcome.Success,
258 Conclusion.Failure: Outcome.Failure,
259 Conclusion.TimedOut: Outcome.Timeout,
260 Conclusion.Skipped: Outcome.Skip,
261 Conclusion.Cancelled: Outcome.Cancellation,
262}
263"""GitHub's conclusions with a service-independent outcome of their own."""
266@export
267class Event(StringEnum):
268 """The event that triggered a workflow run."""
270 CheckRun = "check_run" #: A check run was created or completed.
271 CheckSuite = "check_suite" #: A check suite was created or completed.
272 Create = "create" #: A branch or tag was created.
273 Delete = "delete" #: A branch or tag was deleted.
274 Deployment = "deployment" #: A deployment was created.
275 DeploymentStatus = "deployment_status" #: A deployment's status changed.
276 Discussion = "discussion" #: A discussion was touched.
277 DiscussionComment = "discussion_comment" #: A discussion was commented on.
278 Fork = "fork" #: The repository was forked.
279 Gollum = "gollum" #: A wiki page was created or updated.
280 IssueComment = "issue_comment" #: An issue or pull-request was commented on.
281 Issues = "issues" #: An issue was touched.
282 Label = "label" #: A label was touched.
283 MergeGroup = "merge_group" #: A merge group entered the merge queue.
284 Milestone = "milestone" #: A milestone was touched.
285 PageBuild = "page_build" #: GitHub Pages was built.
286 Public = "public" #: The repository was made public.
287 PullRequest = "pull_request" #: A pull-request was touched.
288 PullRequestComment = "pull_request_comment" #: A pull-request was commented on.
289 PullRequestReview = "pull_request_review" #: A pull-request was reviewed.
290 PullRequestReviewComment = "pull_request_review_comment" #: A review was commented on.
291 PullRequestTarget = "pull_request_target" #: A pull-request, run against its base.
292 Push = "push" #: A commit or tag was pushed.
293 RegistryPackage = "registry_package" #: A package was published or updated.
294 Release = "release" #: A release was touched.
295 RepositoryDispatch = "repository_dispatch" #: An external event was dispatched.
296 Schedule = "schedule" #: A cron schedule fired.
297 Status = "status" #: A commit's status changed.
298 Watch = "watch" #: The repository was starred.
299 WorkflowCall = "workflow_call" #: The workflow was called by another one.
300 WorkflowDispatch = "workflow_dispatch" #: The workflow was started by hand or by a token.
301 WorkflowRun = "workflow_run" #: Another workflow run completed.
302 Dynamic = "dynamic" #: GitHub started the run without a workflow file.
304 @classmethod
305 def Parse(cls, value: Nullable[str]) -> Nullable[Event]:
306 """
307 Convert GitHub's ``event`` field to a member of this enumeration.
309 :param value: Optional, the field's value. Default: ``None``.
310 :returns: The matching member, or ``None`` if the field was absent or empty.
311 :raises TypeError: If parameter 'value' is not of type :class:`str`.
312 :raises GitHubError: If the value is not an event GitHub documents. |br|
313 The note lists the documented values.
314 """
315 try:
316 return super().Parse(value)
317 except ValueError as ex:
318 error = GitHubError(f"'{value}' is not a GitHub event.")
319 error.add_note(f"Known: {', '.join(member.value for member in cls)}.")
320 raise error from ex
323def _parseISO8601Timestamp(value: Nullable[str], field: str) -> Nullable[datetime]:
324 """
325 Parse an ISO 8601 timestamp, as the GitHub REST API reports them.
327 A timestamp without a time zone is read as UTC, so every timestamp of a run can be compared with every other.
329 :param value: Optional, the field's value. Default: ``None``.
330 :param field: Name of the field, for the exception's message.
331 :returns: The timestamp, or ``None`` if the field was absent or empty.
332 :raises GitHubError: If the value isn't an ISO 8601 timestamp. |br|
333 The note reports the value that was read.
334 """
335 try:
336 return parseISO8601Timestamp(value, timezone.utc)
337 except ValueError as ex:
338 error = GitHubError(f"Field '{field}' isn't an ISO 8601 timestamp.")
339 error.add_note(f"Got '{value}'.")
340 raise error from ex
343def _parseURL(value: Nullable[str], field: str) -> Nullable[URL]:
344 """
345 Parse a URL, as the GitHub REST API reports them.
347 :param value: Optional, the field's value. Default: ``None``.
348 :param field: Name of the field, kept for symmetry with :func:`_parseISO8601Timestamp` and for the message this
349 will report once :meth:`~pyTooling.GenericPath.URL.URL.Parse` rejects what isn't a URL.
350 :returns: The URL, or ``None`` if the field was absent or empty.
351 """
352 if value is None or value == "":
353 return None
355 return URL.Parse(value)
358def _splitMatrixJobName(name: str) -> tuple[str, Nullable[dict[str, str]]]:
359 """
360 Split a job's name into the matrix' name and the dimensions, if it carries any.
362 GitHub appends the dimensions' values of a matrix instance to the job's name, as
363 ``Unit Tests (ubuntu-26.04, 3.14)``. That bracketed suffix is a naming convention of GitHub's own interface, not a
364 field of the payload, so a job genuinely named ``Build (fast)`` and produced by no matrix is indistinguishable from
365 one that was. A job whose workflow sets its own ``name:`` carries no values at all, and its matrix stays invisible.
367 The suffix carries no dimension names, so a dimension is named by the position of its value: ``"0"``, ``"1"``, ...
369 :param name: The job's name, without any calling workflows' prefixes.
370 :returns: The name without the suffix and the dimensions, or the name and ``None`` if it carries none.
371 """
372 if not name.endswith(")") or "(" not in name:
373 return name, None
375 base, _, values = name[:-1].rpartition("(")
376 base = base.rstrip()
377 if base == "":
378 return name, None
380 return base, {str(position): value.strip() for position, value in enumerate(values.split(","))}
383@export
384class StatusMixin(metaclass=ExtendedType, mixin=True, expects=("_outcome",)):
385 """
386 Mixin-class for the elements GitHub reports: a workflow run, a job and a step.
388 GitHub reports a :class:`Status` and, once completed, a :class:`Conclusion` for each of them, and a URL on
389 github.com for a run and a job. A called workflow and a matrix aren't reported, so they have none of it. The
390 conclusion is also the element's generic :attr:`~pyTooling.CI.Base.Outcome`.
391 """
393 _status: Nullable[Status] #: State the element is in.
394 _conclusion: Nullable[Conclusion] #: How the element ended.
395 _url: Nullable[URL] #: URL of the element on github.com.
397 def __init__(
398 self,
399 status: Nullable[Status] = None,
400 conclusion: Nullable[Conclusion] = None,
401 url: Nullable[URL] = None
402 ) -> None:
403 """
404 Initializes what GitHub reports about an element.
406 :param status: Optional, state the element is in. Default: ``None``.
407 :param conclusion: Optional, how the element ended. Default: ``None``.
408 :param url: Optional, URL of the element on github.com. Default: ``None``.
409 :raises TypeError: If parameter 'status' is not of type :class:`Status`.
410 :raises TypeError: If parameter 'conclusion' is not of type :class:`Conclusion`.
411 :raises TypeError: If parameter 'url' is not of type :class:`~pyTooling.GenericPath.URL.URL`.
412 """
413 if status is not None and not isinstance(status, Status):
414 ex = TypeError("Parameter 'status' is not of type 'Status'.")
415 ex.add_note(f"Got type '{getFullyQualifiedName(status)}'.")
416 raise ex
418 if conclusion is not None and not isinstance(conclusion, Conclusion):
419 ex = TypeError("Parameter 'conclusion' is not of type 'Conclusion'.")
420 ex.add_note(f"Got type '{getFullyQualifiedName(conclusion)}'.")
421 raise ex
423 if url is not None and not isinstance(url, URL):
424 ex = TypeError("Parameter 'url' is not of type 'URL'.")
425 ex.add_note(f"Got type '{getFullyQualifiedName(url)}'.")
426 raise ex
428 self._status = status
429 self._conclusion = conclusion
430 self._url = url
431 if conclusion is not None:
432 self._outcome = conclusion.ToOutcome()
434 @readonly
435 def Status(self) -> Nullable[Status]:
436 """
437 Read-only property to access the state the element is in (:attr:`_status`).
439 :returns: The state, or ``None`` if GitHub reported none.
440 """
441 return self._status
443 @readonly
444 def Conclusion(self) -> Nullable[Conclusion]:
445 """
446 Read-only property to access how the element ended (:attr:`_conclusion`).
448 The service-independent :attr:`~pyTooling.CI.Base.Outcome` is derived from it by
449 :meth:`Conclusion.ToOutcome`.
451 :returns: The conclusion, or ``None`` while the element hasn't concluded.
452 """
453 return self._conclusion
455 @readonly
456 def URL(self) -> Nullable[URL]:
457 """
458 Read-only property to access the element's URL on github.com (:attr:`_url`).
460 :returns: The URL, or ``None`` if GitHub reported none.
461 """
462 return self._url
465@export
466class PipelineGroup(CIPipelineGroup):
467 """
468 Every workflow run GitHub started for one commit, and the top of the tree.
470 A push starts one run per workflow file whose triggers match, so a commit has as many pipelines as the repository
471 has matching workflows - `pyTooling/Actions` answers a push with six.
473 **A run at a tag is in the group as well, and is not the same thing.** It carries the same commit, so the API
474 cannot separate it, but it was started later and for a different reason: a release pipeline tags its own commit,
475 and the run at that tag publishes the release. For `pyTooling/MiKTeX` v1.6.0 the two runs of commit ``2c36ead``
476 were
478 .. code-block:: text
480 event=push head_branch=main run_started_at=07:53:39 tags the commit
481 event=workflow_dispatch head_branch=v1.6.0 run_started_at=08:09:37 publishes the release
483 :meth:`ByGitReference` separates them, since :attr:`Pipeline.GitReference` holds the tag's name for the second.
485 The group has no times of its own and derives them from its pipelines - so its span covers the tag's run too, and
486 is wider than the time the commit's checks took.
487 """
489 def __init__(
490 self,
491 sha: str,
492 pipelines: Nullable[Iterable[Pipeline]] = None,
493 *,
494 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None
495 ) -> None:
496 """
497 Initializes a group of pipelines started for one commit.
499 :param sha: Commit every pipeline of the group was started on.
500 :param pipelines: Optional, the pipelines, which are attached to the group. Default: ``None``.
501 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
502 :raises ValueError: If parameter 'sha' is ``None``.
503 :raises TypeError: If parameter 'sha' is not of type :class:`str`.
504 :raises ValueError: If parameter 'sha' is empty.
505 :raises TypeError: If an element of parameter 'pipelines' is not of type :class:`Pipeline`.
506 """
507 if sha is None:
508 raise ValueError("Parameter 'sha' is None.")
509 elif not isinstance(sha, str):
510 ex = TypeError("Parameter 'sha' is not of type 'str'.")
511 ex.add_note(f"Got type '{getFullyQualifiedName(sha)}'.")
512 raise ex
513 elif sha == "":
514 raise ValueError("Parameter 'sha' is empty.")
516 super().__init__(sha, pipelines, keyValuePairs=keyValuePairs)
518 @readonly
519 def SHA(self) -> str:
520 """
521 Read-only property to access the commit every pipeline of the group was started on (:attr:`_name`).
523 :returns: The commit's hash.
524 """
525 return self._name
527 @readonly
528 def Conclusion(self) -> Nullable[Conclusion]:
529 """
530 Read-only property to return how the commit's pipelines ended, taken together.
532 The worst conclusion wins, so one failed pipeline makes the commit's verdict a failure, as a branch protection
533 rule would. The order is:
535 #. :attr:`~Conclusion.Failure`
536 #. :attr:`~Conclusion.TimedOut`
537 #. :attr:`~Conclusion.StartupFailure`
538 #. :attr:`~Conclusion.ActionRequired`
539 #. :attr:`~Conclusion.Cancelled`
540 #. :attr:`~Conclusion.Stale`
541 #. :attr:`~Conclusion.Neutral`
542 #. :attr:`~Conclusion.Skipped`
543 #. :attr:`~Conclusion.Success`
545 :returns: The worst conclusion of the group's pipelines, or ``None`` while one hasn't concluded.
546 """
547 if len(self._pipelines) == 0: 547 ↛ 548line 547 didn't jump to line 548 because the condition on line 547 was never true
548 return None
550 conclusions = set()
551 for pipeline in self._pipelines:
552 if pipeline.Conclusion is None:
553 return None
555 conclusions.add(pipeline.Conclusion)
557 for conclusion in ( 557 ↛ 564line 557 didn't jump to line 564 because the loop on line 557 didn't complete
558 Conclusion.Failure, Conclusion.TimedOut, Conclusion.StartupFailure, Conclusion.ActionRequired,
559 Conclusion.Cancelled, Conclusion.Stale, Conclusion.Neutral, Conclusion.Skipped, Conclusion.Success
560 ):
561 if conclusion in conclusions:
562 return conclusion
564 return None
566 def ByGitReference(self) -> dict[Nullable[str], list[Pipeline]]:
567 """
568 Group the commit's pipelines by the branch or tag they were started on.
570 A commit pushed to a branch and later tagged has its pipelines under two keys - the branch's name and the
571 tag's - which is what separates the checks of a commit from the run that published its release.
573 :returns: Dictionary of a reference's name to the pipelines started on it, in the order they were reported.
574 """
575 byReference: dict[Nullable[str], list[Pipeline]] = {}
576 for pipeline in self._pipelines:
577 if (pipelines := byReference.get(pipeline.GitReference, None)) is None: 577 ↛ 581line 577 didn't jump to line 581 because the condition on line 577 was always true
578 pipelines = []
579 byReference[pipeline.GitReference] = pipelines
581 pipelines.append(pipeline)
583 return byReference
585 @classmethod
586 def Parse(
587 cls,
588 runs: Union[JSONObject, Iterable[JSONObject]],
589 jobs: Nullable[dict[int, Iterable[JSONObject]]] = None,
590 sha: Nullable[str] = None
591 ) -> Self:
592 """
593 Build a group of pipelines from the JSON objects the GitHub REST API answers with.
595 :param runs: The runs, as returned by ``GET /repos/{owner}/{repo}/actions/runs?head_sha=...`` - either
596 the answer itself or its ``workflow_runs`` array.
597 :param jobs: Optional, the jobs of each run, by the run's identifier. Default: ``None``.
598 :param sha: Optional, the commit. Default: the ``head_sha`` the runs report.
599 :returns: The group, with its pipelines attached.
600 :raises GitHubError: If the runs report different commits.
601 :raises GitHubError: If no run reports a commit and parameter 'sha' wasn't given.
602 """
603 if not isinstance(runs, dict):
604 workflowRuns = runs
605 elif (workflowRuns := runs.get("workflow_runs", None)) is None: 605 ↛ 606line 605 didn't jump to line 606 because the condition on line 605 was never true
606 workflowRuns = []
608 pipelines = []
609 shas = set()
610 for run in workflowRuns:
611 runJobs = None
612 if jobs is not None:
613 runJobs = jobs.get(run.get("id", None), None)
615 pipeline = Pipeline.Parse(run, runJobs)
616 pipelines.append(pipeline)
617 if pipeline.SHA is not None:
618 shas.add(pipeline.SHA)
620 if sha is None:
621 if (commits := len(shas)) == 0:
622 raise GitHubError("None of the runs reports a 'head_sha', and parameter 'sha' wasn't given.")
623 elif commits > 1:
624 error = GitHubError("The runs report different commits.")
625 error.add_note(f"Got {', '.join(sorted(shas))}.")
626 raise error
628 sha = shas.pop()
630 return cls(sha, pipelines)
633@export
634class Pipeline(CIPipeline, StatusMixin):
635 """
636 A workflow run.
638 A run contains its own jobs, a :class:`~pyTooling.CI.Matrix` for every matrix, and a
639 :class:`~pyTooling.CI.Workflow` for every workflow it called. Unlike a called workflow, a run reports its
640 own status, conclusion and times. Several runs of one commit are held by a :class:`PipelineGroup`, which is the
641 top of the tree.
642 """
644 _PARENT_TYPE: ClassVar[Nullable[type]] = PipelineGroup #: A workflow run is contained in a pipeline group.
646 _id: Nullable[int] #: GitHub's identifier of the run.
647 _workflowID: Nullable[int] #: GitHub's identifier of the workflow the run belongs to.
648 _path: Nullable[str] #: Path of the workflow's YAML file in the repository.
649 _runNumber: Nullable[int] #: Number of the run within its workflow.
650 _runAttempt: Nullable[int] #: Attempt of the run, starting at 1.
651 _event: Nullable[Event] #: Event that triggered the run.
652 _gitReference: Nullable[str] #: Branch or tag the run was started on.
653 _sha: Nullable[str] #: Commit the run was started on.
655 def __init__(
656 self,
657 name: str,
658 identifier: Nullable[int] = None,
659 status: Nullable[Status] = None,
660 conclusion: Nullable[Conclusion] = None,
661 createdAt: Nullable[datetime] = None,
662 startedAt: Nullable[datetime] = None,
663 completedAt: Nullable[datetime] = None,
664 url: Nullable[URL] = None,
665 workflowID: Nullable[int] = None,
666 path: Nullable[str] = None,
667 runNumber: Nullable[int] = None,
668 runAttempt: Nullable[int] = None,
669 event: Nullable[Event] = None,
670 gitReference: Nullable[str] = None,
671 sha: Nullable[str] = None,
672 *,
673 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
674 parent: Nullable[PipelineGroup] = None
675 ) -> None:
676 """
677 Initializes a workflow run.
679 :param name: Name of the workflow.
680 :param identifier: Optional, GitHub's identifier of the run. Default: ``None``.
681 :param status: Optional, state the run is in. Default: ``None``.
682 :param conclusion: Optional, how the run ended. Default: ``None``.
683 :param createdAt: Optional, time the run was created. Default: ``None``.
684 :param startedAt: Optional, time the run started. Default: ``None``.
685 :param completedAt: Optional, time the run was last updated, once completed. Default: ``None``.
686 :param url: Optional, URL of the run on github.com. Default: ``None``.
687 :param workflowID: Optional, GitHub's identifier of the workflow the run belongs to. Default: ``None``.
688 :param path: Optional, path of the workflow's YAML file in the repository. Default: ``None``.
689 :param runNumber: Optional, number of the run within its workflow. Default: ``None``.
690 :param runAttempt: Optional, attempt of the run, starting at 1. Default: ``None``.
691 :param event: Optional, event that triggered the run. Default: ``None``.
692 :param gitReference: Optional, branch or tag the run was started on. Default: ``None``.
693 :param sha: Optional, commit the run was started on. Default: ``None``.
694 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
695 :param parent: Optional, reference to the group of the commit's runs. Default: ``None``.
696 :raises TypeError: If parameter 'identifier' is not of type :class:`int`.
697 :raises TypeError: If parameter 'workflowID' is not of type :class:`int`.
698 :raises TypeError: If parameter 'path' is not of type :class:`str`.
699 :raises TypeError: If parameter 'runNumber' is not of type :class:`int`.
700 :raises TypeError: If parameter 'runAttempt' is not of type :class:`int`.
701 :raises TypeError: If parameter 'event' is not of type :class:`Event`.
702 :raises TypeError: If parameter 'gitReference' is not of type :class:`str`.
703 :raises TypeError: If parameter 'sha' is not of type :class:`str`.
704 """
705 for parameterName, number in (
706 ("identifier", identifier), ("workflowID", workflowID), ("runNumber", runNumber), ("runAttempt", runAttempt)
707 ):
708 if number is not None and not isinstance(number, int):
709 ex = TypeError(f"Parameter '{parameterName}' is not of type 'int'.")
710 ex.add_note(f"Got type '{getFullyQualifiedName(number)}'.")
711 raise ex
713 if event is not None and not isinstance(event, Event):
714 ex = TypeError("Parameter 'event' is not of type 'Event'.")
715 ex.add_note(f"Got type '{getFullyQualifiedName(event)}'.")
716 raise ex
718 for parameterName, text in (("path", path), ("gitReference", gitReference), ("sha", sha)):
719 if text is not None and not isinstance(text, str):
720 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
721 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.")
722 raise ex
724 super().__init__(
725 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs,
726 parent=parent
727 )
728 StatusMixin.__init__(self, status, conclusion, url)
730 self._id = identifier
731 self._workflowID = workflowID
732 self._path = path
733 self._runNumber = runNumber
734 self._runAttempt = runAttempt
735 self._event = event
736 self._gitReference = gitReference
737 self._sha = sha
739 @readonly
740 def ID(self) -> Nullable[int]:
741 """
742 Read-only property to access GitHub's identifier of the run (:attr:`_id`).
744 :returns: The identifier, or ``None`` if GitHub reported none.
745 """
746 return self._id
748 @readonly
749 def WorkflowID(self) -> Nullable[int]:
750 """
751 Read-only property to access GitHub's identifier of the workflow the run belongs to (:attr:`_workflowID`).
753 Every run of the same workflow file reports the same identifier, so it groups a workflow's runs over time,
754 where :attr:`ID` identifies the single run.
756 :returns: The identifier, or ``None`` if GitHub reported none.
757 """
758 return self._workflowID
760 @readonly
761 def Path(self) -> Nullable[str]:
762 """
763 Read-only property to access the path of the workflow's YAML file (:attr:`_path`).
765 GitHub reports it relative to the repository's root, e.g. ``.github/workflows/Pipeline.yml``.
767 :returns: The path, or ``None`` if GitHub reported none.
768 """
769 return self._path
771 @readonly
772 def RunNumber(self) -> Nullable[int]:
773 """
774 Read-only property to access the run's number within its workflow (:attr:`_runNumber`).
776 :returns: The number, or ``None`` if GitHub reported none.
777 """
778 return self._runNumber
780 @readonly
781 def RunAttempt(self) -> Nullable[int]:
782 """
783 Read-only property to access which attempt of the run this is (:attr:`_runAttempt`).
785 :returns: The attempt, starting at 1, or ``None`` if GitHub reported none.
786 """
787 return self._runAttempt
789 @readonly
790 def Event(self) -> Nullable[Event]:
791 """
792 Read-only property to access the event that triggered the run (:attr:`_event`).
794 :returns: The event, or ``None`` if GitHub reported none.
795 """
796 return self._event
798 @readonly
799 def GitReference(self) -> Nullable[str]:
800 """
801 Read-only property to access the branch or tag the run was started on (:attr:`_gitReference`).
803 GitHub reports this as ``head_branch`` and puts the **tag's** name there for a run started at a tag, with no
804 field saying which it is - a run of `pyTooling/MiKTeX` v1.6.0 reports ``main``, and the run at the tag of the
805 very same commit reports ``v1.6.0``. :attr:`Event` is the other half of telling them apart.
807 :returns: The branch's or tag's name, or ``None`` if GitHub reported none.
808 """
809 return self._gitReference
811 @readonly
812 def SHA(self) -> Nullable[str]:
813 """
814 Read-only property to access the commit the run was started on (:attr:`_sha`).
816 :returns: The commit's hash, or ``None`` if GitHub reported none.
817 """
818 return self._sha
820 @classmethod
821 def Parse(
822 cls,
823 run: JSONObject,
824 jobs: Nullable[Iterable[JSONObject]] = None,
825 *,
826 parent: Nullable[PipelineGroup] = None
827 ) -> Self:
828 """
829 Build a workflow run and its tree from the JSON objects the GitHub REST API answers with.
831 A job's name says where it sits, and is read back into the tree: ``Docs / Sphinx / HTML`` nests below a
832 :class:`~pyTooling.CI.Workflow` per prefix, and ``Unit Tests (ubuntu-26.04, 3.14)`` becomes a
833 :class:`MatrixJob` below a :class:`~pyTooling.CI.Matrix` named ``Unit Tests``. A prefix carrying
834 dimension values - ``Tests (3.14) / Unit``, a matrix of calls of a reusable workflow - becomes a
835 :class:`~pyTooling.CI.MatrixWorkflow` below a :class:`~pyTooling.CI.Matrix` named ``Tests``.
837 :param run: The workflow run, as returned by ``GET /repos/{owner}/{repo}/actions/runs/{run_id}``.
838 :param jobs: Optional, the run's jobs, as listed by ``GET .../actions/runs/{run_id}/jobs``.
839 :param parent: Optional, reference to the group of the commit's runs. Default: ``None``.
840 :returns: The workflow run, with its workflows, matrices, jobs and steps attached.
841 :raises TypeError: If parameter 'run' is not of type :class:`dict`.
842 :raises TypeError: If an element of parameter 'jobs' is not of type :class:`dict`.
843 :raises GitHubError: If field ``name`` is missing.
844 :raises GitHubError: If a field holds a value GitHub doesn't document.
845 """
846 if not isinstance(run, dict):
847 ex = TypeError("Parameter 'run' is not of type 'dict'.")
848 ex.add_note(f"Got type '{getFullyQualifiedName(run)}'.")
849 raise ex
851 if (name := run.get("name", None)) is None:
852 raise GitHubError("Field 'run.name' is missing.")
854 identifier = run.get("id", None)
855 status = Status.Parse(run.get("status", None))
856 conclusion = Conclusion.Parse(run.get("conclusion", None))
857 createdAt = _parseISO8601Timestamp(run.get("created_at", None), "run.created_at")
858 startedAt = _parseISO8601Timestamp(run.get("run_started_at", None), "run.run_started_at")
859 completedAt = _parseISO8601Timestamp(run.get("updated_at", None), "run.updated_at") \
860 if status is Status.Completed else None
861 url = _parseURL(run.get("html_url", None), "run.html_url")
862 workflowID = run.get("workflow_id", None)
863 path = run.get("path", None)
864 runNumber = run.get("run_number", None)
865 runAttempt = run.get("run_attempt", None)
866 event = Event.Parse(run.get("event", None))
867 gitReference = run.get("head_branch", None)
868 sha = run.get("head_sha", None)
870 pipeline = cls(
871 name, identifier, status, conclusion, createdAt, startedAt, completedAt, url, workflowID, path, runNumber,
872 runAttempt, event, gitReference, sha, parent=parent
873 )
875 if jobs is None:
876 return pipeline
878 for position, job in enumerate(jobs):
879 if not isinstance(job, dict):
880 ex = TypeError(f"Job {position} is not of type 'dict'.")
881 ex.add_note(f"Got type '{getFullyQualifiedName(job)}'.")
882 raise ex
884 path = f"jobs[{position}]"
885 if (fullName := job.get("name", None)) is None:
886 raise GitHubError(f"Field '{path}.name' is missing.")
888 *callers, leaf = fullName.split(" / ")
890 group: CIWorkflow = pipeline
891 for caller in callers:
892 if (calledWorkflow := group.Workflows.get(caller, None)) is None:
893 callerName, callerDimensions = _splitMatrixJobName(caller)
894 if callerDimensions is None:
895 calledWorkflow = CIWorkflow(caller, parent=group)
896 else:
897 if (matrix := group.Matrices.get(callerName, None)) is None:
898 matrix = CIMatrix(callerName, parent=group)
900 if matrix.ContainsElement(caller):
901 calledWorkflow = matrix.GetElement(caller)
902 else:
903 calledWorkflow = CIMatrixWorkflow(callerName, callerDimensions, parent=matrix)
905 group = calledWorkflow
907 jobName, dimensions = _splitMatrixJobName(leaf)
908 if dimensions is None:
909 Job.Parse(job, path, parent=group)
910 else:
911 if (matrix := group.Matrices.get(jobName, None)) is None:
912 matrix = CIMatrix(jobName, parent=group)
914 MatrixJob.Parse(job, path, jobName, dimensions, parent=matrix)
916 return pipeline
919@export
920class Job(CIJob, StatusMixin):
921 """A job of a workflow run, which ran on a runner and contains steps."""
923 _id: Nullable[int] #: GitHub's identifier of the job.
924 _labels: list[str] #: Labels the job requested its runner by, i.e. its ``runs-on``.
925 _runnerName: Nullable[str] #: Name of the runner the job ran on.
926 _runnerGroupName: Nullable[str] #: Name of the runner group the runner belongs to.
928 def __init__(
929 self,
930 name: str,
931 identifier: Nullable[int] = None,
932 status: Nullable[Status] = None,
933 conclusion: Nullable[Conclusion] = None,
934 createdAt: Nullable[datetime] = None,
935 startedAt: Nullable[datetime] = None,
936 completedAt: Nullable[datetime] = None,
937 url: Nullable[URL] = None,
938 labels: Nullable[Iterable[str]] = None,
939 runnerName: Nullable[str] = None,
940 runnerGroupName: Nullable[str] = None,
941 steps: Nullable[Iterable[Step]] = None,
942 *,
943 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
944 parent: Nullable[CIJobGroup] = None
945 ) -> None:
946 """
947 Initializes a job of a workflow run.
949 :param name: Name of the job, without the calling workflows' prefixes.
950 :param identifier: Optional, GitHub's identifier of the job. Default: ``None``.
951 :param status: Optional, state the job is in. Default: ``None``.
952 :param conclusion: Optional, how the job ended. Default: ``None``.
953 :param createdAt: Optional, time the job was created, i.e. queued for a runner. Default: ``None``.
954 :param startedAt: Optional, time the job started running on a runner. Default: ``None``.
955 :param completedAt: Optional, time the job completed. Default: ``None``.
956 :param url: Optional, URL of the job on github.com. Default: ``None``.
957 :param labels: Optional, labels the job requested its runner by. Default: ``None``.
958 :param runnerName: Optional, name of the runner the job ran on. Default: ``None``.
959 :param runnerGroupName: Optional, name of the runner group the runner belongs to. Default: ``None``.
960 :param steps: Optional, the job's steps, which are attached to it. Default: ``None``.
961 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
962 :param parent: Optional, reference to the group containing the job. Default: ``None``.
963 :raises TypeError: If parameter 'identifier' is not of type :class:`int`.
964 :raises TypeError: If an element of parameter 'steps' is not of type :class:`Step`.
965 :raises TypeError: If an element of parameter 'labels' is not of type :class:`str`.
966 :raises TypeError: If parameter 'runnerName' is not of type :class:`str`.
967 :raises TypeError: If parameter 'runnerGroupName' is not of type :class:`str`.
968 """
969 if identifier is not None and not isinstance(identifier, int):
970 ex = TypeError("Parameter 'identifier' is not of type 'int'.")
971 ex.add_note(f"Got type '{getFullyQualifiedName(identifier)}'.")
972 raise ex
974 for parameterName, value in (("runnerName", runnerName), ("runnerGroupName", runnerGroupName)):
975 if value is not None and not isinstance(value, str):
976 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
977 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
978 raise ex
980 self._labels = labelList = []
981 if labels is not None:
982 for label in labels:
983 if not isinstance(label, str):
984 ex = TypeError("An element of parameter 'labels' is not of type 'str'.")
985 ex.add_note(f"Got type '{getFullyQualifiedName(label)}'.")
986 raise ex
988 labelList.append(label)
990 stepList = []
991 if steps is not None:
992 for step in steps:
993 if not isinstance(step, Step):
994 ex = TypeError("An element of parameter 'steps' is not of type 'Step'.")
995 ex.add_note(f"Got type '{getFullyQualifiedName(step)}'.")
996 raise ex
998 stepList.append(step)
1000 super().__init__(
1001 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs,
1002 parent=parent
1003 )
1004 StatusMixin.__init__(self, status, conclusion, url)
1006 self._id = identifier
1007 self._runnerName = runnerName
1008 self._runnerGroupName = runnerGroupName
1010 for step in stepList:
1011 step._parent = self
1012 step._pipeline = self._pipeline
1013 self._steps.append(step)
1015 @readonly
1016 def ID(self) -> Nullable[int]:
1017 """
1018 Read-only property to access GitHub's identifier of the job (:attr:`_id`).
1020 :returns: The identifier, or ``None`` if GitHub reported none.
1021 """
1022 return self._id
1024 @readonly
1025 def Labels(self) -> list[str]:
1026 """
1027 Read-only property to access the labels the job requested its runner by (:attr:`_labels`).
1029 These are the values of the workflow's ``runs-on``. For a hosted runner a label doubles as the image's name
1030 (``ubuntu-26.04``); for a self-hosted runner they are the tags the runner was registered with.
1032 :returns: The labels, empty if GitHub reported none.
1033 """
1034 return self._labels
1036 @readonly
1037 def RunnerName(self) -> Nullable[str]:
1038 """
1039 Read-only property to access the name of the runner the job ran on (:attr:`_runnerName`).
1041 :returns: The runner's name, or ``None`` while the job hasn't started.
1042 """
1043 return self._runnerName
1045 @readonly
1046 def RunnerGroupName(self) -> Nullable[str]:
1047 """
1048 Read-only property to access the name of the runner group the runner belongs to (:attr:`_runnerGroupName`).
1050 :returns: The group's name, or ``None`` if GitHub reported none.
1051 """
1052 return self._runnerGroupName
1054 @readonly
1055 def CreatedAt(self) -> Nullable[datetime]:
1056 """
1057 Read-only property to return the time the job was created, at the latest when it started.
1059 :returns: The time, or ``None`` if GitHub reported none.
1060 """
1061 if self._createdAt is None or all(step.StartedAt is None for step in self._steps):
1062 return self._createdAt
1064 return min(self._createdAt, self.StartedAt)
1066 @readonly
1067 def StartedAt(self) -> Nullable[datetime]:
1068 """
1069 Read-only property to return the time the job started, at the latest when its first step started.
1071 GitHub reports a job's times and its steps' times independently and in whole seconds, so a step is sometimes
1072 reported as starting before the job containing it. The step really did run then, so the job is the timespan
1073 that stretches - otherwise a consumer building a tree has a child outside its parent.
1075 :returns: The time, or ``None`` while neither the job nor a step of it has started.
1076 """
1077 times = [step.StartedAt for step in self._steps if step.StartedAt is not None]
1078 if len(times) == 0:
1079 return self._startedAt
1081 first = min(times)
1083 return first if self._startedAt is None else min(self._startedAt, first)
1085 @readonly
1086 def CompletedAt(self) -> Nullable[datetime]:
1087 """
1088 Read-only property to return the time the job completed, at the earliest when its last step completed.
1090 A job that hasn't completed reports no time, even when a step of it has - see :attr:`StartedAt` for why the
1091 job is the timespan that stretches.
1093 :returns: The time, or ``None`` while the job hasn't completed.
1094 """
1095 if self._completedAt is None or all(step.StartedAt is None for step in self._steps):
1096 return self._completedAt
1098 times = [step.CompletedAt for step in self._steps if step.CompletedAt is not None]
1099 if len(times) == 0: 1099 ↛ 1100line 1099 didn't jump to line 1100 because the condition on line 1099 was never true
1100 return self._completedAt
1102 return max(self._completedAt, max(times))
1104 @readonly
1105 def QueuedDuration(self) -> Nullable[float]:
1106 """
1107 Read-only property to return how long the job waited for a runner.
1109 :returns: Seconds from being created to starting, or ``None`` while either time is unknown.
1110 """
1111 if self._createdAt is None or self._startedAt is None:
1112 return None
1114 return (self._startedAt - self._createdAt).total_seconds()
1116 @classmethod
1117 def Parse(cls, json: JSONObject, path: str = "job", *, parent: Nullable[CIJobGroup] = None) -> Self:
1118 """
1119 Build a job and its steps from the JSON object the GitHub REST API answers with.
1121 The job's name is taken without the calling workflows' prefixes: a job reported as ``Caller / Build`` is named
1122 ``Build``, because the prefix describes where it sits, which the tree already says.
1124 :param json: The job, as listed by ``GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs``.
1125 :param path: Optional, position of the job, for an exception's message. Default: ``'job'``.
1126 :param parent: Optional, reference to the group containing the job. Default: ``None``.
1127 :returns: The job, with its steps attached.
1128 :raises TypeError: If parameter 'json' is not of type :class:`dict`.
1129 :raises GitHubError: If field ``name`` is missing.
1130 :raises GitHubError: If a field holds a value GitHub doesn't document.
1131 """
1132 if not isinstance(json, dict):
1133 ex = TypeError("Parameter 'json' is not of type 'dict'.")
1134 ex.add_note(f"Got type '{getFullyQualifiedName(json)}'.")
1135 raise ex
1137 if (fullName := json.get("name", None)) is None: 1137 ↛ 1138line 1137 didn't jump to line 1138 because the condition on line 1137 was never true
1138 raise GitHubError(f"Field '{path}.name' is missing.")
1140 name = fullName.rsplit(" / ", 1)[-1]
1141 identifier = json.get("id", None)
1142 status = Status.Parse(json.get("status", None))
1143 conclusion = Conclusion.Parse(json.get("conclusion", None))
1144 createdAt = _parseISO8601Timestamp(json.get("created_at", None), f"{path}.created_at")
1145 startedAt = _parseISO8601Timestamp(json.get("started_at", None), f"{path}.started_at")
1146 completedAt = _parseISO8601Timestamp(json.get("completed_at", None), f"{path}.completed_at")
1147 url = _parseURL(json.get("html_url", None), f"{path}.html_url")
1148 labels = json.get("labels", None)
1149 runnerName = json.get("runner_name", None)
1150 runnerGroupName = json.get("runner_group_name", None)
1152 if (jsonSteps := json.get("steps", None)) is None: 1152 ↛ 1153line 1152 didn't jump to line 1153 because the condition on line 1152 was never true
1153 steps = None
1154 else:
1155 steps = [Step.Parse(step, f"{path}.steps[{pos}]") for pos, step in enumerate(jsonSteps)]
1157 return cls(
1158 name, identifier, status, conclusion, createdAt, startedAt, completedAt, url, labels, runnerName,
1159 runnerGroupName, steps, parent=parent
1160 )
1163@export
1164class MatrixJob(Job, MatrixInstanceMixin):
1165 """
1166 One instance of a job produced by a matrix.
1168 GitHub reports a matrix instance as an ordinary job whose name carries the dimensions' values in brackets, e.g.
1169 ``Unit Tests (ubuntu-26.04, 3.14)``. :meth:`Pipeline.Parse` reads those back into :attr:`Dimensions` and groups
1170 the instances below a :class:`~pyTooling.CI.Matrix`. The values are in the order GitHub prints them. The
1171 job's payload names no dimension - only the workflow file does -, so a dimension's name is the position of its
1172 value, ``{"0": "ubuntu-26.04", "1": "3.14"}``, until the names are known.
1173 """
1175 _PARENT_TYPE: ClassVar[Nullable[type]] = CIMatrix #: A matrix instance is contained in a matrix.
1177 def __init__(
1178 self,
1179 name: str,
1180 dimensions: Nullable[Mapping[str, Any]] = None,
1181 identifier: Nullable[int] = None,
1182 status: Nullable[Status] = None,
1183 conclusion: Nullable[Conclusion] = None,
1184 createdAt: Nullable[datetime] = None,
1185 startedAt: Nullable[datetime] = None,
1186 completedAt: Nullable[datetime] = None,
1187 url: Nullable[URL] = None,
1188 labels: Nullable[Iterable[str]] = None,
1189 runnerName: Nullable[str] = None,
1190 runnerGroupName: Nullable[str] = None,
1191 steps: Nullable[Iterable[Step]] = None,
1192 *,
1193 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
1194 parent: Nullable[CIMatrix] = None
1195 ) -> None:
1196 """
1197 Initializes one instance of a job produced by a matrix.
1199 :param name: Name of the job, without the dimensions' values.
1200 :param dimensions: Optional, the dimensions' names and values this instance ran with. Default: ``None``.
1201 :param identifier: Optional, GitHub's identifier of the job. Default: ``None``.
1202 :param status: Optional, state the job is in. Default: ``None``.
1203 :param conclusion: Optional, how the job ended. Default: ``None``.
1204 :param createdAt: Optional, time the job was created, i.e. queued for a runner. Default: ``None``.
1205 :param startedAt: Optional, time the job started running on a runner. Default: ``None``.
1206 :param completedAt: Optional, time the job completed. Default: ``None``.
1207 :param url: Optional, URL of the job on github.com. Default: ``None``.
1208 :param labels: Optional, labels the job requested its runner by. Default: ``None``.
1209 :param runnerName: Optional, name of the runner the job ran on. Default: ``None``.
1210 :param runnerGroupName: Optional, name of the runner group the runner belongs to. Default: ``None``.
1211 :param steps: Optional, the job's steps, which are attached to it. Default: ``None``.
1212 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
1213 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``.
1214 """
1215 super().__init__(
1216 name, identifier, status, conclusion, createdAt, startedAt, completedAt, url, labels, runnerName,
1217 runnerGroupName, steps, keyValuePairs=keyValuePairs, parent=parent
1218 )
1219 MatrixInstanceMixin.__init__(self, dimensions)
1221 def __str__(self) -> str:
1222 """
1223 Return a string representation of the matrix instance.
1225 :returns: The job's name with its dimensions' values, as GitHub prints it.
1226 """
1227 if len(self._dimensions) == 0: 1227 ↛ 1228line 1227 didn't jump to line 1228 because the condition on line 1227 was never true
1228 return self._name
1230 return f"{self._name} ({', '.join(str(value) for value in self._dimensions.values())})"
1232 @classmethod
1233 def Parse(
1234 cls,
1235 json: JSONObject,
1236 path: str = "job",
1237 name: Nullable[str] = None,
1238 dimensions: Nullable[Mapping[str, Any]] = None,
1239 *,
1240 parent: Nullable[CIMatrix] = None
1241 ) -> Self:
1242 """
1243 Build a matrix instance and its steps from the JSON object the GitHub REST API answers with.
1245 :param json: The job, as listed by ``GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs``.
1246 :param path: Optional, position of the job, for an exception's message. Default: ``'job'``.
1247 :param name: Optional, the job's name without the dimensions' values. Default: read from the payload.
1248 :param dimensions: Optional, the dimensions' names and values. Default: read from the payload.
1249 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``.
1250 :returns: The matrix instance, with its steps attached.
1251 :raises TypeError: If parameter 'json' is not of type :class:`dict`.
1252 :raises GitHubError: If field ``name`` is missing.
1253 :raises GitHubError: If a field holds a value GitHub doesn't document.
1254 """
1255 if not isinstance(json, dict):
1256 ex = TypeError("Parameter 'json' is not of type 'dict'.")
1257 ex.add_note(f"Got type '{getFullyQualifiedName(json)}'.")
1258 raise ex
1260 if (fullName := json.get("name", None)) is None: 1260 ↛ 1261line 1260 didn't jump to line 1261 because the condition on line 1260 was never true
1261 raise GitHubError(f"Field '{path}.name' is missing.")
1263 if name is None: 1263 ↛ 1264line 1263 didn't jump to line 1264 because the condition on line 1263 was never true
1264 name, dimensions = _splitMatrixJobName(fullName.rsplit(" / ", 1)[-1])
1266 identifier = json.get("id", None)
1267 status = Status.Parse(json.get("status", None))
1268 conclusion = Conclusion.Parse(json.get("conclusion", None))
1269 createdAt = _parseISO8601Timestamp(json.get("created_at", None), f"{path}.created_at")
1270 startedAt = _parseISO8601Timestamp(json.get("started_at", None), f"{path}.started_at")
1271 completedAt = _parseISO8601Timestamp(json.get("completed_at", None), f"{path}.completed_at")
1272 url = _parseURL(json.get("html_url", None), f"{path}.html_url")
1273 labels = json.get("labels", None)
1274 runnerName = json.get("runner_name", None)
1275 runnerGroupName = json.get("runner_group_name", None)
1277 if (jsonSteps := json.get("steps", None)) is None: 1277 ↛ 1278line 1277 didn't jump to line 1278 because the condition on line 1277 was never true
1278 steps = None
1279 else:
1280 steps = [Step.Parse(step, f"{path}.steps[{pos}]") for pos, step in enumerate(jsonSteps)]
1282 return cls(
1283 name, dimensions, identifier, status, conclusion, createdAt, startedAt, completedAt, url, labels,
1284 runnerName, runnerGroupName, steps, parent=parent
1285 )
1288@export
1289class Step(CIStep, StatusMixin):
1290 """A step within a job."""
1292 _PARENT_TYPE: ClassVar[Nullable[type]] = Job #: A step is contained in a job of a workflow run.
1294 _number: Nullable[int] #: Position of the step within its job, starting at 1.
1296 def __init__(
1297 self,
1298 name: str,
1299 number: Nullable[int] = None,
1300 status: Nullable[Status] = None,
1301 conclusion: Nullable[Conclusion] = None,
1302 startedAt: Nullable[datetime] = None,
1303 completedAt: Nullable[datetime] = None,
1304 *,
1305 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
1306 parent: Nullable[Job] = None
1307 ) -> None:
1308 """
1309 Initializes a step within a job.
1311 :param name: Name of the step.
1312 :param number: Optional, position of the step within its job, starting at 1. Default: ``None``.
1313 :param status: Optional, state the step is in. Default: ``None``.
1314 :param conclusion: Optional, how the step ended. Default: ``None``.
1315 :param startedAt: Optional, time the step started running. Default: ``None``.
1316 :param completedAt: Optional, time the step completed. Default: ``None``.
1317 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
1318 :param parent: Optional, reference to the job containing the step. Default: ``None``.
1319 :raises TypeError: If parameter 'number' is not of type :class:`int`.
1320 :raises ValueError: If parameter 'number' is not positive.
1321 """
1322 if number is not None and not isinstance(number, int):
1323 ex = TypeError("Parameter 'number' is not of type 'int'.")
1324 ex.add_note(f"Got type '{getFullyQualifiedName(number)}'.")
1325 raise ex
1326 elif number is not None and number < 1:
1327 ex = ValueError("Parameter 'number' is not positive.")
1328 ex.add_note(f"Got value '{number}'.")
1329 raise ex
1331 super().__init__(
1332 name, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs, parent=parent
1333 )
1334 StatusMixin.__init__(self, status, conclusion)
1336 self._number = number
1338 @readonly
1339 def Number(self) -> Nullable[int]:
1340 """
1341 Read-only property to access the step's position within its job (:attr:`_number`).
1343 :returns: The position, starting at 1, or ``None`` if GitHub reported none.
1344 """
1345 return self._number
1347 @classmethod
1348 def Parse(cls, json: JSONObject, path: str = "step", *, parent: Nullable[Job] = None) -> Self:
1349 """
1350 Build a step from the JSON object the GitHub REST API answers with.
1352 :param json: The step, as it appears in a job's ``steps`` array.
1353 :param path: Optional, position of the step, for an exception's message. Default: ``'step'``.
1354 :param parent: Optional, reference to the job containing the step. Default: ``None``.
1355 :returns: The step.
1356 :raises TypeError: If parameter 'json' is not of type :class:`dict`.
1357 :raises GitHubError: If field ``name`` is missing.
1358 :raises GitHubError: If a field holds a value GitHub doesn't document.
1359 """
1360 if not isinstance(json, dict):
1361 ex = TypeError("Parameter 'json' is not of type 'dict'.")
1362 ex.add_note(f"Got type '{getFullyQualifiedName(json)}'.")
1363 raise ex
1365 if (name := json.get("name", None)) is None: 1365 ↛ 1366line 1365 didn't jump to line 1366 because the condition on line 1365 was never true
1366 raise GitHubError(f"Field '{path}.name' is missing.")
1368 number = json.get("number", None)
1369 status = Status.Parse(json.get("status", None))
1370 conclusion = Conclusion.Parse(json.get("conclusion", None))
1371 startedAt = _parseISO8601Timestamp(json.get("started_at", None), f"{path}.started_at")
1372 completedAt = _parseISO8601Timestamp(json.get("completed_at", None), f"{path}.completed_at")
1374 return cls(name, number, status, conclusion, startedAt, completedAt, parent=parent)