Coverage for pyTooling/GitLab/Tracing.py: 97%
201 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"""
32Read the timing of a GitLab CI/CD pipeline into a software execution trace.
34A pipeline becomes a :class:`~pyTooling.Tracing.Trace`, and each job a :class:`~pyTooling.Tracing.Span`. The time a job
35waited for a runner is a separate timespan in front of the job. The instances of a matrix are grouped below a timespan
36named like the job, and the jobs of a child pipeline below a timespan named like the trigger job.
38.. code-block:: python
40 from os import getenv
41 from pyTooling.GitLab.Tracing import PipelineReader
43 reader = PipelineReader("gitlab-org/gitlab-development-kit", token=getenv("GITLAB_TOKEN"))
44 trace = reader.ReadPipeline(2954836704)
45 print("\\n".join(trace.Format()))
47.. hint::
49 See :ref:`high-level help <VIS/PipelineTrace>` for explanations and usage examples.
50"""
51from datetime import datetime, timedelta
52from typing import Any, ClassVar, Iterable, Mapping, Optional as Nullable, Self, Union
54from pyTooling.CI import Base, JobGroup, JSONObject, Matrix
55from pyTooling.Common import getFullyQualifiedName
56from pyTooling.Decorators import export
57from pyTooling.GitLab import DownstreamPipeline, GitLabError, Job, MatrixJob, Pipeline, ProjectClient, Status
58from pyTooling.MetaClasses import ExtendedType
59from pyTooling.REST import RESTError
60from pyTooling.Tracing import Span
61from pyTooling.Tracing.CI import OTLP, Result
62from pyTooling.Tracing.CI import JobSpan as CIJobSpan, MatrixSpan as CIMatrixSpan
63from pyTooling.Tracing.CI import PipelineTrace as CIPipelineTrace, QueuedSpan as CIQueuedSpan
64from pyTooling.Tracing.CI import WorkflowSpan as CIWorkflowSpan
67@export
68class GitLab(metaclass=ExtendedType, slots=True):
69 """
70 Attribute keys naming what only GitLab CI/CD reports, beside the keys of :class:`~pyTooling.Tracing.CI.OTLP`.
72 The nesting mirrors the key itself, as it does there: :attr:`GitLab.Runner.Tags` spells ``'gitlab.runner.tags'``.
73 """
75 Status: ClassVar[str] = "gitlab.status" #: GitLab's own status, next to the result it maps to.
77 class Pipeline(metaclass=ExtendedType, slots=True):
78 """Attribute keys naming a pipeline."""
80 IID: ClassVar[str] = "gitlab.pipeline.iid" #: The pipeline's number within its project.
81 Source: ClassVar[str] = "gitlab.pipeline.source" #: What started the pipeline, e.g. ``'push'``.
83 class Project(metaclass=ExtendedType, slots=True):
84 """Attribute keys naming a project."""
86 ID: ClassVar[str] = "gitlab.project.id" #: GitLab's identifier of the project.
88 class Job(metaclass=ExtendedType, slots=True):
89 """Attribute keys naming a job."""
91 Stage: ClassVar[str] = "gitlab.job.stage" #: The stage the job is listed in.
92 AllowFailure: ClassVar[str] = "gitlab.job.allow_failure" #: Whether the job may fail, its pipeline not.
93 FailureReason: ClassVar[str] = "gitlab.job.failure_reason" #: Why the job failed, e.g. ``'script_failure'``.
95 class Matrix(metaclass=ExtendedType, slots=True):
96 """Attribute keys naming a matrix."""
98 Dimensions: ClassVar[str] = "gitlab.matrix.dimensions" #: One instance's values, e.g. ``['3.14', 'linux']``.
100 class Runner(metaclass=ExtendedType, slots=True):
101 """Attribute keys naming the runner a job ran on."""
103 ID: ClassVar[str] = "gitlab.runner.id" #: GitLab's identifier of the runner.
104 Tags: ClassVar[str] = "gitlab.runner.tags" #: The tags the job asked for, e.g. ``['saas-linux-small-amd64']``.
106 class Downstream(metaclass=ExtendedType, slots=True):
107 """Attribute keys naming a downstream pipeline."""
109 ID: ClassVar[str] = "gitlab.downstream.id" #: GitLab's identifier of the downstream pipeline.
110 ProjectID: ClassVar[str] = "gitlab.downstream.project_id" #: GitLab's identifier of its project.
112 class Trigger(metaclass=ExtendedType, slots=True):
113 """Attribute keys naming the trigger job, which started a downstream pipeline."""
115 ID: ClassVar[str] = "gitlab.trigger.id" #: GitLab's identifier of the trigger job.
116 Status: ClassVar[str] = "gitlab.trigger.status" #: The trigger job's status.
117 URL: ClassVar[str] = "gitlab.trigger.url" #: The trigger job's address.
120@export
121def runnerCategory(span: Span) -> str:
122 """
123 Span category naming the runner a timespan ran on, for :class:`~pyTooling.Tracing.Render.GanttLayout`.
125 The runner is the first tag of the timespan or its nearest ancestor, e.g. ``saas-linux-large-amd64``; a job without
126 tags is categorized by the runner's description, :data:`OTLP.CICD.Worker.Name <pyTooling.Tracing.CI.OTLP>`.
128 :param span: The timespan.
129 :returns: The category, or the empty string if neither the timespan nor an ancestor names its runner.
130 """
131 current: Nullable[Span] = span
132 while current is not None:
133 if GitLab.Runner.Tags in current and len(tags := current[GitLab.Runner.Tags]) > 0:
134 return str(tags[0])
135 elif OTLP.CICD.Worker.Name in current:
136 return str(current[OTLP.CICD.Worker.Name])
138 current = current.Parent
140 return ""
143@export
144class GitLabTimespanMixin(metaclass=ExtendedType, mixin=True):
145 """
146 Mixin-class for a timespan built from :mod:`pyTooling.GitLab`'s model of a pipeline.
148 It holds what every flavour needs to read that model: the result an element's outcome is, and the span a group's
149 contents have. Everything else the model answers itself - the order its elements are in, and the times of a
150 downstream pipeline, which contain the jobs of its child pipeline.
151 """
153 @staticmethod
154 def _Result(element: Base) -> Nullable[Result]:
155 """
156 Return the CI/CD result of an element of the model.
158 The result is the member of the same value as the element's :attr:`~pyTooling.CI.Base.Outcome`. A GitLab
159 status is mapped onto an outcome by :meth:`Status.ToOutcome <pyTooling.GitLab.Status.ToOutcome>`.
161 :param element: The pipeline, job or downstream pipeline.
162 :returns: The CI/CD result, or ``None`` while the element hasn't ended.
163 """
164 if element.Outcome is None: 164 ↛ 165line 164 didn't jump to line 165 because the condition on line 164 was never true
165 return None
167 return Result(element.Outcome.value)
169 @staticmethod
170 def _NotBefore(end: Nullable[datetime], begin: datetime) -> Nullable[datetime]:
171 """
172 Clamp an end time so it doesn't precede a begin time.
174 :param end: Optional, the end time. Default: ``None``.
175 :param begin: The begin time.
176 :returns: The end time, but not before the begin time.
177 """
178 return None if end is None else max(end, begin)
180 @staticmethod
181 def _Value(status: Nullable[Status]) -> Nullable[str]:
182 """
183 Return the value of a status, as GitLab reports it.
185 :param status: Optional, the status. Default: ``None``.
186 :returns: The status' value, or ``None`` if there is none.
187 """
188 return None if status is None else status.value
190 @staticmethod
191 def _NotBeforeParent(begin: datetime, parent: Span) -> datetime:
192 """
193 Clamp a begin time so it doesn't precede the begin of the parent timespan.
195 :mod:`pyTooling.Tracing` requires a timespan to lie within its parent. GitLab reports a job's times in
196 milliseconds, but the time it was queued as a duration, so a waiting timespan may reach a moment before the
197 timespan holding it.
199 :param begin: The begin time.
200 :param parent: The parent timespan.
201 :returns: The begin time, but not before the parent's.
202 """
203 if parent.StartTime is None or begin >= parent.StartTime: 203 ↛ 206line 203 didn't jump to line 206 because the condition on line 203 was always true
204 return begin
206 return parent.StartTime
208 @staticmethod
209 def _Earliest(times: Iterable[datetime]) -> Nullable[datetime]:
210 """
211 Return the earliest of some times.
213 :param times: The times.
214 :returns: The earliest time, or ``None`` if there is none.
215 """
216 return min(times, default=None)
218 @staticmethod
219 def _QueueBegin(job: Job) -> Nullable[datetime]:
220 """
221 Return when a job started waiting for a runner, by the time GitLab reports it was queued.
223 :param job: The job.
224 :returns: The time, or ``None`` if the job hasn't started, was skipped, or wasn't queued.
225 """
226 if job.StartedAt is None or job.QueuedDuration is None or job.QueuedDuration <= 0.0 or job.Status is Status.Skipped:
227 return None
229 return job.StartedAt - timedelta(seconds=job.QueuedDuration)
231 @classmethod
232 def _GroupTimespan(cls, group: JobGroup) -> tuple[Nullable[datetime], Nullable[datetime]]:
233 """
234 Return the timespan of a group, which holds the time its jobs were queued too.
236 A group begins when it or its first element started, or earlier, when a job was queued for a runner before. A group
237 reporting no start - a downstream pipeline whose payload wasn't read - begins at its creation. A group nothing of
238 which started - e.g. a matrix of jobs waiting to be started by hand - has nothing to place on a timeline.
240 :param group: The group - a pipeline, a downstream pipeline or a matrix.
241 :returns: When the timespan begins and ends, each ``None`` if there is nothing to place on a timeline
242 respectively while it is still running.
243 """
244 begins = [queueBegin for job in group.IterateJobs() if (queueBegin := cls._QueueBegin(job)) is not None]
245 for started in (group.StartedAt, group.ContentsStartedAt):
246 if started is not None:
247 begins.append(started)
249 if len(begins) == 0:
250 if not group.IsReported or group.CreatedAt is None:
251 return None, None
253 begins.append(group.CreatedAt)
255 begin = min(begins)
257 return begin, cls._NotBefore(group.CompletedAt, begin)
259 @classmethod
260 def _AddContents(cls, group: JobGroup, parent: Span) -> None:
261 """
262 Add the jobs, matrices and downstream pipelines of a group to a timespan, in the order they were created.
264 :param group: The group - a pipeline, a downstream pipeline or a matrix.
265 :param parent: The timespan the group's contents are added to.
266 """
267 for item in group.IterateElements():
268 if isinstance(item, Job):
269 JobSpan.FromJob(item, parent)
270 elif isinstance(item, Matrix):
271 MatrixSpan.FromGroup(item, parent)
272 else:
273 DownstreamSpan.FromDownstreamPipeline(item, parent)
276@export
277class QueuedSpan(CIQueuedSpan, GitLabTimespanMixin):
278 """The timespan a GitLab job waited for a runner, in front of the job's own timespan."""
280 @classmethod
281 def FromJob(cls, job: Job, begin: datetime, started: Nullable[datetime], parent: Span) -> Self:
282 """
283 Build the timespan a job waited for a runner.
285 :param job: The job that waited.
286 :param begin: When the job started waiting for a runner.
287 :param started: Optional, when the job started, or ``None`` while it is still waiting.
288 :param parent: The timespan of the trace, matrix or downstream pipeline containing the job.
289 :returns: The waiting timespan.
290 """
291 begin = cls._NotBeforeParent(begin, parent)
293 return cls(
294 f"{job!s} (queued)",
295 begin,
296 cls._NotBefore(started, begin),
297 parent=parent,
298 taskName=job.QualifiedName,
299 attributes={GitLab.Runner.Tags: list(job.Tags)}
300 )
303@export
304class JobSpan(CIJobSpan, GitLabTimespanMixin):
305 """The timespan of a GitLab job, from the moment it started on a runner until it completed."""
307 @classmethod
308 def FromJob(cls, job: Job, parent: Span) -> Nullable[Self]:
309 """
310 Build the timespans of a job: the time it waited for a runner, and the job itself.
312 GitLab creates every job of a pipeline when the pipeline is created, so the time between a job's creation and
313 its start is mostly the time it waited for the jobs it depends on. The waiting timespan is therefore the time
314 GitLab reports the job was queued for a runner - :attr:`~pyTooling.GitLab.JobMixin.QueuedDuration` - right in
315 front of its start. A job not yet started has no timespan.
317 :param job: The job.
318 :param parent: The timespan of the trace, matrix or downstream pipeline containing the job.
319 :returns: The job's timespan, or ``None`` if the job hasn't started.
320 """
321 started = job.StartedAt
322 completed = job.CompletedAt
324 if job.Status is Status.Skipped:
325 # A skipped job neither waited for a runner nor ran on one: it is placed at the moment it was skipped.
326 if (skippedAt := started if started is not None else completed) is None: 326 ↛ 327line 326 didn't jump to line 327 because the condition on line 326 was never true
327 return None
329 begin = cls._NotBeforeParent(skippedAt, parent)
330 end = begin
331 elif started is None:
332 return None
333 else:
334 if (queueBegin := cls._QueueBegin(job)) is not None:
335 QueuedSpan.FromJob(job, queueBegin, started, parent)
337 begin = cls._NotBeforeParent(started, parent)
338 end = cls._NotBefore(completed, begin)
340 attributes: dict[str, Any] = {
341 GitLab.Status: cls._Value(job.Status),
342 GitLab.Job.Stage: job.Stage,
343 GitLab.Job.AllowFailure: job.AllowFailure,
344 GitLab.Job.FailureReason: job.FailureReason,
345 GitLab.Runner.ID: job.RunnerID,
346 GitLab.Runner.Tags: list(job.Tags)
347 }
348 if isinstance(job, MatrixJob):
349 attributes[GitLab.Matrix.Dimensions] = [str(value) for value in job.Dimensions.values()]
351 return cls(
352 str(job),
353 begin,
354 end,
355 parent=parent,
356 taskName=job.QualifiedName,
357 runID=None if job.ID is None else str(job.ID),
358 runURL=None if job.URL is None else str(job.URL),
359 result=cls._Result(job),
360 workerName=job.RunnerDescription,
361 attributes=attributes
362 )
365@export
366class MatrixSpan(CIMatrixSpan, GitLabTimespanMixin):
367 """The instances a matrix produced, grouped into one timespan."""
369 @classmethod
370 def FromGroup(cls, group: Matrix, parent: Span) -> Nullable[Self]:
371 """
372 Build the timespan of a matrix and everything below it.
374 :param group: The matrix.
375 :param parent: The timespan containing the matrix.
376 :returns: The matrix' timespan, or ``None`` if no instance of it started.
377 """
378 begin, end = cls._GroupTimespan(group)
379 if begin is None:
380 return None
382 begin = cls._NotBeforeParent(begin, parent)
383 span = cls(str(group), begin, cls._NotBefore(end, begin), parent=parent, taskName=group.QualifiedName)
384 cls._AddContents(group, span)
386 return span
389@export
390class DownstreamSpan(CIWorkflowSpan, GitLabTimespanMixin):
391 """The jobs of a downstream pipeline, grouped into one timespan named like its trigger job."""
393 @classmethod
394 def FromDownstreamPipeline(cls, pipeline: DownstreamPipeline, parent: Span) -> Nullable[Self]:
395 """
396 Build the timespan of a downstream pipeline and everything below it.
398 :param pipeline: The downstream pipeline.
399 :param parent: The timespan containing the trigger job.
400 :returns: The downstream pipeline's timespan, or ``None`` if it hasn't been created, or nothing of it
401 started.
402 """
403 begin, end = cls._GroupTimespan(pipeline)
404 if begin is None:
405 return None
407 begin = cls._NotBeforeParent(begin, parent)
408 span = cls(
409 str(pipeline),
410 begin,
411 cls._NotBefore(end, begin),
412 parent=parent,
413 taskName=pipeline.QualifiedName,
414 attributes={
415 OTLP.CICD.Pipeline.Task.Run.ID: None if pipeline.DownstreamID is None else str(pipeline.DownstreamID),
416 OTLP.CICD.Pipeline.Task.Run.URL.Full: None if pipeline.URL is None else str(pipeline.URL),
417 OTLP.CICD.Pipeline.Task.Run.Result: cls._Result(pipeline),
418 GitLab.Status: cls._Value(pipeline.Status),
419 GitLab.Job.Stage: pipeline.Stage,
420 GitLab.Downstream.ID: pipeline.DownstreamID,
421 GitLab.Downstream.ProjectID: pipeline.DownstreamProjectID,
422 GitLab.Trigger.ID: pipeline.ID,
423 GitLab.Trigger.Status: cls._Value(pipeline.TriggerStatus),
424 GitLab.Trigger.URL: None if pipeline.TriggerURL is None else str(pipeline.TriggerURL)
425 }
426 )
427 cls._AddContents(pipeline, span)
429 return span
432@export
433class PipelineTrace(CIPipelineTrace, GitLabTimespanMixin):
434 """A GitLab CI/CD pipeline as a trace."""
436 @classmethod
437 def Parse(
438 cls,
439 pipeline: JSONObject,
440 jobs: Nullable[Iterable[JSONObject]] = None,
441 bridges: Nullable[Iterable[JSONObject]] = None,
442 downstream: Nullable[Mapping[Union[int, str], JSONObject]] = None
443 ) -> Self:
444 """
445 Build the trace of a pipeline from the payloads the GitLab REST API answers with.
447 The payloads are read into a :class:`~pyTooling.GitLab.Pipeline` first, so the tree - the matrices, the jobs
448 and the downstream pipelines - is reconstructed by the model rather than here, and :meth:`FromPipeline` turns it
449 into timespans.
451 :param pipeline: The pipeline, as returned by ``GET /projects/{id}/pipelines/{pipeline_id}``.
452 :param jobs: Optional, the pipeline's jobs, as listed by ``GET .../pipelines/{pipeline_id}/jobs``.
453 :param bridges: Optional, the pipeline's trigger jobs, as listed by
454 ``GET .../pipelines/{pipeline_id}/bridges``.
455 :param downstream: Optional, the downstream pipelines' payloads, by the downstream pipeline's identifier, as
456 for :meth:`Pipeline.Parse <pyTooling.GitLab.Pipeline.Parse>`. Default: ``None``.
457 :returns: The pipeline as a trace.
458 :raises TypeError: If parameter 'pipeline' is not of type :class:`dict`.
459 :raises GitLabError: If a mandatory field is missing, or a field holds a value GitLab doesn't document.
460 """
461 return cls.FromPipeline(Pipeline.Parse(pipeline, jobs, bridges, downstream))
463 @classmethod
464 def FromPipeline(cls, pipeline: Pipeline) -> Self:
465 """
466 Build the trace of a pipeline, as :mod:`pyTooling.GitLab` models it.
468 * The pipeline becomes the trace, widened where a job started earlier or completed later than the pipeline
469 reports.
470 * A matrix and a downstream pipeline become a timespan holding the jobs below them.
471 * A job becomes a timespan from its start to its completion, preceded by ``<job> (queued)`` for the time GitLab
472 reports it was queued for a runner. A job that hasn't started has no timespan; a skipped job no waiting
473 timespan.
474 * Every timespan is classified by :attr:`CI.Span.Kind <pyTooling.Tracing.CI.CI>` and carries the OpenTelemetry
475 CI/CD attributes, GitLab's status (:class:`GitLab`), and for jobs the stage, the runner and its tags.
477 :param pipeline: The pipeline.
478 :returns: The pipeline as a trace.
479 :raises TypeError: If parameter 'pipeline' is not of type :class:`~pyTooling.GitLab.Pipeline`.
480 """
481 if not isinstance(pipeline, Pipeline):
482 ex = TypeError("Parameter 'pipeline' is not of type 'Pipeline'.")
483 ex.add_note(f"Got type '{getFullyQualifiedName(pipeline)}'.")
484 raise ex
486 beginTime = pipeline.StartedAt if pipeline.StartedAt is not None else pipeline.CreatedAt
487 endTime = cls._NotBefore(pipeline.CompletedAt, beginTime) if beginTime is not None else pipeline.CompletedAt
489 # a job's queue may begin before the pipeline reports itself started, and a job may complete after it finished
490 jobsBegin = cls._Earliest(
491 queueBegin for job in pipeline.IterateJobs() if (queueBegin := cls._QueueBegin(job)) is not None
492 )
493 jobsEnd = pipeline.ContentsCompletedAt
494 if (contentsBegin := pipeline.ContentsStartedAt) is not None:
495 jobsBegin = contentsBegin if jobsBegin is None else min(jobsBegin, contentsBegin)
497 if jobsBegin is not None:
498 beginTime = jobsBegin if beginTime is None else min(beginTime, jobsBegin)
500 if endTime is not None and jobsEnd is not None:
501 endTime = max(endTime, jobsEnd)
503 trace = cls(
504 pipeline.Name,
505 beginTime,
506 endTime,
507 runID=None if pipeline.ID is None else str(pipeline.ID),
508 runURL=None if pipeline.URL is None else str(pipeline.URL),
509 result=cls._Result(pipeline),
510 reference=pipeline.GitReference,
511 revision=pipeline.SHA,
512 attributes={
513 GitLab.Status: cls._Value(pipeline.Status),
514 GitLab.Pipeline.IID: pipeline.IID,
515 GitLab.Pipeline.Source: None if pipeline.Source is None else pipeline.Source.value,
516 GitLab.Project.ID: pipeline.ProjectID
517 }
518 )
519 cls._AddContents(pipeline, trace)
521 return trace
524@export
525class PipelineReader(ProjectClient):
526 """
527 Reads pipelines of a GitLab project through the GitLab REST API and converts them into traces.
529 A token is needed for a private project - inside a pipeline, ``CI_JOB_TOKEN`` doesn't suffice, a token with the
530 ``read_api`` scope does. A public project's pipelines are read without one.
531 """
533 def ReadPayloads(self, pipelineID: int, downstream: bool = True) -> JSONObject:
534 """
535 Read a pipeline's payloads: the pipeline, its jobs, its trigger jobs, and those of its child pipelines.
537 The result has the shape of :meth:`Pipeline.Parse <pyTooling.GitLab.Pipeline.Parse>`'s parameters, so it is read
538 with :pycode:`Pipeline.Parse(**payloads)`. A child pipeline - a downstream pipeline of the same project - is read
539 with its own child pipelines; a multi-project pipeline isn't, as it belongs to a project this reader wasn't
540 made for.
542 :param pipelineID: The pipeline's identifier.
543 :param downstream: Optional, ``True``, if the child pipelines are read too. Default: ``True``.
544 :returns: The payloads - ``pipeline``, ``jobs``, ``bridges`` and ``downstream``.
545 :raises ValueError: If parameter 'pipelineID' is ``None``.
546 :raises TypeError: If parameter 'pipelineID' is not of type :class:`int`.
547 :raises ValueError: If parameter 'pipelineID' isn't positive.
548 :raises TypeError: If parameter 'downstream' is not of type :class:`bool`.
549 :raises RESTError: If a request fails, or GitLab's answer isn't JSON of the expected shape.
550 :raises GitLabError: If GitLab's answer lacks a mandatory field.
551 """
552 if pipelineID is None:
553 raise ValueError("Parameter 'pipelineID' is None.")
554 elif isinstance(pipelineID, bool) or not isinstance(pipelineID, int):
555 ex = TypeError("Parameter 'pipelineID' is not of type 'int'.")
556 ex.add_note(f"Got type '{getFullyQualifiedName(pipelineID)}'.")
557 raise ex
558 elif pipelineID <= 0:
559 ex = ValueError("Parameter 'pipelineID' isn't positive.")
560 ex.add_note(f"Got value '{pipelineID}'.")
561 raise ex
563 if not isinstance(downstream, bool):
564 ex = TypeError("Parameter 'downstream' is not of type 'bool'.")
565 ex.add_note(f"Got type '{getFullyQualifiedName(downstream)}'.")
566 raise ex
568 pipelinePath = f"{self.ProjectResourcePath}/pipelines/{pipelineID}"
569 pipeline, _ = self.GetJSONObject(pipelinePath)
570 if not isinstance(pipeline, dict) or (projectID := pipeline.get("project_id", None)) is None:
571 raise GitLabError(f"Field 'project_id' is missing in the answer of '{pipelinePath}'.")
573 jobs = self.GetJSONArray(f"{pipelinePath}/jobs")
574 bridges = self.GetJSONArray(f"{pipelinePath}/bridges")
576 children: dict[str, JSONObject] = {}
577 if downstream:
578 for bridge in bridges:
579 child = bridge.get("downstream_pipeline", None)
580 if isinstance(child, dict) and child.get("project_id", None) == projectID and child.get("id", None) is not None:
581 children[str(child["id"])] = self.ReadPayloads(child["id"])
583 return {"pipeline": pipeline, "jobs": jobs, "bridges": bridges, "downstream": children}
585 def ReadPipeline(self, pipelineID: int, downstream: bool = True) -> PipelineTrace:
586 """
587 Read a pipeline, its jobs and its child pipelines, and convert them into a trace.
589 :param pipelineID: The pipeline's identifier.
590 :param downstream: Optional, ``True``, if the child pipelines are read too. Default: ``True``.
591 :returns: The pipeline as a trace (see :meth:`PipelineTrace.Parse`).
592 :raises ValueError: If parameter 'pipelineID' is ``None``.
593 :raises TypeError: If parameter 'pipelineID' is not of type :class:`int`.
594 :raises ValueError: If parameter 'pipelineID' isn't positive.
595 :raises TypeError: If parameter 'downstream' is not of type :class:`bool`.
596 :raises RESTError: If a request fails, or GitLab's answer isn't JSON of the expected shape.
597 :raises GitLabError: If GitLab's answer lacks a mandatory field, or a field holds a value GitLab doesn't document.
598 """
599 return PipelineTrace.Parse(**self.ReadPayloads(pipelineID, downstream))
601 def _AddErrorNotes(self, error: RESTError, status: int) -> None:
602 """
603 Add a note saying what a failing status means for the GitLab REST API.
605 :param error: The error the note is added to.
606 :param status: The HTTP status the request failed with.
607 """
608 if status in (401, 403, 404): 608 ↛ exitline 608 didn't return from function '_AddErrorNotes' because the condition on line 608 was always true
609 error.add_note("Check the project's path or identifier, and that the token may read the project's pipelines.")