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

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. 

33 

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. 

37 

38.. code-block:: python 

39 

40 from os import getenv 

41 from pyTooling.GitLab.Tracing import PipelineReader 

42 

43 reader = PipelineReader("gitlab-org/gitlab-development-kit", token=getenv("GITLAB_TOKEN")) 

44 trace = reader.ReadPipeline(2954836704) 

45 print("\\n".join(trace.Format())) 

46 

47.. hint:: 

48 

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 

53 

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 

65 

66 

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`. 

71 

72 The nesting mirrors the key itself, as it does there: :attr:`GitLab.Runner.Tags` spells ``'gitlab.runner.tags'``. 

73 """ 

74 

75 Status: ClassVar[str] = "gitlab.status" #: GitLab's own status, next to the result it maps to. 

76 

77 class Pipeline(metaclass=ExtendedType, slots=True): 

78 """Attribute keys naming a pipeline.""" 

79 

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'``. 

82 

83 class Project(metaclass=ExtendedType, slots=True): 

84 """Attribute keys naming a project.""" 

85 

86 ID: ClassVar[str] = "gitlab.project.id" #: GitLab's identifier of the project. 

87 

88 class Job(metaclass=ExtendedType, slots=True): 

89 """Attribute keys naming a job.""" 

90 

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'``. 

94 

95 class Matrix(metaclass=ExtendedType, slots=True): 

96 """Attribute keys naming a matrix.""" 

97 

98 Dimensions: ClassVar[str] = "gitlab.matrix.dimensions" #: One instance's values, e.g. ``['3.14', 'linux']``. 

99 

100 class Runner(metaclass=ExtendedType, slots=True): 

101 """Attribute keys naming the runner a job ran on.""" 

102 

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']``. 

105 

106 class Downstream(metaclass=ExtendedType, slots=True): 

107 """Attribute keys naming a downstream pipeline.""" 

108 

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. 

111 

112 class Trigger(metaclass=ExtendedType, slots=True): 

113 """Attribute keys naming the trigger job, which started a downstream pipeline.""" 

114 

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. 

118 

119 

120@export 

121def runnerCategory(span: Span) -> str: 

122 """ 

123 Span category naming the runner a timespan ran on, for :class:`~pyTooling.Tracing.Render.GanttLayout`. 

124 

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>`. 

127 

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]) 

137 

138 current = current.Parent 

139 

140 return "" 

141 

142 

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. 

147 

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 """ 

152 

153 @staticmethod 

154 def _Result(element: Base) -> Nullable[Result]: 

155 """ 

156 Return the CI/CD result of an element of the model. 

157 

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>`. 

160 

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 

166 

167 return Result(element.Outcome.value) 

168 

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. 

173 

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) 

179 

180 @staticmethod 

181 def _Value(status: Nullable[Status]) -> Nullable[str]: 

182 """ 

183 Return the value of a status, as GitLab reports it. 

184 

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 

189 

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. 

194 

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. 

198 

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 

205 

206 return parent.StartTime 

207 

208 @staticmethod 

209 def _Earliest(times: Iterable[datetime]) -> Nullable[datetime]: 

210 """ 

211 Return the earliest of some times. 

212 

213 :param times: The times. 

214 :returns: The earliest time, or ``None`` if there is none. 

215 """ 

216 return min(times, default=None) 

217 

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. 

222 

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 

228 

229 return job.StartedAt - timedelta(seconds=job.QueuedDuration) 

230 

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. 

235 

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. 

239 

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) 

248 

249 if len(begins) == 0: 

250 if not group.IsReported or group.CreatedAt is None: 

251 return None, None 

252 

253 begins.append(group.CreatedAt) 

254 

255 begin = min(begins) 

256 

257 return begin, cls._NotBefore(group.CompletedAt, begin) 

258 

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. 

263 

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) 

274 

275 

276@export 

277class QueuedSpan(CIQueuedSpan, GitLabTimespanMixin): 

278 """The timespan a GitLab job waited for a runner, in front of the job's own timespan.""" 

279 

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. 

284 

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) 

292 

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 ) 

301 

302 

303@export 

304class JobSpan(CIJobSpan, GitLabTimespanMixin): 

305 """The timespan of a GitLab job, from the moment it started on a runner until it completed.""" 

306 

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. 

311 

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. 

316 

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 

323 

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 

328 

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) 

336 

337 begin = cls._NotBeforeParent(started, parent) 

338 end = cls._NotBefore(completed, begin) 

339 

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()] 

350 

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 ) 

363 

364 

365@export 

366class MatrixSpan(CIMatrixSpan, GitLabTimespanMixin): 

367 """The instances a matrix produced, grouped into one timespan.""" 

368 

369 @classmethod 

370 def FromGroup(cls, group: Matrix, parent: Span) -> Nullable[Self]: 

371 """ 

372 Build the timespan of a matrix and everything below it. 

373 

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 

381 

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) 

385 

386 return span 

387 

388 

389@export 

390class DownstreamSpan(CIWorkflowSpan, GitLabTimespanMixin): 

391 """The jobs of a downstream pipeline, grouped into one timespan named like its trigger job.""" 

392 

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. 

397 

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 

406 

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) 

428 

429 return span 

430 

431 

432@export 

433class PipelineTrace(CIPipelineTrace, GitLabTimespanMixin): 

434 """A GitLab CI/CD pipeline as a trace.""" 

435 

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. 

446 

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. 

450 

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)) 

462 

463 @classmethod 

464 def FromPipeline(cls, pipeline: Pipeline) -> Self: 

465 """ 

466 Build the trace of a pipeline, as :mod:`pyTooling.GitLab` models it. 

467 

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. 

476 

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 

485 

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 

488 

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) 

496 

497 if jobsBegin is not None: 

498 beginTime = jobsBegin if beginTime is None else min(beginTime, jobsBegin) 

499 

500 if endTime is not None and jobsEnd is not None: 

501 endTime = max(endTime, jobsEnd) 

502 

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) 

520 

521 return trace 

522 

523 

524@export 

525class PipelineReader(ProjectClient): 

526 """ 

527 Reads pipelines of a GitLab project through the GitLab REST API and converts them into traces. 

528 

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 """ 

532 

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. 

536 

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. 

541 

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 

562 

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 

567 

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}'.") 

572 

573 jobs = self.GetJSONArray(f"{pipelinePath}/jobs") 

574 bridges = self.GetJSONArray(f"{pipelinePath}/bridges") 

575 

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"]) 

582 

583 return {"pipeline": pipeline, "jobs": jobs, "bridges": bridges, "downstream": children} 

584 

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. 

588 

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)) 

600 

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. 

604 

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.")