Coverage for pyTooling/GitLab/__init__.py: 98%
621 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-18 15:00 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-18 15:00 +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"""
32Working with GitLab CI/CD pipelines: pipeline files, pipeline runs, and their conversion into traces.
34A pipeline file is a :file:`.gitlab-ci.yml` and the files it includes. A pipeline run is read from GitLab's REST API.
35A trace is written e.g. as OpenTelemetry's OTLP/JSON.
37The models derive from the service-independent model :mod:`pyTooling.CI`. Every exception raised by this package
38derives from :exc:`GitLabError`.
39"""
40from __future__ import annotations
42__author__ = "Patrick Lehmann"
43__email__ = "Paebbels@gmail.com"
44__copyright__ = "2026-2026, Patrick Lehmann"
45__license__ = "Apache License, Version 2.0"
46__version__ = "0.1.0rc1"
47__keywords__ = ["GitLab", "GitLab CI/CD", "Pipeline", "CI", "Trace", "OpenTelemetry", "OTLP"]
48__project_url__ = "https://github.com/pyTooling/pyTooling.GitLab"
49__documentation_url__ = "https://pyTooling.github.io/pyTooling.GitLab"
50__issue_tracker_url__ = "https://GitHub.com/pyTooling/pyTooling.GitLab/issues"
52from datetime import datetime, timezone
53from http import HTTPMethod
54from json import loads as json_loads
55from typing import Optional as Nullable, Any, ClassVar, Hashable, Iterable, Mapping, Self, Union
57from pyTooling.CI import CIError, JSONObject, MatrixInstanceMixin, Outcome
58from pyTooling.CI import Job as CIJob, JobGroup as CIJobGroup, Matrix as CIMatrix
59from pyTooling.CI import Pipeline as CIPipeline, PipelineGroup as CIPipelineGroup, Workflow as CIWorkflow
60from pyTooling.Common import getFullyQualifiedName, parseISO8601Timestamp, StringEnum
61from pyTooling.Decorators import export, readonly
62from pyTooling.GenericPath.URL import URL
63from pyTooling.MetaClasses import ExtendedType
64from pyTooling.REST import MediaType, RESTClient, RESTError
67__all__ = ["GITLAB_API_URL", "STATUS_TO_OUTCOME", "TIMEOUT_FAILURE_REASONS"]
70@export
71class GitLabError(CIError):
72 """Base-exception of all exceptions raised by :mod:`pyTooling.GitLab`."""
75GITLAB_API_URL = URL.Parse("https://gitlab.com/api/v4")
76"""Base URL of the REST API of gitlab.com."""
79@export
80class ProjectClient(RESTClient):
81 """
82 A client of the GitLab REST API for one project.
84 A reader of a project's data derives from it. The requests themselves - the retries, the pagination and the
85 token's boundary - are :class:`~pyTooling.REST.RESTClient`'s. GitLab answers a list as a JSON array rather than a
86 JSON object, so this client reads arrays too, with :meth:`GetJSONArray`.
87 """
89 _project: str #: The project, as its numeric identifier or its path.
91 def __init__(
92 self,
93 project: Union[int, str],
94 token: Nullable[str] = None,
95 *,
96 apiURL: Union[str, URL] = GITLAB_API_URL,
97 timeout: float = 30.0,
98 retries: int = 3,
99 retryDelay: float = 2.0
100 ) -> None:
101 """
102 Initializes a client for one project.
104 :param project: The project, as its numeric identifier, e.g. ``74823``, or as its path, e.g.
105 ``gitlab-org/gitlab-development-kit``.
106 :param token: Optional, token authorizing the requests. Default: anonymous requests.
107 :param apiURL: Optional, base URL of the GitLab REST API, e.g. of a self-managed instance.
108 Default: :data:`GITLAB_API_URL`.
109 :param timeout: Optional, timeout of a single request in seconds. Default: ``30.0``.
110 :param retries: Optional, how often a transiently failing request is tried again. ``0`` tries once.
111 Default: ``3``.
112 :param retryDelay: Optional, pause in seconds before a request is tried again the first time. The pause doubles
113 with every further attempt. Default: ``2.0``.
114 :raises ValueError: If parameter 'project' is ``None``.
115 :raises TypeError: If parameter 'project' is neither of type :class:`int` nor :class:`str`.
116 :raises ValueError: If parameter 'project' is a negative number, an empty path, or a path with an empty part.
117 :raises TypeError: If a parameter of :class:`~pyTooling.REST.RESTClient` has the wrong type.
118 :raises ValueError: If a parameter of :class:`~pyTooling.REST.RESTClient` has an invalid value.
119 """
120 super().__init__(apiURL, token, timeout=timeout, retries=retries, retryDelay=retryDelay)
122 if project is None:
123 raise ValueError("Parameter 'project' is None.")
124 elif isinstance(project, bool) or not isinstance(project, (int, str)):
125 ex = TypeError("Parameter 'project' is neither of type 'int' nor 'str'.")
126 ex.add_note(f"Got type '{getFullyQualifiedName(project)}'.")
127 raise ex
128 elif isinstance(project, int) and project < 0:
129 ex = ValueError("Parameter 'project' is a negative number.")
130 ex.add_note(f"Got value '{project}'.")
131 raise ex
132 elif isinstance(project, str) and (project == "" or "" in project.split("/")):
133 ex = ValueError("Parameter 'project' is an empty path or has an empty part.")
134 ex.add_note(f"Got value '{project}'.")
135 raise ex
137 self._project = str(project)
139 @readonly
140 def Project(self) -> str:
141 """
142 Read-only property to access the project, as its numeric identifier or its path (:attr:`_project`).
144 :returns: The project's identifier or path, as given.
145 """
146 return self._project
148 @readonly
149 def ProjectResourcePath(self) -> str:
150 """
151 Read-only property to return the project's resource path below the API.
153 A project's path is URL-encoded, so ``gitlab-org/cli`` becomes ``projects/gitlab-org%2Fcli``.
155 :returns: The resource path of the project.
156 """
157 return f"projects/{self._project.replace('/', '%2F')}"
159 def GetJSONArray(self, resourcePath: str, pageSize: int = 100) -> list[JSONObject]:
160 """
161 Read a list GitLab answers as a JSON array, following its pages.
163 :param resourcePath: Path of the resource below :attr:`~pyTooling.REST.RESTClient.APIURL`.
164 :param pageSize: Optional, number of entries per page, at most 100. Default: ``100``.
165 :returns: The entries of every page, in the order GitLab answered them.
166 :raises ValueError: If parameter 'pageSize' is not in range 1..100.
167 :raises RESTError: If a request fails, or an answer isn't a JSON array.
168 """
169 if not (1 <= pageSize <= 100):
170 ex = ValueError("Parameter 'pageSize' is out of range 1..100.")
171 ex.add_note(f"Got value '{pageSize}'.")
172 raise ex
174 separator = "&" if "?" in resourcePath else "?"
175 entries: list[JSONObject] = []
176 nextResourcePath: Nullable[str] = f"{resourcePath}{separator}per_page={pageSize}"
177 while nextResourcePath is not None:
178 document, nextResourcePath = self._Request(HTTPMethod.GET, nextResourcePath)
179 if not isinstance(document, list):
180 raise RESTError(f"API didn't answer with a JSON array: {self._apiURL / resourcePath}")
182 entries.extend(document)
184 return entries
186 def _ProcessAnswer(self, body: bytes, contentType: Nullable[str], url: URL) -> Nullable[Union[JSONObject, list[Any]]]:
187 """
188 Read an answer's body as a JSON object or a JSON array.
190 :param body: The answer's body.
191 :param contentType: The answer's ``Content-Type`` header, or ``None``.
192 :param url: The URL that was requested.
193 :returns: The JSON object or array, or ``None`` if the answer had no body.
194 :raises RESTError: If the answer's media type isn't JSON. |br|
195 The note reports the media type that was announced.
196 :raises RESTError: If the answer isn't valid JSON.
197 :raises RESTError: If the answer is JSON, but neither a JSON object nor a JSON array.
198 """
199 if len(body) == 0: 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true
200 return None
202 if contentType is not None and not MediaType.JSON.Matches(contentType): 202 ↛ 203line 202 didn't jump to line 203 because the condition on line 202 was never true
203 error = RESTError(f"API didn't answer with JSON: {url}")
204 error.add_note(f"Got 'Content-Type: {contentType}'.")
205 raise error
207 try:
208 document = json_loads(body)
209 except ValueError as ex:
210 raise RESTError(f"API answered with invalid JSON: {url}") from ex
212 if not isinstance(document, (dict, list)): 212 ↛ 213line 212 didn't jump to line 213 because the condition on line 212 was never true
213 raise RESTError(f"API didn't answer with a JSON object or array: {url}")
215 return document
218@export
219class Status(StringEnum):
220 """The state a pipeline or a job is in."""
222 Created = "created" #: Created, but not yet processed.
223 WaitingForResource = "waiting_for_resource" #: Waiting for a resource group.
224 Preparing = "preparing" #: The runner prepares the execution environment.
225 WaitingForCallback = "waiting_for_callback" #: Waiting for a callback from an external service.
226 Pending = "pending" #: Queued, waiting for a runner.
227 Running = "running" #: Running.
228 Success = "success" #: Succeeded.
229 Failed = "failed" #: Failed.
230 Canceling = "canceling" #: Being canceled, while its ``after_script`` runs.
231 Canceled = "canceled" #: Canceled by hand, or aborted.
232 Skipped = "skipped" #: Not run, because a rule or a dependency excluded it.
233 Manual = "manual" #: Waiting to be started by hand.
234 Scheduled = "scheduled" #: Scheduled to start later.
236 @classmethod
237 def Parse(cls, value: Nullable[str]) -> Nullable[Status]:
238 """
239 Convert GitLab's ``status`` field to a member of this enumeration.
241 :param value: Optional, the field's value. Default: ``None``.
242 :returns: The matching member, or ``None`` if the field was absent or empty.
243 :raises TypeError: If parameter 'value' is not of type :class:`str`.
244 :raises GitLabError: If the value is not a status GitLab documents. |br|
245 The note lists the documented values.
246 """
247 try:
248 return super().Parse(value)
249 except ValueError as ex:
250 error = GitLabError(f"'{value}' is not a GitLab status.")
251 error.add_note(f"Known: {', '.join(member.value for member in cls)}.")
252 raise error from ex
254 def ToOutcome(self, failureReason: Nullable[str] = None) -> Nullable[Outcome]:
255 """
256 Return the service-independent outcome this status corresponds to.
258 The statuses ending a pipeline or a job are listed in :data:`STATUS_TO_OUTCOME`; a failure for one of the
259 :data:`TIMEOUT_FAILURE_REASONS` is a :attr:`~pyTooling.CI.Outcome.Timeout`. Any other status - e.g.
260 :attr:`Manual` or :attr:`Running` - hasn't ended.
262 :param failureReason: Optional, the reason GitLab reports for a failure. Default: ``None``.
263 :returns: The outcome, or ``None`` while the status hasn't ended.
264 """
265 if self is Status.Failed and failureReason in TIMEOUT_FAILURE_REASONS:
266 return Outcome.Timeout
268 return STATUS_TO_OUTCOME.get(self, None)
271STATUS_TO_OUTCOME = {
272 Status.Success: Outcome.Success,
273 Status.Failed: Outcome.Failure,
274 Status.Canceled: Outcome.Cancellation,
275 Status.Skipped: Outcome.Skip,
276}
277"""GitLab's statuses ending a pipeline or a job, and their service-independent outcome."""
279TIMEOUT_FAILURE_REASONS = frozenset(("job_execution_timeout", "stuck_or_timeout_failure"))
280"""GitLab's failure reasons of a job stopped by a timeout."""
283@export
284class Source(StringEnum):
285 """What started a pipeline, as GitLab's ``CI_PIPELINE_SOURCE`` names it."""
287 Unknown = "unknown" #: The source isn't known.
288 Push = "push" #: A branch or tag was pushed.
289 Web = "web" #: Started from GitLab's web interface.
290 Trigger = "trigger" #: Started with a trigger token.
291 Schedule = "schedule" #: A pipeline schedule fired.
292 API = "api" #: Started through the pipelines API.
293 External = "external" #: Started by another CI service.
294 Pipeline = "pipeline" #: A multi-project pipeline.
295 Chat = "chat" #: Started by a ChatOps command.
296 WebIDE = "webide" #: Started from the Web IDE.
297 MergeRequestEvent = "merge_request_event" #: A merge request was touched.
298 ExternalPullRequestEvent = "external_pull_request_event" #: A GitHub pull request was touched.
299 ParentPipeline = "parent_pipeline" #: A child pipeline.
300 OnDemandDASTScan = "ondemand_dast_scan" #: An on-demand DAST scan.
301 OnDemandDASTValidation = "ondemand_dast_validation" #: An on-demand DAST validation.
302 SecurityOrchestrationPolicy = "security_orchestration_policy" #: A scan execution policy.
303 ContainerRegistryPush = "container_registry_push" #: An image was pushed to the registry.
304 DuoWorkflow = "duo_workflow" #: Started by a GitLab Duo workflow.
305 PipelineExecutionPolicySchedule = "pipeline_execution_policy_schedule" #: A scheduled execution policy.
306 DependencyManagementSecurityUpdate = "dependency_management_security_update" #: A dependency security update.
308 @classmethod
309 def Parse(cls, value: Nullable[str]) -> Nullable[Source]:
310 """
311 Convert GitLab's ``source`` field to a member of this enumeration.
313 :param value: Optional, the field's value. Default: ``None``.
314 :returns: The matching member, or ``None`` if the field was absent or empty.
315 :raises TypeError: If parameter 'value' is not of type :class:`str`.
316 :raises GitLabError: If the value is not a pipeline source GitLab knows. |br|
317 The note lists the known values.
318 """
319 try:
320 return super().Parse(value)
321 except ValueError as ex:
322 error = GitLabError(f"'{value}' is not a GitLab pipeline source.")
323 error.add_note(f"Known: {', '.join(member.value for member in cls)}.")
324 raise error from ex
327def _parseISO8601Timestamp(value: Nullable[str], field: str) -> Nullable[datetime]:
328 """
329 Parse an ISO 8601 timestamp, as the GitLab REST API reports them.
331 A timestamp without a time zone is read as UTC, so every timestamp of a pipeline can be compared with every other.
333 :param value: Optional, the field's value. Default: ``None``.
334 :param field: Name of the field, for the exception's message.
335 :returns: The timestamp, or ``None`` if the field was absent or empty.
336 :raises GitLabError: If the value isn't an ISO 8601 timestamp. |br|
337 The note reports the value that was read.
338 """
339 try:
340 return parseISO8601Timestamp(value, timezone.utc)
341 except ValueError as ex:
342 error = GitLabError(f"Field '{field}' isn't an ISO 8601 timestamp.")
343 error.add_note(f"Got '{value}'.")
344 raise error from ex
347def _parseURL(value: Nullable[str]) -> Nullable[URL]:
348 """
349 Parse a URL, as the GitLab REST API reports them.
351 :param value: Optional, the field's value. Default: ``None``.
352 :returns: The URL, or ``None`` if the field was absent or empty.
353 """
354 if value is None or value == "":
355 return None
357 return URL.Parse(value)
360def _splitMatrixJobName(name: str) -> tuple[str, Nullable[dict[str, str]]]:
361 """
362 Split a job's name into the matrix' name and the dimensions, if it carries any.
364 GitLab names the instances of a matrix after the job and the values of the instance's dimensions: a
365 ``parallel:matrix`` instance ``test: [3.14, linux]``, an instance of ``parallel: 4`` ``test 1/4``. That suffix is
366 how GitLab names a job, not a field of the payload, so a job genuinely named ``deploy 1/2`` by its pipeline file is
367 indistinguishable from one produced by ``parallel``.
369 A ``parallel:matrix`` suffix carries no dimension names, so a dimension is named by the position of its value:
370 ``"0"``, ``"1"``, ... An instance of ``parallel: N`` carries the variables GitLab sets for it, ``CI_NODE_INDEX`` and
371 ``CI_NODE_TOTAL``.
373 :param name: The job's name.
374 :returns: The name without the suffix and the dimensions, or the name and ``None`` if it carries none.
375 """
376 if name.endswith("]") and ": [" in name:
377 base, _, values = name[:-1].rpartition(": [")
378 if base != "":
379 return base, {str(position): value.strip() for position, value in enumerate(values.split(","))}
381 return name, None
383 base, _, suffix = name.rpartition(" ")
384 index, _, total = suffix.partition("/")
385 if base == "" or not index.isdigit() or not total.isdigit():
386 return name, None
388 return base, {"CI_NODE_INDEX": index, "CI_NODE_TOTAL": total}
391def _matrixInstanceName(name: str, dimensions: Mapping[str, Any]) -> str:
392 """
393 Return the name GitLab gives an instance of a matrix, the inverse of :func:`_splitMatrixJobName`.
395 :param name: The job's name, without the instance's values.
396 :param dimensions: The instance's dimensions.
397 :returns: The name with the instance's values: ``test 1/4`` for ``parallel``, ``test: [3.14, linux]`` for
398 ``parallel:matrix``, and the name alone without dimensions.
399 """
400 if len(dimensions) == 0:
401 return name
402 elif tuple(dimensions) == ("CI_NODE_INDEX", "CI_NODE_TOTAL"):
403 return f"{name} {dimensions['CI_NODE_INDEX']}/{dimensions['CI_NODE_TOTAL']}"
405 return f"{name}: [{', '.join(str(value) for value in dimensions.values())}]"
408@export
409class StatusMixin(metaclass=ExtendedType, mixin=True, expects=("_outcome",)):
410 """
411 Mixin-class for the elements GitLab reports: a pipeline, a downstream pipeline and a job.
413 GitLab reports a :class:`Status` for each of them, a failure reason for a failed job, and a URL. The status is also
414 the element's generic :attr:`~pyTooling.CI.Base.Outcome`, by :meth:`Status.ToOutcome`.
415 """
417 _status: Nullable[Status] #: State the element is in.
418 _failureReason: Nullable[str] #: Reason GitLab reports for a failure.
419 _url: Nullable[URL] #: URL of the element in GitLab's web interface.
421 def __init__(
422 self,
423 status: Nullable[Status] = None,
424 failureReason: Nullable[str] = None,
425 url: Nullable[URL] = None
426 ) -> None:
427 """
428 Initializes what GitLab reports about an element.
430 :param status: Optional, state the element is in. Default: ``None``.
431 :param failureReason: Optional, reason GitLab reports for a failure, e.g. ``script_failure``. Default: ``None``.
432 :param url: Optional, URL of the element in GitLab's web interface. Default: ``None``.
433 :raises TypeError: If parameter 'status' is not of type :class:`Status`.
434 :raises TypeError: If parameter 'failureReason' is not of type :class:`str`.
435 :raises TypeError: If parameter 'url' is not of type :class:`~pyTooling.GenericPath.URL.URL`.
436 """
437 if status is not None and not isinstance(status, Status):
438 ex = TypeError("Parameter 'status' is not of type 'Status'.")
439 ex.add_note(f"Got type '{getFullyQualifiedName(status)}'.")
440 raise ex
442 if failureReason is not None and not isinstance(failureReason, str):
443 ex = TypeError("Parameter 'failureReason' is not of type 'str'.")
444 ex.add_note(f"Got type '{getFullyQualifiedName(failureReason)}'.")
445 raise ex
447 if url is not None and not isinstance(url, URL):
448 ex = TypeError("Parameter 'url' is not of type 'URL'.")
449 ex.add_note(f"Got type '{getFullyQualifiedName(url)}'.")
450 raise ex
452 self._status = status
453 self._failureReason = failureReason
454 self._url = url
455 if status is not None:
456 self._outcome = status.ToOutcome(failureReason)
458 @readonly
459 def Status(self) -> Nullable[Status]:
460 """
461 Read-only property to access the state the element is in (:attr:`_status`).
463 The service-independent :attr:`~pyTooling.CI.Base.Outcome` is derived from it by :meth:`Status.ToOutcome`.
465 :returns: The state, or ``None`` if GitLab reported none.
466 """
467 return self._status
469 @readonly
470 def FailureReason(self) -> Nullable[str]:
471 """
472 Read-only property to access the reason GitLab reports for a failure (:attr:`_failureReason`).
474 GitLab reports it for a failed job, e.g. ``script_failure`` or ``job_execution_timeout``.
476 :returns: The reason, or ``None`` if GitLab reported none.
477 """
478 return self._failureReason
480 @readonly
481 def URL(self) -> Nullable[URL]:
482 """
483 Read-only property to access the element's URL in GitLab's web interface (:attr:`_url`).
485 :returns: The URL, or ``None`` if GitLab reported none.
486 """
487 return self._url
490@export
491class PipelineGroup(CIPipelineGroup):
492 """
493 Every pipeline GitLab started for one commit, and the top of the tree.
495 A commit can have several pipelines: a branch pipeline started by the push, a merge request pipeline, a pipeline at
496 a tag of the same commit, a scheduled one. GitLab lists a project's pipelines without its child pipelines, which
497 belong to the pipeline that triggered them (see :class:`~pyTooling.CI.Workflow`).
499 The group has no times of its own and derives them from its pipelines.
500 """
502 def __init__(
503 self,
504 sha: str,
505 pipelines: Nullable[Iterable[Pipeline]] = None,
506 *,
507 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None
508 ) -> None:
509 """
510 Initializes a group of pipelines started for one commit.
512 :param sha: Commit every pipeline of the group was started on.
513 :param pipelines: Optional, the pipelines, which are attached to the group. Default: ``None``.
514 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
515 :raises ValueError: If parameter 'sha' is ``None``.
516 :raises TypeError: If parameter 'sha' is not of type :class:`str`.
517 :raises ValueError: If parameter 'sha' is empty.
518 :raises TypeError: If an element of parameter 'pipelines' is not of type :class:`Pipeline`.
519 """
520 super().__init__(sha, pipelines, keyValuePairs=keyValuePairs)
522 @readonly
523 def SHA(self) -> str:
524 """
525 Read-only property to access the commit every pipeline of the group was started on (:attr:`_name`).
527 :returns: The commit's hash.
528 """
529 return self._name
531 def ByGitReference(self) -> dict[Nullable[str], list[Pipeline]]:
532 """
533 Group the commit's pipelines by the reference they were started on.
535 A commit pushed to a branch, proposed in a merge request and later tagged has its pipelines under three keys -
536 e.g. ``main``, ``refs/merge-requests/6293/merge`` and ``v1.0.0``.
538 :returns: Dictionary of a reference to the pipelines started on it, in the order they were reported.
539 """
540 byReference: dict[Nullable[str], list[Pipeline]] = {}
541 for pipeline in self._pipelines:
542 if (pipelines := byReference.get(pipeline.GitReference, None)) is None: 542 ↛ 546line 542 didn't jump to line 546 because the condition on line 542 was always true
543 pipelines = []
544 byReference[pipeline.GitReference] = pipelines
546 pipelines.append(pipeline)
548 return byReference
550 @classmethod
551 def Parse(
552 cls,
553 pipelines: Iterable[JSONObject],
554 jobs: Nullable[Mapping[int, Iterable[JSONObject]]] = None,
555 sha: Nullable[str] = None
556 ) -> Self:
557 """
558 Build a group of pipelines from the JSON objects the GitLab REST API answers with.
560 :param pipelines: The pipelines, as returned by ``GET /projects/{id}/pipelines?sha=...``, or each as returned
561 by ``GET /projects/{id}/pipelines/{pipeline_id}``.
562 :param jobs: Optional, the jobs of each pipeline, by the pipeline's identifier. Default: ``None``.
563 :param sha: Optional, the commit. Default: the ``sha`` the pipelines report.
564 :returns: The group, with its pipelines attached.
565 :raises GitLabError: If the pipelines report different commits.
566 :raises GitLabError: If no pipeline reports a commit and parameter 'sha' wasn't given.
567 """
568 pipelineList = []
569 shas = set()
570 for json in pipelines:
571 pipelineJobs = None
572 if jobs is not None and isinstance(json, dict):
573 pipelineJobs = jobs.get(json.get("id", None), None)
575 pipeline = Pipeline.Parse(json, pipelineJobs)
576 pipelineList.append(pipeline)
577 if pipeline.SHA is not None:
578 shas.add(pipeline.SHA)
580 if sha is None:
581 if (commits := len(shas)) == 0:
582 raise GitLabError("None of the pipelines reports a 'sha', and parameter 'sha' wasn't given.")
583 elif commits > 1:
584 error = GitLabError("The pipelines report different commits.")
585 error.add_note(f"Got {', '.join(sorted(shas))}.")
586 raise error
588 sha = shas.pop()
590 return cls(sha, pipelineList)
593@export
594class PipelineContentsMixin(metaclass=ExtendedType, mixin=True, expects=("_elements", "_matrices", "_workflows")):
595 """
596 Mixin-class for the groups holding what GitLab lists for a pipeline: a pipeline and a downstream pipeline.
598 It adds the jobs GitLab lists for a pipeline and the trigger jobs starting downstream pipelines, and the
599 dependencies the stages imply.
600 """
602 def _AddJobs(self, jobs: Iterable[JSONObject], path: str) -> None:
603 """
604 Add the jobs GitLab listed for this pipeline, in the order it created them.
606 :param jobs: The pipeline's jobs, as listed by ``GET .../pipelines/{pipeline_id}/jobs``.
607 :param path: Name of the jobs' list, for an exception's message.
608 :raises TypeError: If an element of parameter 'jobs' is not of type :class:`dict`.
609 :raises GitLabError: If a job's field ``name`` is missing.
610 :raises GitLabError: If a field holds a value GitLab doesn't document.
611 """
612 jobList = list(jobs)
613 for position, job in enumerate(jobList):
614 if not isinstance(job, dict):
615 ex = TypeError(f"Job {position} is not of type 'dict'.")
616 ex.add_note(f"Got type '{getFullyQualifiedName(job)}'.")
617 raise ex
619 for position, job in sorted(enumerate(jobList), key=self._CreationOrder):
620 jobPath = f"{path}[{position}]"
621 if (fullName := job.get("name", None)) is None:
622 raise GitLabError(f"Field '{jobPath}.name' is missing.")
624 jobName, dimensions = _splitMatrixJobName(fullName)
625 if dimensions is None:
626 Job.Parse(job, jobPath, parent=self)
627 else:
628 if (matrix := self._matrices.get(jobName, None)) is None:
629 matrix = CIMatrix(jobName, parent=self)
631 MatrixJob.Parse(job, jobPath, jobName, dimensions, parent=matrix)
633 def _AddBridges(
634 self,
635 bridges: Iterable[JSONObject],
636 downstream: Nullable[Mapping[Union[int, str], JSONObject]],
637 path: str
638 ) -> None:
639 """
640 Add a downstream pipeline for every trigger job GitLab listed, in the order it created them.
642 :param bridges: The trigger jobs, as listed by ``GET .../pipelines/{pipeline_id}/bridges``.
643 :param downstream: The downstream pipelines' payloads, by the downstream pipeline's identifier, or ``None``.
644 :param path: Name of the trigger jobs' list, for an exception's message.
645 :raises TypeError: If an element of parameter 'bridges' is not of type :class:`dict`.
646 :raises GitLabError: If a trigger job's field ``name`` is missing.
647 :raises GitLabError: If a field holds a value GitLab doesn't document.
648 """
649 bridgeList = list(bridges)
650 for position, bridge in enumerate(bridgeList):
651 if not isinstance(bridge, dict):
652 ex = TypeError(f"Trigger job {position} is not of type 'dict'.")
653 ex.add_note(f"Got type '{getFullyQualifiedName(bridge)}'.")
654 raise ex
656 for position, bridge in sorted(enumerate(bridgeList), key=self._CreationOrder):
657 bridgePath = f"{path}[{position}]"
658 if (fullName := bridge.get("name", None)) is None:
659 raise GitLabError(f"Field '{bridgePath}.name' is missing.")
661 payload = None
662 if downstream is not None and (child := bridge.get("downstream_pipeline", None)) is not None:
663 if (payload := downstream.get(child.get("id", None), None)) is None:
664 payload = downstream.get(str(child.get("id", None)), None)
666 bridgeName, dimensions = _splitMatrixJobName(fullName)
667 if dimensions is None:
668 DownstreamPipeline.Parse(bridge, payload, bridgePath, parent=self)
669 else:
670 if (matrix := self._matrices.get(bridgeName, None)) is None:
671 matrix = CIMatrix(bridgeName, parent=self)
673 MatrixDownstreamPipeline.Parse(bridge, payload, bridgePath, bridgeName, dimensions, parent=matrix)
675 @staticmethod
676 def _CreationOrder(entry: tuple[int, JSONObject]) -> tuple[bool, int]:
677 """
678 Sort the jobs GitLab listed by their identifier, which grows with a job's creation.
680 :param entry: A job's position in its list, and the job.
681 :returns: The sort key; a job without identifier is placed behind every job with one.
682 """
683 identifier = entry[1].get("id", None)
685 return (identifier is None, 0 if identifier is None else identifier)
687 def ApplyNeeds(self, stages: Iterable[str], needs: Nullable[Mapping[str, Iterable[str]]] = None) -> None:
688 """
689 Add the dependencies between the elements of this pipeline, which the stages and the ``needs`` imply.
691 GitLab's REST API reports a job's stage, but neither the order of the stages nor a job's ``needs``; both are
692 in the pipeline file. An element listed in parameter 'needs' needs the siblings named there - a matrix or a
693 downstream pipeline by its name, ``[]`` needing nothing. Every other element needs every element of the
694 nearest earlier stage holding elements, as GitLab runs them. A downstream pipeline's contents aren't touched:
695 apply its own stages to it.
697 :param stages: The pipeline's stages, in their order.
698 :param needs: Optional, the names an element needs, by the element's name. Default: ``None``.
699 :raises ValueError: If parameter 'stages' is ``None``.
700 :raises TypeError: If parameter 'stages' is not iterable.
701 :raises TypeError: If parameter 'needs' is not a mapping.
702 :raises GitLabError: If an element's stage is unknown or not one of parameter 'stages'.
703 :raises GitLabError: If an element needs a name no sibling has.
704 """
705 if stages is None:
706 raise ValueError("Parameter 'stages' is None.")
707 elif not isinstance(stages, Iterable) or isinstance(stages, str):
708 ex = TypeError("Parameter 'stages' is not iterable.")
709 ex.add_note(f"Got type '{getFullyQualifiedName(stages)}'.")
710 raise ex
712 if needs is not None and not isinstance(needs, Mapping):
713 ex = TypeError("Parameter 'needs' is not a mapping ('dict', ...).")
714 ex.add_note(f"Got type '{getFullyQualifiedName(needs)}'.")
715 raise ex
717 stageIndex = {stage: index for index, stage in enumerate(stages)}
718 byStage: dict[int, list[Any]] = {}
719 elementStages = {}
720 for element in self._elements:
721 stage = element.Instances[0].Stage if isinstance(element, CIMatrix) else element.Stage
722 if (index := stageIndex.get(stage, None)) is None:
723 ex = GitLabError(f"Element '{element.Name}' of '{self}' is in stage '{stage}', which isn't one of the stages.")
724 ex.add_note(f"Stages: {', '.join(stageIndex)}.")
725 raise ex
727 byStage.setdefault(index, []).append(element)
728 elementStages[element] = index
730 siblings = {element.Name: element for element in self._elements}
731 for element, index in elementStages.items():
732 if needs is not None and element.Name in needs:
733 for name in needs[element.Name]:
734 if (need := siblings.get(name, None)) is None:
735 raise GitLabError(f"Element '{element.Name}' of '{self}' needs '{name}', which no sibling is named.")
737 element.AddNeed(need)
738 elif len(earlier := [stage for stage in byStage if stage < index]) > 0:
739 for need in byStage[max(earlier)]:
740 element.AddNeed(need)
743@export
744class Pipeline(CIPipeline, StatusMixin, PipelineContentsMixin):
745 """
746 A pipeline.
748 A pipeline contains its jobs and a :class:`~pyTooling.CI.Matrix` for every job with ``parallel`` or
749 ``parallel:matrix``. Several pipelines of one commit are held by a :class:`PipelineGroup`, which is the top of the
750 tree.
751 """
753 _PARENT_TYPE: ClassVar[Nullable[type]] = PipelineGroup #: A pipeline is contained in a pipeline group.
755 _id: Nullable[int] #: GitLab's identifier of the pipeline, unique on the instance.
756 _iid: Nullable[int] #: Number of the pipeline within its project.
757 _projectID: Nullable[int] #: GitLab's identifier of the project.
758 _source: Nullable[Source] #: What started the pipeline.
759 _gitReference: Nullable[str] #: Branch, tag or merge request reference the pipeline was started on.
760 _sha: Nullable[str] #: Commit the pipeline was started on.
761 _isTag: Nullable[bool] #: Whether the reference is a tag.
762 _queuedDuration: Nullable[float] #: Seconds the pipeline waited before its first job started.
764 def __init__(
765 self,
766 name: str,
767 identifier: Nullable[int] = None,
768 iid: Nullable[int] = None,
769 projectID: Nullable[int] = None,
770 status: Nullable[Status] = None,
771 createdAt: Nullable[datetime] = None,
772 startedAt: Nullable[datetime] = None,
773 completedAt: Nullable[datetime] = None,
774 url: Nullable[URL] = None,
775 source: Nullable[Source] = None,
776 gitReference: Nullable[str] = None,
777 sha: Nullable[str] = None,
778 isTag: Nullable[bool] = None,
779 queuedDuration: Nullable[float] = None,
780 *,
781 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
782 parent: Nullable[PipelineGroup] = None
783 ) -> None:
784 """
785 Initializes a pipeline.
787 :param name: Name of the pipeline.
788 :param identifier: Optional, GitLab's identifier of the pipeline, unique on the instance. Default: ``None``.
789 :param iid: Optional, number of the pipeline within its project. Default: ``None``.
790 :param projectID: Optional, GitLab's identifier of the project. Default: ``None``.
791 :param status: Optional, state the pipeline is in. Default: ``None``.
792 :param createdAt: Optional, time the pipeline was created. Default: ``None``.
793 :param startedAt: Optional, time the pipeline started. Default: ``None``.
794 :param completedAt: Optional, time the pipeline finished. Default: ``None``.
795 :param url: Optional, URL of the pipeline in GitLab's web interface. Default: ``None``.
796 :param source: Optional, what started the pipeline. Default: ``None``.
797 :param gitReference: Optional, branch, tag or merge request reference the pipeline was started on.
798 Default: ``None``.
799 :param sha: Optional, commit the pipeline was started on. Default: ``None``.
800 :param isTag: Optional, ``True``, if the reference is a tag. Default: ``None``.
801 :param queuedDuration: Optional, seconds the pipeline waited before its first job started. Default: ``None``.
802 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
803 :param parent: Optional, reference to the group of the commit's pipelines. Default: ``None``.
804 :raises TypeError: If parameter 'identifier' is not of type :class:`int`.
805 :raises TypeError: If parameter 'iid' is not of type :class:`int`.
806 :raises TypeError: If parameter 'projectID' is not of type :class:`int`.
807 :raises TypeError: If parameter 'source' is not of type :class:`Source`.
808 :raises TypeError: If parameter 'gitReference' is not of type :class:`str`.
809 :raises TypeError: If parameter 'sha' is not of type :class:`str`.
810 :raises TypeError: If parameter 'isTag' is not of type :class:`bool`.
811 :raises TypeError: If parameter 'queuedDuration' is not of type :class:`float`.
812 """
813 super().__init__(
814 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs,
815 parent=parent
816 )
817 StatusMixin.__init__(self, status, None, url)
819 for parameterName, number in (
820 ("identifier", identifier),
821 ("iid", iid),
822 ("projectID", projectID)
823 ):
824 if number is not None and (isinstance(number, bool) or not isinstance(number, int)):
825 ex = TypeError(f"Parameter '{parameterName}' is not of type 'int'.")
826 ex.add_note(f"Got type '{getFullyQualifiedName(number)}'.")
827 raise ex
829 if source is not None and not isinstance(source, Source):
830 ex = TypeError("Parameter 'source' is not of type 'Source'.")
831 ex.add_note(f"Got type '{getFullyQualifiedName(source)}'.")
832 raise ex
834 for parameterName, text in (("gitReference", gitReference), ("sha", sha)):
835 if text is not None and not isinstance(text, str):
836 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
837 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.")
838 raise ex
840 if isTag is not None and not isinstance(isTag, bool):
841 ex = TypeError("Parameter 'isTag' is not of type 'bool'.")
842 ex.add_note(f"Got type '{getFullyQualifiedName(isTag)}'.")
843 raise ex
845 if queuedDuration is not None and (
846 isinstance(queuedDuration, bool) or not isinstance(queuedDuration, (int, float))
847 ):
848 ex = TypeError("Parameter 'queuedDuration' is not of type 'float'.")
849 ex.add_note(f"Got type '{getFullyQualifiedName(queuedDuration)}'.")
850 raise ex
852 self._id = identifier
853 self._iid = iid
854 self._projectID = projectID
855 self._source = source
856 self._gitReference = gitReference
857 self._sha = sha
858 self._isTag = isTag
859 self._queuedDuration = None if queuedDuration is None else float(queuedDuration)
861 @readonly
862 def ID(self) -> Nullable[int]:
863 """
864 Read-only property to access GitLab's identifier of the pipeline (:attr:`_id`).
866 The identifier is unique on the GitLab instance; :attr:`IID` numbers the pipelines of a project.
868 :returns: The identifier, or ``None`` if GitLab reported none.
869 """
870 return self._id
872 @readonly
873 def IID(self) -> Nullable[int]:
874 """
875 Read-only property to access the number of the pipeline within its project (:attr:`_iid`).
877 :returns: The number, or ``None`` if GitLab reported none.
878 """
879 return self._iid
881 @readonly
882 def ProjectID(self) -> Nullable[int]:
883 """
884 Read-only property to access GitLab's identifier of the project the pipeline belongs to (:attr:`_projectID`).
886 :returns: The identifier, or ``None`` if GitLab reported none.
887 """
888 return self._projectID
890 @readonly
891 def Source(self) -> Nullable[Source]:
892 """
893 Read-only property to access what started the pipeline (:attr:`_source`).
895 :returns: The source, or ``None`` if GitLab reported none.
896 """
897 return self._source
899 @readonly
900 def GitReference(self) -> Nullable[str]:
901 """
902 Read-only property to access the reference the pipeline was started on (:attr:`_gitReference`).
904 It is a branch's or a tag's name, e.g. ``main``, or a merge request's reference, e.g.
905 ``refs/merge-requests/6293/merge``. :attr:`IsTag` tells a tag from a branch.
907 :returns: The reference, or ``None`` if GitLab reported none.
908 """
909 return self._gitReference
911 @readonly
912 def SHA(self) -> Nullable[str]:
913 """
914 Read-only property to access the commit the pipeline was started on (:attr:`_sha`).
916 :returns: The commit's hash, or ``None`` if GitLab reported none.
917 """
918 return self._sha
920 @readonly
921 def IsTag(self) -> Nullable[bool]:
922 """
923 Read-only property to access whether the pipeline was started on a tag (:attr:`_isTag`).
925 :returns: ``True``, if :attr:`GitReference` is a tag, or ``None`` if GitLab reported nothing.
926 """
927 return self._isTag
929 @readonly
930 def QueuedDuration(self) -> Nullable[float]:
931 """
932 Read-only property to access how long the pipeline waited before its first job started (:attr:`_queuedDuration`).
934 :returns: Seconds, as GitLab reports them, or ``None`` if GitLab reported none.
935 """
936 return self._queuedDuration
938 @classmethod
939 def Parse(
940 cls,
941 pipeline: JSONObject,
942 jobs: Nullable[Iterable[JSONObject]] = None,
943 bridges: Nullable[Iterable[JSONObject]] = None,
944 downstream: Nullable[Mapping[Union[int, str], JSONObject]] = None,
945 *,
946 parent: Nullable[PipelineGroup] = None
947 ) -> Self:
948 """
949 Build a pipeline and its tree from the JSON objects the GitLab REST API answers with.
951 A pipeline without a ``name`` - GitLab reports one only when the pipeline file sets ``workflow:name`` - is named
952 by its number within the project, e.g. ``#47177``. The jobs are added in the order GitLab created them. A job
953 named as an instance of a matrix - ``test: [3.14, linux]`` or ``test 1/4`` - becomes a :class:`MatrixJob` below
954 a :class:`~pyTooling.CI.Matrix` named ``test``. A trigger job becomes a :class:`DownstreamPipeline`, holding the
955 downstream pipeline's jobs if parameter 'downstream' carries them.
957 Parameter 'downstream' maps a downstream pipeline's identifier - an :class:`int`, or its text as a JSON file's
958 key - to a JSON object with the downstream pipeline's payloads: ``pipeline``, ``jobs``, ``bridges`` and its own
959 ``downstream``, in the shape of this method's parameters.
961 :param pipeline: The pipeline, as returned by ``GET /projects/{id}/pipelines/{pipeline_id}``.
962 :param jobs: Optional, the pipeline's jobs, as listed by ``GET .../pipelines/{pipeline_id}/jobs``.
963 :param bridges: Optional, the pipeline's trigger jobs, as listed by
964 ``GET .../pipelines/{pipeline_id}/bridges``.
965 :param downstream: Optional, the downstream pipelines' payloads, by the downstream pipeline's identifier.
966 Default: ``None``.
967 :param parent: Optional, reference to the group of the commit's pipelines. Default: ``None``.
968 :returns: The pipeline, with its matrices, jobs and downstream pipelines attached.
969 :raises TypeError: If parameter 'pipeline' is not of type :class:`dict`.
970 :raises TypeError: If an element of parameter 'jobs' is not of type :class:`dict`.
971 :raises TypeError: If an element of parameter 'bridges' is not of type :class:`dict`.
972 :raises GitLabError: If field ``id`` is missing.
973 :raises GitLabError: If a job's or a trigger job's field ``name`` is missing.
974 :raises GitLabError: If a field holds a value GitLab doesn't document.
975 """
976 if not isinstance(pipeline, dict):
977 ex = TypeError("Parameter 'pipeline' is not of type 'dict'.")
978 ex.add_note(f"Got type '{getFullyQualifiedName(pipeline)}'.")
979 raise ex
981 if (identifier := pipeline.get("id", None)) is None:
982 raise GitLabError("Field 'pipeline.id' is missing.")
984 iid = pipeline.get("iid", None)
985 if (name := pipeline.get("name", None)) is None or name == "":
986 name = f"#{identifier if iid is None else iid}"
988 result = cls(
989 name,
990 identifier,
991 iid,
992 pipeline.get("project_id", None),
993 Status.Parse(pipeline.get("status", None)),
994 _parseISO8601Timestamp(pipeline.get("created_at", None), "pipeline.created_at"),
995 _parseISO8601Timestamp(pipeline.get("started_at", None), "pipeline.started_at"),
996 _parseISO8601Timestamp(pipeline.get("finished_at", None), "pipeline.finished_at"),
997 _parseURL(pipeline.get("web_url", None)),
998 Source.Parse(pipeline.get("source", None)),
999 pipeline.get("ref", None),
1000 pipeline.get("sha", None),
1001 pipeline.get("tag", None),
1002 pipeline.get("queued_duration", None),
1003 parent=parent
1004 )
1006 if jobs is not None:
1007 result._AddJobs(jobs, "jobs")
1009 if bridges is not None:
1010 result._AddBridges(bridges, downstream, "bridges")
1012 return result
1015@export
1016class JobMixin(metaclass=ExtendedType, mixin=True):
1017 """
1018 Mixin-class for what GitLab reports about a job: a job running on a runner, and a trigger job.
1020 Both are listed in a stage and may be allowed to fail; GitLab reports how long a job waited in its queue.
1021 """
1023 _id: Nullable[int] #: GitLab's identifier of the job.
1024 _stage: Nullable[str] #: Stage the job is listed in.
1025 _allowFailure: Nullable[bool] #: Whether the job may fail without failing its pipeline.
1026 _queuedDuration: Nullable[float] #: Seconds the job waited in GitLab's queue.
1028 def __init__(
1029 self,
1030 identifier: Nullable[int] = None,
1031 stage: Nullable[str] = None,
1032 allowFailure: Nullable[bool] = None,
1033 queuedDuration: Nullable[float] = None
1034 ) -> None:
1035 """
1036 Initializes what GitLab reports about a job.
1038 :param identifier: Optional, GitLab's identifier of the job. Default: ``None``.
1039 :param stage: Optional, stage the job is listed in. Default: ``None``.
1040 :param allowFailure: Optional, ``True``, if the job may fail without failing its pipeline. Default: ``None``.
1041 :param queuedDuration: Optional, seconds the job waited in GitLab's queue. Default: ``None``.
1042 :raises TypeError: If parameter 'identifier' is not of type :class:`int`.
1043 :raises TypeError: If parameter 'stage' is not of type :class:`str`.
1044 :raises TypeError: If parameter 'allowFailure' is not of type :class:`bool`.
1045 :raises TypeError: If parameter 'queuedDuration' is not of type :class:`float`.
1046 """
1047 if identifier is not None and (isinstance(identifier, bool) or not isinstance(identifier, int)):
1048 ex = TypeError("Parameter 'identifier' is not of type 'int'.")
1049 ex.add_note(f"Got type '{getFullyQualifiedName(identifier)}'.")
1050 raise ex
1052 if stage is not None and not isinstance(stage, str):
1053 ex = TypeError("Parameter 'stage' is not of type 'str'.")
1054 ex.add_note(f"Got type '{getFullyQualifiedName(stage)}'.")
1055 raise ex
1057 if allowFailure is not None and not isinstance(allowFailure, bool):
1058 ex = TypeError("Parameter 'allowFailure' is not of type 'bool'.")
1059 ex.add_note(f"Got type '{getFullyQualifiedName(allowFailure)}'.")
1060 raise ex
1062 if queuedDuration is not None and (
1063 isinstance(queuedDuration, bool) or not isinstance(queuedDuration, (int, float))
1064 ):
1065 ex = TypeError("Parameter 'queuedDuration' is not of type 'float'.")
1066 ex.add_note(f"Got type '{getFullyQualifiedName(queuedDuration)}'.")
1067 raise ex
1069 self._id = identifier
1070 self._stage = stage
1071 self._allowFailure = allowFailure
1072 self._queuedDuration = None if queuedDuration is None else float(queuedDuration)
1074 @readonly
1075 def ID(self) -> Nullable[int]:
1076 """
1077 Read-only property to access GitLab's identifier of the job (:attr:`_id`).
1079 :returns: The identifier, or ``None`` if GitLab reported none.
1080 """
1081 return self._id
1083 @readonly
1084 def Stage(self) -> Nullable[str]:
1085 """
1086 Read-only property to access the stage the job is listed in (:attr:`_stage`).
1088 GitLab reports a job's stage, but not the order of the stages, and not a job's ``needs``.
1090 :returns: The stage's name, or ``None`` if GitLab reported none.
1091 """
1092 return self._stage
1094 @readonly
1095 def AllowFailure(self) -> Nullable[bool]:
1096 """
1097 Read-only property to access whether the job may fail without failing its pipeline (:attr:`_allowFailure`).
1099 :returns: ``True``, if a failure of the job doesn't fail its pipeline, or ``None`` if GitLab reported nothing.
1100 """
1101 return self._allowFailure
1103 @readonly
1104 def QueuedDuration(self) -> Nullable[float]:
1105 """
1106 Read-only property to access how long the job waited in GitLab's queue (:attr:`_queuedDuration`).
1108 GitLab counts the time the job was pending for a runner. The time a job waited for the jobs it depends on isn't
1109 included, though it lies between :attr:`~pyTooling.CI.Base.CreatedAt` and :attr:`~pyTooling.CI.Base.StartedAt`.
1111 :returns: Seconds, as GitLab reports them, or ``None`` if GitLab reported none.
1112 """
1113 return self._queuedDuration
1116@export
1117class Job(CIJob, StatusMixin, JobMixin):
1118 """A job of a pipeline, which ran on a runner."""
1120 _tags: list[str] #: Tags the job requested its runner by, i.e. its ``tags``.
1121 _runnerID: Nullable[int] #: GitLab's identifier of the runner the job ran on.
1122 _runnerDescription: Nullable[str] #: Description of the runner the job ran on.
1124 def __init__(
1125 self,
1126 name: str,
1127 identifier: Nullable[int] = None,
1128 stage: Nullable[str] = None,
1129 status: Nullable[Status] = None,
1130 failureReason: Nullable[str] = None,
1131 allowFailure: Nullable[bool] = None,
1132 createdAt: Nullable[datetime] = None,
1133 startedAt: Nullable[datetime] = None,
1134 completedAt: Nullable[datetime] = None,
1135 queuedDuration: Nullable[float] = None,
1136 url: Nullable[URL] = None,
1137 tags: Nullable[Iterable[str]] = None,
1138 runnerID: Nullable[int] = None,
1139 runnerDescription: Nullable[str] = None,
1140 *,
1141 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
1142 parent: Nullable[CIJobGroup] = None
1143 ) -> None:
1144 """
1145 Initializes a job of a pipeline.
1147 :param name: Name of the job.
1148 :param identifier: Optional, GitLab's identifier of the job. Default: ``None``.
1149 :param stage: Optional, stage the job is listed in. Default: ``None``.
1150 :param status: Optional, state the job is in. Default: ``None``.
1151 :param failureReason: Optional, reason GitLab reports for a failure. Default: ``None``.
1152 :param allowFailure: Optional, ``True``, if the job may fail without failing its pipeline. Default: ``None``.
1153 :param createdAt: Optional, time the job was created. Default: ``None``.
1154 :param startedAt: Optional, time the job started running on a runner. Default: ``None``.
1155 :param completedAt: Optional, time the job finished. Default: ``None``.
1156 :param queuedDuration: Optional, seconds the job waited in GitLab's queue. Default: ``None``.
1157 :param url: Optional, URL of the job in GitLab's web interface. Default: ``None``.
1158 :param tags: Optional, tags the job requested its runner by. Default: ``None``.
1159 :param runnerID: Optional, GitLab's identifier of the runner the job ran on. Default: ``None``.
1160 :param runnerDescription: Optional, description of the runner the job ran on. Default: ``None``.
1161 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
1162 :param parent: Optional, reference to the group containing the job. Default: ``None``.
1163 :raises TypeError: If an element of parameter 'tags' is not of type :class:`str`.
1164 :raises TypeError: If parameter 'runnerID' is not of type :class:`int`.
1165 :raises TypeError: If parameter 'runnerDescription' is not of type :class:`str`.
1166 """
1167 super().__init__(
1168 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, keyValuePairs=keyValuePairs,
1169 parent=parent
1170 )
1171 StatusMixin.__init__(self, status, failureReason, url)
1172 JobMixin.__init__(self, identifier, stage, allowFailure, queuedDuration)
1174 if runnerID is not None and (isinstance(runnerID, bool) or not isinstance(runnerID, int)):
1175 ex = TypeError("Parameter 'runnerID' is not of type 'int'.")
1176 ex.add_note(f"Got type '{getFullyQualifiedName(runnerID)}'.")
1177 raise ex
1179 if runnerDescription is not None and not isinstance(runnerDescription, str):
1180 ex = TypeError("Parameter 'runnerDescription' is not of type 'str'.")
1181 ex.add_note(f"Got type '{getFullyQualifiedName(runnerDescription)}'.")
1182 raise ex
1184 tagList = []
1185 if tags is not None:
1186 for tag in tags:
1187 if not isinstance(tag, str):
1188 ex = TypeError("An element of parameter 'tags' is not of type 'str'.")
1189 ex.add_note(f"Got type '{getFullyQualifiedName(tag)}'.")
1190 raise ex
1192 tagList.append(tag)
1194 self._tags = tagList
1195 self._runnerID = runnerID
1196 self._runnerDescription = runnerDescription
1198 @readonly
1199 def Tags(self) -> list[str]:
1200 """
1201 Read-only property to access the tags the job requested its runner by (:attr:`_tags`).
1203 These are the values of the job's ``tags:``; a runner picks a job, whose tags it was registered with.
1205 :returns: The tags, empty if GitLab reported none.
1206 """
1207 return self._tags
1209 @readonly
1210 def RunnerID(self) -> Nullable[int]:
1211 """
1212 Read-only property to access GitLab's identifier of the runner the job ran on (:attr:`_runnerID`).
1214 :returns: The runner's identifier, or ``None`` while the job hasn't started.
1215 """
1216 return self._runnerID
1218 @readonly
1219 def RunnerDescription(self) -> Nullable[str]:
1220 """
1221 Read-only property to access the description of the runner the job ran on (:attr:`_runnerDescription`).
1223 :returns: The runner's description, e.g. its host, or ``None`` while the job hasn't started.
1224 """
1225 return self._runnerDescription
1227 @classmethod
1228 def _ParseFields(cls, json: JSONObject, path: str) -> dict[str, Any]:
1229 """
1230 Read the fields of a job from the JSON object the GitLab REST API answers with.
1232 :param json: The job, as listed by ``GET /projects/{id}/pipelines/{pipeline_id}/jobs``.
1233 :param path: Position of the job, for an exception's message.
1234 :returns: The values of the job's initializer, by parameter name.
1235 :raises TypeError: If parameter 'json' is not of type :class:`dict`.
1236 :raises GitLabError: If field ``name`` is missing.
1237 :raises GitLabError: If a field holds a value GitLab doesn't document.
1238 """
1239 if not isinstance(json, dict):
1240 ex = TypeError("Parameter 'json' is not of type 'dict'.")
1241 ex.add_note(f"Got type '{getFullyQualifiedName(json)}'.")
1242 raise ex
1244 if (name := json.get("name", None)) is None:
1245 raise GitLabError(f"Field '{path}.name' is missing.")
1247 if (runner := json.get("runner", None)) is None:
1248 runner = {}
1250 return {
1251 "name": name,
1252 "identifier": json.get("id", None),
1253 "stage": json.get("stage", None),
1254 "status": Status.Parse(json.get("status", None)),
1255 "failureReason": json.get("failure_reason", None),
1256 "allowFailure": json.get("allow_failure", None),
1257 "createdAt": _parseISO8601Timestamp(json.get("created_at", None), f"{path}.created_at"),
1258 "startedAt": _parseISO8601Timestamp(json.get("started_at", None), f"{path}.started_at"),
1259 "completedAt": _parseISO8601Timestamp(json.get("finished_at", None), f"{path}.finished_at"),
1260 "queuedDuration": json.get("queued_duration", None),
1261 "url": _parseURL(json.get("web_url", None)),
1262 "tags": json.get("tag_list", None),
1263 "runnerID": runner.get("id", None),
1264 "runnerDescription": runner.get("description", None),
1265 }
1267 @classmethod
1268 def Parse(cls, json: JSONObject, path: str = "job", *, parent: Nullable[CIJobGroup] = None) -> Self:
1269 """
1270 Build a job from the JSON object the GitLab REST API answers with.
1272 :param json: The job, as listed by ``GET /projects/{id}/pipelines/{pipeline_id}/jobs``.
1273 :param path: Optional, position of the job, for an exception's message. Default: ``'job'``.
1274 :param parent: Optional, reference to the group containing the job. Default: ``None``.
1275 :returns: The job.
1276 :raises TypeError: If parameter 'json' is not of type :class:`dict`.
1277 :raises GitLabError: If field ``name`` is missing.
1278 :raises GitLabError: If a field holds a value GitLab doesn't document.
1279 """
1280 return cls(**cls._ParseFields(json, path), parent=parent)
1283@export
1284class MatrixJob(Job, MatrixInstanceMixin):
1285 """
1286 One instance of a job produced by ``parallel:matrix`` or ``parallel``.
1288 GitLab reports a matrix instance as an ordinary job, whose name carries the instance's values:
1289 ``test: [3.14, linux]`` for ``parallel:matrix``, ``test 1/4`` for ``parallel: 4``. :meth:`Pipeline.Parse` reads
1290 those back into :attr:`~pyTooling.CI.MatrixInstanceMixin.Dimensions` and groups the instances below a
1291 :class:`~pyTooling.CI.Matrix`. The job's payload names no dimension of a ``parallel:matrix`` - only the pipeline
1292 file does -, so a dimension's name is the position of its value, ``{"0": "3.14", "1": "linux"}``, until the names
1293 are known. An instance of ``parallel`` carries ``CI_NODE_INDEX`` and ``CI_NODE_TOTAL``, as GitLab names them.
1294 """
1296 _PARENT_TYPE: ClassVar[Nullable[type]] = CIMatrix #: A matrix instance is contained in a matrix.
1298 def __init__(
1299 self,
1300 name: str,
1301 dimensions: Nullable[Mapping[str, Any]] = None,
1302 identifier: Nullable[int] = None,
1303 stage: Nullable[str] = None,
1304 status: Nullable[Status] = None,
1305 failureReason: Nullable[str] = None,
1306 allowFailure: Nullable[bool] = None,
1307 createdAt: Nullable[datetime] = None,
1308 startedAt: Nullable[datetime] = None,
1309 completedAt: Nullable[datetime] = None,
1310 queuedDuration: Nullable[float] = None,
1311 url: Nullable[URL] = None,
1312 tags: Nullable[Iterable[str]] = None,
1313 runnerID: Nullable[int] = None,
1314 runnerDescription: Nullable[str] = None,
1315 *,
1316 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
1317 parent: Nullable[CIMatrix] = None
1318 ) -> None:
1319 """
1320 Initializes one instance of a job produced by a matrix.
1322 :param name: Name of the job, without the instance's values.
1323 :param dimensions: Optional, the dimensions' names and values this instance ran with. Default: ``None``.
1324 :param identifier: Optional, GitLab's identifier of the job. Default: ``None``.
1325 :param stage: Optional, stage the job is listed in. Default: ``None``.
1326 :param status: Optional, state the job is in. Default: ``None``.
1327 :param failureReason: Optional, reason GitLab reports for a failure. Default: ``None``.
1328 :param allowFailure: Optional, ``True``, if the job may fail without failing its pipeline. Default: ``None``.
1329 :param createdAt: Optional, time the job was created. Default: ``None``.
1330 :param startedAt: Optional, time the job started running on a runner. Default: ``None``.
1331 :param completedAt: Optional, time the job finished. Default: ``None``.
1332 :param queuedDuration: Optional, seconds the job waited in GitLab's queue. Default: ``None``.
1333 :param url: Optional, URL of the job in GitLab's web interface. Default: ``None``.
1334 :param tags: Optional, tags the job requested its runner by. Default: ``None``.
1335 :param runnerID: Optional, GitLab's identifier of the runner the job ran on. Default: ``None``.
1336 :param runnerDescription: Optional, description of the runner the job ran on. Default: ``None``.
1337 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
1338 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``.
1339 """
1340 super().__init__(
1341 name, identifier, stage, status, failureReason, allowFailure, createdAt, startedAt, completedAt,
1342 queuedDuration, url, tags, runnerID, runnerDescription, keyValuePairs=keyValuePairs, parent=parent
1343 )
1344 MatrixInstanceMixin.__init__(self, dimensions)
1346 def __str__(self) -> str:
1347 """
1348 Return a string representation of the matrix instance.
1350 :returns: The job's name with the instance's values, as GitLab prints it.
1351 """
1352 return _matrixInstanceName(self._name, self._dimensions)
1354 @classmethod
1355 def Parse(
1356 cls,
1357 json: JSONObject,
1358 path: str = "job",
1359 name: Nullable[str] = None,
1360 dimensions: Nullable[Mapping[str, Any]] = None,
1361 *,
1362 parent: Nullable[CIMatrix] = None
1363 ) -> Self:
1364 """
1365 Build a matrix instance from the JSON object the GitLab REST API answers with.
1367 :param json: The job, as listed by ``GET /projects/{id}/pipelines/{pipeline_id}/jobs``.
1368 :param path: Optional, position of the job, for an exception's message. Default: ``'job'``.
1369 :param name: Optional, the job's name without the instance's values. Default: read from the payload.
1370 :param dimensions: Optional, the dimensions' names and values. Default: read from the payload.
1371 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``.
1372 :returns: The matrix instance.
1373 :raises TypeError: If parameter 'json' is not of type :class:`dict`.
1374 :raises GitLabError: If field ``name`` is missing.
1375 :raises GitLabError: If a field holds a value GitLab doesn't document.
1376 """
1377 fields = cls._ParseFields(json, path)
1378 if name is None:
1379 name, dimensions = _splitMatrixJobName(fields["name"])
1381 fields["name"] = name
1383 return cls(**fields, dimensions=dimensions, parent=parent)
1386@export
1387class DownstreamPipeline(CIWorkflow, StatusMixin, JobMixin, PipelineContentsMixin):
1388 """
1389 A downstream pipeline, started by a trigger job: a child pipeline of the same project, or a multi-project pipeline.
1391 It stands where its trigger job stands - GitLab calls that job a *bridge* - and is named after it, but it reports
1392 the downstream pipeline's own status, URL and times. What GitLab reports about the trigger job - its identifier,
1393 stage, status and URL - is kept beside them. A child pipeline's jobs, matrices and own downstream pipelines are its
1394 contents, if they were read; a multi-project pipeline names its project as :attr:`~pyTooling.CI.Workflow.Reference`.
1395 """
1397 _downstreamID: Nullable[int] #: GitLab's identifier of the downstream pipeline.
1398 _downstreamProjectID: Nullable[int] #: GitLab's identifier of the downstream pipeline's project.
1399 _triggerStatus: Nullable[Status] #: State the trigger job is in.
1400 _triggerURL: Nullable[URL] #: URL of the trigger job in GitLab's web interface.
1402 def __init__(
1403 self,
1404 name: str,
1405 identifier: Nullable[int] = None,
1406 stage: Nullable[str] = None,
1407 status: Nullable[Status] = None,
1408 allowFailure: Nullable[bool] = None,
1409 createdAt: Nullable[datetime] = None,
1410 startedAt: Nullable[datetime] = None,
1411 completedAt: Nullable[datetime] = None,
1412 queuedDuration: Nullable[float] = None,
1413 url: Nullable[URL] = None,
1414 downstreamID: Nullable[int] = None,
1415 downstreamProjectID: Nullable[int] = None,
1416 triggerStatus: Nullable[Status] = None,
1417 triggerURL: Nullable[URL] = None,
1418 *,
1419 reference: Nullable[str] = None,
1420 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
1421 parent: Nullable[CIWorkflow] = None
1422 ) -> None:
1423 """
1424 Initializes a downstream pipeline.
1426 :param name: Name of the trigger job.
1427 :param identifier: Optional, GitLab's identifier of the trigger job. Default: ``None``.
1428 :param stage: Optional, stage the trigger job is listed in. Default: ``None``.
1429 :param status: Optional, state the downstream pipeline is in. Default: ``None``.
1430 :param allowFailure: Optional, ``True``, if the trigger job may fail without failing its pipeline.
1431 Default: ``None``.
1432 :param createdAt: Optional, time the downstream pipeline was created. Default: ``None``.
1433 :param startedAt: Optional, time the downstream pipeline started. Default: ``None``.
1434 :param completedAt: Optional, time the downstream pipeline finished. Default: ``None``.
1435 :param queuedDuration: Optional, seconds the trigger job waited in GitLab's queue. Default: ``None``.
1436 :param url: Optional, URL of the downstream pipeline in GitLab's web interface. Default: ``None``.
1437 :param downstreamID: Optional, GitLab's identifier of the downstream pipeline. Default: ``None``.
1438 :param downstreamProjectID: Optional, GitLab's identifier of the downstream pipeline's project. Default: ``None``.
1439 :param triggerStatus: Optional, state the trigger job is in. Default: ``None``.
1440 :param triggerURL: Optional, URL of the trigger job in GitLab's web interface. Default: ``None``.
1441 :param reference: Optional, the project of a multi-project pipeline, e.g. ``gitlab-org/gitlab``.
1442 Default: ``None``.
1443 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
1444 :param parent: Optional, reference to the pipeline containing the trigger job. Default: ``None``.
1445 :raises TypeError: If parameter 'downstreamID' is not of type :class:`int`.
1446 :raises TypeError: If parameter 'downstreamProjectID' is not of type :class:`int`.
1447 :raises TypeError: If parameter 'triggerStatus' is not of type :class:`Status`.
1448 :raises TypeError: If parameter 'triggerURL' is not of type :class:`~pyTooling.GenericPath.URL.URL`.
1449 """
1450 super().__init__(
1451 name, reference=reference, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt,
1452 keyValuePairs=keyValuePairs, parent=parent
1453 )
1454 StatusMixin.__init__(self, status, None, url)
1455 JobMixin.__init__(self, identifier, stage, allowFailure, queuedDuration)
1457 for parameterName, number in (
1458 ("downstreamID", downstreamID),
1459 ("downstreamProjectID", downstreamProjectID)
1460 ):
1461 if number is not None and (isinstance(number, bool) or not isinstance(number, int)):
1462 ex = TypeError(f"Parameter '{parameterName}' is not of type 'int'.")
1463 ex.add_note(f"Got type '{getFullyQualifiedName(number)}'.")
1464 raise ex
1466 if triggerStatus is not None and not isinstance(triggerStatus, Status):
1467 ex = TypeError("Parameter 'triggerStatus' is not of type 'Status'.")
1468 ex.add_note(f"Got type '{getFullyQualifiedName(triggerStatus)}'.")
1469 raise ex
1471 if triggerURL is not None and not isinstance(triggerURL, URL):
1472 ex = TypeError("Parameter 'triggerURL' is not of type 'URL'.")
1473 ex.add_note(f"Got type '{getFullyQualifiedName(triggerURL)}'.")
1474 raise ex
1476 self._downstreamID = downstreamID
1477 self._downstreamProjectID = downstreamProjectID
1478 self._triggerStatus = triggerStatus
1479 self._triggerURL = triggerURL
1481 @readonly
1482 def DownstreamID(self) -> Nullable[int]:
1483 """
1484 Read-only property to access GitLab's identifier of the downstream pipeline (:attr:`_downstreamID`).
1486 :returns: The identifier, or ``None`` while the trigger job hasn't started a pipeline.
1487 """
1488 return self._downstreamID
1490 @readonly
1491 def DownstreamProjectID(self) -> Nullable[int]:
1492 """
1493 Read-only property to access the identifier of the downstream pipeline's project (:attr:`_downstreamProjectID`).
1495 :returns: The identifier, or ``None`` while the trigger job hasn't started a pipeline.
1496 """
1497 return self._downstreamProjectID
1499 @readonly
1500 def TriggerStatus(self) -> Nullable[Status]:
1501 """
1502 Read-only property to access the state the trigger job is in (:attr:`_triggerStatus`).
1504 It differs from the downstream pipeline's :attr:`~StatusMixin.Status` for a trigger job without
1505 ``strategy: depend``, which succeeds once it started the downstream pipeline, and for a trigger job waiting to be
1506 started by hand, which hasn't started a pipeline yet.
1508 :returns: The state, or ``None`` if GitLab reported none.
1509 """
1510 return self._triggerStatus
1512 @readonly
1513 def TriggerURL(self) -> Nullable[URL]:
1514 """
1515 Read-only property to access the trigger job's URL in GitLab's web interface (:attr:`_triggerURL`).
1517 :returns: The URL, or ``None`` if GitLab reported none.
1518 """
1519 return self._triggerURL
1521 @readonly
1522 def IsMultiProject(self) -> Nullable[bool]:
1523 """
1524 Read-only property to return whether the downstream pipeline belongs to another project.
1526 :returns: ``True`` for a multi-project pipeline, ``False`` for a child pipeline, or ``None`` if either project
1527 isn't known.
1528 """
1529 if self._downstreamProjectID is None or self._pipeline is None or self._pipeline.ProjectID is None:
1530 return None
1532 return self._downstreamProjectID != self._pipeline.ProjectID
1534 @staticmethod
1535 def _CheckPayload(payload: Nullable[JSONObject], path: str) -> Nullable[JSONObject]:
1536 """
1537 Check the downstream pipeline's payloads, and return the downstream pipeline's own payload.
1539 :param payload: The downstream pipeline's payloads, or ``None``.
1540 :param path: Position of the trigger job, for an exception's message.
1541 :returns: The downstream pipeline, as returned by ``GET /projects/{id}/pipelines/{pipeline_id}``, or
1542 ``None``, if it wasn't read.
1543 :raises TypeError: If parameter 'payload' is not of type :class:`dict`.
1544 """
1545 if payload is None:
1546 return None
1547 elif not isinstance(payload, dict):
1548 ex = TypeError(f"Payload of '{path}' is not of type 'dict'.")
1549 ex.add_note(f"Got type '{getFullyQualifiedName(payload)}'.")
1550 raise ex
1552 return payload.get("pipeline", None)
1554 @classmethod
1555 def _ParseFields(cls, bridge: JSONObject, pipeline: Nullable[JSONObject], path: str) -> dict[str, Any]:
1556 """
1557 Read the fields of a downstream pipeline from the JSON objects the GitLab REST API answers with.
1559 The downstream pipeline's status, URL and times are taken from its own payload, if it was read; otherwise from
1560 the summary GitLab gives in the trigger job's ``downstream_pipeline``, which has no start, and whose last update
1561 is the pipeline's completion once it ended.
1563 :param bridge: The trigger job, as listed by ``GET /projects/{id}/pipelines/{pipeline_id}/bridges``.
1564 :param pipeline: The downstream pipeline, as returned by ``GET /projects/{id}/pipelines/{pipeline_id}``, or
1565 ``None``, if it wasn't read.
1566 :param path: Position of the trigger job, for an exception's message.
1567 :returns: The values of the initializer, by parameter name.
1568 :raises TypeError: If parameter 'bridge' is not of type :class:`dict`.
1569 :raises TypeError: If parameter 'pipeline' is not of type :class:`dict`.
1570 :raises GitLabError: If field ``name`` is missing.
1571 :raises GitLabError: If a field holds a value GitLab doesn't document.
1572 """
1573 if not isinstance(bridge, dict): 1573 ↛ 1574line 1573 didn't jump to line 1574 because the condition on line 1573 was never true
1574 ex = TypeError("Parameter 'bridge' is not of type 'dict'.")
1575 ex.add_note(f"Got type '{getFullyQualifiedName(bridge)}'.")
1576 raise ex
1578 if pipeline is not None and not isinstance(pipeline, dict):
1579 ex = TypeError(f"Downstream pipeline of '{path}' is not of type 'dict'.")
1580 ex.add_note(f"Got type '{getFullyQualifiedName(pipeline)}'.")
1581 raise ex
1583 if (name := bridge.get("name", None)) is None: 1583 ↛ 1584line 1583 didn't jump to line 1584 because the condition on line 1583 was never true
1584 raise GitLabError(f"Field '{path}.name' is missing.")
1586 if (summary := bridge.get("downstream_pipeline", None)) is None:
1587 summary = {}
1589 if pipeline is None:
1590 ownPath = f"{path}.downstream_pipeline"
1591 status = Status.Parse(summary.get("status", None))
1592 createdAt = _parseISO8601Timestamp(summary.get("created_at", None), f"{ownPath}.created_at")
1593 startedAt = None
1594 completedAt = None
1595 if status is not None and status.ToOutcome() is not None:
1596 completedAt = _parseISO8601Timestamp(summary.get("updated_at", None), f"{ownPath}.updated_at")
1597 url = _parseURL(summary.get("web_url", None))
1598 else:
1599 ownPath = f"{path}.pipeline"
1600 status = Status.Parse(pipeline.get("status", None))
1601 createdAt = _parseISO8601Timestamp(pipeline.get("created_at", None), f"{ownPath}.created_at")
1602 startedAt = _parseISO8601Timestamp(pipeline.get("started_at", None), f"{ownPath}.started_at")
1603 completedAt = _parseISO8601Timestamp(pipeline.get("finished_at", None), f"{ownPath}.finished_at")
1604 url = _parseURL(pipeline.get("web_url", None))
1606 reference = None
1607 if (projectID := bridge.get("pipeline", {}).get("project_id", None)) is not None and url is not None:
1608 if summary.get("project_id", None) not in (None, projectID):
1609 reference = str(url.Path).strip("/").partition("/-/")[0]
1611 return {
1612 "name": name,
1613 "identifier": bridge.get("id", None),
1614 "stage": bridge.get("stage", None),
1615 "status": status,
1616 "allowFailure": bridge.get("allow_failure", None),
1617 "createdAt": createdAt,
1618 "startedAt": startedAt,
1619 "completedAt": completedAt,
1620 "queuedDuration": bridge.get("queued_duration", None),
1621 "url": url,
1622 "downstreamID": summary.get("id", None),
1623 "downstreamProjectID": summary.get("project_id", None),
1624 "triggerStatus": Status.Parse(bridge.get("status", None)),
1625 "triggerURL": _parseURL(bridge.get("web_url", None)),
1626 "reference": reference,
1627 }
1629 def _AddContents(self, payload: Nullable[JSONObject], path: str) -> None:
1630 """
1631 Add the downstream pipeline's jobs and trigger jobs from its payloads.
1633 :param payload: The downstream pipeline's payloads - ``jobs``, ``bridges``, ``downstream`` -, or ``None``.
1634 :param path: Position of the trigger job, for an exception's message.
1635 :raises GitLabError: If a field holds a value GitLab doesn't document.
1636 """
1637 if payload is None:
1638 return
1640 if (jobs := payload.get("jobs", None)) is not None: 1640 ↛ 1643line 1640 didn't jump to line 1643 because the condition on line 1640 was always true
1641 self._AddJobs(jobs, f"{path}.jobs")
1643 if (bridges := payload.get("bridges", None)) is not None:
1644 self._AddBridges(bridges, payload.get("downstream", None), f"{path}.bridges")
1646 @classmethod
1647 def Parse(
1648 cls,
1649 bridge: JSONObject,
1650 payload: Nullable[JSONObject] = None,
1651 path: str = "bridge",
1652 *,
1653 parent: Nullable[CIWorkflow] = None
1654 ) -> Self:
1655 """
1656 Build a downstream pipeline from the JSON objects the GitLab REST API answers with.
1658 :param bridge: The trigger job, as listed by ``GET /projects/{id}/pipelines/{pipeline_id}/bridges``.
1659 :param payload: Optional, the downstream pipeline's payloads - ``pipeline``, ``jobs``, ``bridges`` and
1660 ``downstream`` in the shape of :meth:`Pipeline.Parse`'s parameters. Default: ``None``.
1661 :param path: Optional, position of the trigger job, for an exception's message. Default: ``'bridge'``.
1662 :param parent: Optional, reference to the pipeline containing the trigger job. Default: ``None``.
1663 :returns: The downstream pipeline, with its contents attached.
1664 :raises TypeError: If parameter 'bridge' is not of type :class:`dict`.
1665 :raises TypeError: If parameter 'payload' is not of type :class:`dict`.
1666 :raises GitLabError: If field ``name`` is missing.
1667 :raises GitLabError: If a field holds a value GitLab doesn't document.
1668 """
1669 pipeline = cls._CheckPayload(payload, path)
1670 result = cls(**cls._ParseFields(bridge, pipeline, path), parent=parent)
1671 result._AddContents(payload, path)
1673 return result
1676@export
1677class MatrixDownstreamPipeline(DownstreamPipeline, MatrixInstanceMixin):
1678 """
1679 One instance of a trigger job produced by ``parallel:matrix``, and the downstream pipeline it started.
1681 It is named like a :class:`MatrixJob`, as GitLab names it: ``deploy: [staging]``.
1682 """
1684 _PARENT_TYPE: ClassVar[Nullable[type]] = CIMatrix #: A matrix instance is contained in a matrix.
1686 def __init__(
1687 self,
1688 name: str,
1689 dimensions: Nullable[Mapping[str, Any]] = None,
1690 identifier: Nullable[int] = None,
1691 stage: Nullable[str] = None,
1692 status: Nullable[Status] = None,
1693 allowFailure: Nullable[bool] = None,
1694 createdAt: Nullable[datetime] = None,
1695 startedAt: Nullable[datetime] = None,
1696 completedAt: Nullable[datetime] = None,
1697 queuedDuration: Nullable[float] = None,
1698 url: Nullable[URL] = None,
1699 downstreamID: Nullable[int] = None,
1700 downstreamProjectID: Nullable[int] = None,
1701 triggerStatus: Nullable[Status] = None,
1702 triggerURL: Nullable[URL] = None,
1703 *,
1704 reference: Nullable[str] = None,
1705 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
1706 parent: Nullable[CIMatrix] = None
1707 ) -> None:
1708 """
1709 Initializes one instance of a trigger job produced by a matrix.
1711 :param name: Name of the trigger job, without the instance's values.
1712 :param dimensions: Optional, the dimensions' names and values this instance ran with. Default: ``None``.
1713 :param identifier: Optional, GitLab's identifier of the trigger job. Default: ``None``.
1714 :param stage: Optional, stage the trigger job is listed in. Default: ``None``.
1715 :param status: Optional, state the downstream pipeline is in. Default: ``None``.
1716 :param allowFailure: Optional, ``True``, if the trigger job may fail without failing its pipeline.
1717 Default: ``None``.
1718 :param createdAt: Optional, time the downstream pipeline was created. Default: ``None``.
1719 :param startedAt: Optional, time the downstream pipeline started. Default: ``None``.
1720 :param completedAt: Optional, time the downstream pipeline finished. Default: ``None``.
1721 :param queuedDuration: Optional, seconds the trigger job waited in GitLab's queue. Default: ``None``.
1722 :param url: Optional, URL of the downstream pipeline in GitLab's web interface. Default: ``None``.
1723 :param downstreamID: Optional, GitLab's identifier of the downstream pipeline. Default: ``None``.
1724 :param downstreamProjectID: Optional, GitLab's identifier of the downstream pipeline's project. Default: ``None``.
1725 :param triggerStatus: Optional, state the trigger job is in. Default: ``None``.
1726 :param triggerURL: Optional, URL of the trigger job in GitLab's web interface. Default: ``None``.
1727 :param reference: Optional, the project of a multi-project pipeline. Default: ``None``.
1728 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
1729 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``.
1730 """
1731 super().__init__(
1732 name, identifier, stage, status, allowFailure, createdAt, startedAt, completedAt, queuedDuration, url,
1733 downstreamID, downstreamProjectID, triggerStatus, triggerURL, reference=reference, keyValuePairs=keyValuePairs,
1734 parent=parent
1735 )
1736 MatrixInstanceMixin.__init__(self, dimensions)
1738 def __str__(self) -> str:
1739 """
1740 Return a string representation of the matrix instance.
1742 :returns: The trigger job's name with the instance's values, as GitLab prints it.
1743 """
1744 return _matrixInstanceName(self._name, self._dimensions)
1746 @classmethod
1747 def Parse(
1748 cls,
1749 bridge: JSONObject,
1750 payload: Nullable[JSONObject] = None,
1751 path: str = "bridge",
1752 name: Nullable[str] = None,
1753 dimensions: Nullable[Mapping[str, Any]] = None,
1754 *,
1755 parent: Nullable[CIMatrix] = None
1756 ) -> Self:
1757 """
1758 Build a matrix instance of a trigger job from the JSON objects the GitLab REST API answers with.
1760 :param bridge: The trigger job, as listed by ``GET /projects/{id}/pipelines/{pipeline_id}/bridges``.
1761 :param payload: Optional, the downstream pipeline's payloads, as for :meth:`DownstreamPipeline.Parse`.
1762 Default: ``None``.
1763 :param path: Optional, position of the trigger job, for an exception's message. Default: ``'bridge'``.
1764 :param name: Optional, the trigger job's name without the instance's values. Default: read from the payload.
1765 :param dimensions: Optional, the dimensions' names and values. Default: read from the payload.
1766 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``.
1767 :returns: The matrix instance, with its contents attached.
1768 :raises TypeError: If parameter 'bridge' is not of type :class:`dict`.
1769 :raises TypeError: If parameter 'payload' is not of type :class:`dict`.
1770 :raises GitLabError: If field ``name`` is missing.
1771 :raises GitLabError: If a field holds a value GitLab doesn't document.
1772 """
1773 fields = cls._ParseFields(bridge, cls._CheckPayload(payload, path), path)
1774 if name is None: 1774 ↛ 1775line 1774 didn't jump to line 1775 because the condition on line 1774 was never true
1775 name, dimensions = _splitMatrixJobName(fields["name"])
1777 fields["name"] = name
1778 result = cls(**fields, dimensions=dimensions, parent=parent)
1779 result._AddContents(payload, path)
1781 return result