Coverage for pyTooling/GitLab/CLI/Pipeline.py: 96%
113 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"""
32The :pycode:`pipeline` command: read a CI pipeline and write what it took.
34.. rubric:: Usage
36.. code-block:: bash
38 pytooling-gitlab pipeline --gitlab-project=gitlab-org/gitlab-development-kit \
39 --gitlab-pipeline-id=2954836704 \
40 --trace-file=report/Pipeline.otlp.json
42.. hint::
44 See :ref:`high-level help <CLI/Pipeline>` for explanations and usage examples.
45"""
46from argparse import Namespace
47from os import getenv
48from pathlib import Path
49from typing import ClassVar, Optional as Nullable
51from pyTooling.Common import StringEnum
52from pyTooling.Decorators import export
53from pyTooling.MetaClasses import ExtendedType
54from pyTooling.Attributes.ArgParse import CommandHandler, splitFormat
55from pyTooling.Attributes.ArgParse.Flag import LongFlag
56from pyTooling.Attributes.ArgParse.ValuedFlag import LongValuedFlag
57from pyTooling.Tracing import Trace
58from pyTooling.GitLab import GITLAB_API_URL
59from pyTooling.GitLab.Tracing import PipelineReader, runnerCategory
60from pyTooling.Tracing.Render import GanttLayout, ciSpanFilter
63@export
64class TraceFormat(StringEnum):
65 """The formats a trace can be written in, as ``--trace-file`` names them."""
67 OTLPJSON = "otlp-json" #: OpenTelemetry's OTLP/JSON encoding of a trace.
69 DEFAULT = OTLPJSON #: The format ``--trace-file`` writes when its value names none.
72@export
73class GanttFormat(StringEnum):
74 """The formats a Gantt chart can be drawn in, as ``--gantt`` names them: the backend and the file format."""
76 MatplotlibPNG = "matplotlib-png" #: A raster image, drawn by matplotlib.
77 MatplotlibSVG = "matplotlib-svg" #: A vector image, drawn by matplotlib.
78 MatplotlibPDF = "matplotlib-pdf" #: A PDF page, drawn by matplotlib.
80 DEFAULT = MatplotlibPNG #: The format ``--gantt`` draws when its value names none.
83@export
84class PipelineHandlers(metaclass=ExtendedType, mixin=True):
85 """
86 Mixin-class contributing the :pycode:`pipeline` command to :class:`~pyTooling.GitLab.CLI.Application`.
88 The command reads one CI pipeline into a :class:`~pyTooling.Tracing.Trace` and writes what was asked of it.
89 Reading and writing are separate steps on purpose: the trace is the intermediate every output is derived from, so
90 a further output is a further option and not a second reader.
91 """
92 ENVIRONMENT_PROJECT: ClassVar[str] = "CI_PROJECT_ID" #: Variable naming the project inside a pipeline.
93 ENVIRONMENT_PIPELINE_ID: ClassVar[str] = "CI_PIPELINE_ID" #: Variable naming the pipeline inside a pipeline.
94 ENVIRONMENT_API_URL: ClassVar[str] = "CI_API_V4_URL" #: Variable naming the REST API inside a pipeline.
95 ENVIRONMENT_TOKEN: ClassVar[str] = "GITLAB_TOKEN" #: Variable holding the token to read the pipeline with.
97 @CommandHandler(
98 "pipeline",
99 help="Read a CI pipeline and write what it took.",
100 description="Read a CI pipeline and write what it took."
101 )
102 @LongValuedFlag(
103 "--gitlab-project", dest="gitlabProject", metaName="path|ID", optional=True,
104 help=f"Project the pipeline belongs to, as its path or identifier. Default: ${ENVIRONMENT_PROJECT}."
105 )
106 @LongValuedFlag(
107 "--gitlab-pipeline-id", dest="gitlabPipelineID", metaName="ID", optional=True,
108 help=f"Identifier of the GitLab pipeline. Default: ${ENVIRONMENT_PIPELINE_ID}."
109 )
110 @LongValuedFlag(
111 "--gitlab-api-url", dest="gitlabAPIURL", metaName="URL", optional=True,
112 help=f"GitLab's REST API. Default: ${ENVIRONMENT_API_URL}, else {GITLAB_API_URL}."
113 )
114 @LongValuedFlag(
115 "--token", dest="token", metaName="token", optional=True,
116 help=f"Token reading the pipeline, e.g. with scope 'read_api'. Default: ${ENVIRONMENT_TOKEN}."
117 )
118 @LongValuedFlag(
119 "--trace-file", dest="traceFile", metaName="[format:]file", optional=True,
120 help=f"Write the trace. Format: {', '.join(TraceFormat)}. Default: {TraceFormat.DEFAULT}."
121 )
122 @LongValuedFlag(
123 "--gantt", dest="gantt", metaName="[format:]file", optional=True,
124 help=f"Draw a Gantt chart. Format: {', '.join(GanttFormat)}. Default: {GanttFormat.DEFAULT}."
125 )
126 @LongFlag("--force", dest="force", help="Overwrite files that exist.")
127 def HandlePipeline(self, args: Namespace) -> None:
128 """
129 Handle program calls with command ``pipeline``.
131 The outputs are checked **before** the pipeline is read, so a misspelled format or a file that exists is
132 reported at once instead of after a network round-trip.
134 :param args: The parsed command line.
135 """
136 self._PrintHeadline()
138 outputs = self._CheckOutputs(args)
139 self.ExitOnPreviousErrors()
141 trace = self._ReadPipeline(args)
142 self._PrintSummary(trace)
143 self._WriteOutputs(outputs, trace)
145 self.ExitOnPreviousErrors()
147 def _CheckOutputs(self, args: Namespace) -> list[tuple[str, StringEnum, Path]]:
148 """
149 Read every output option, and report what can't be written before anything is read.
151 :param args: The parsed command line.
152 :returns: One ``(option, format, file)`` per output that was asked for and can be written.
153 """
154 outputs: list[tuple[str, StringEnum, Path]] = []
155 for option, value, formats in self._Outputs(args):
156 if value is None:
157 continue
159 try:
160 fileFormat, file = splitFormat(value, formats)
161 except ValueError as ex:
162 self.WriteError(f"Option '{option}': {ex}")
163 for note in ex.__notes__:
164 self.WriteErrorNote(note)
165 continue
167 if option == "--gantt" and file.suffix.lower().lstrip(".") != (suffix := fileFormat.partition("-")[2]):
168 self.WriteError(f"Option '--gantt': format '{fileFormat}' writes a '.{suffix}' file.")
169 self.WriteErrorNote(f"Got '{file.name}'. Name the file '.{suffix}', or state the format it is in.")
170 continue
172 if file.exists() and not args.force: 172 ↛ 173line 172 didn't jump to line 173 because the condition on line 172 was never true
173 self.WriteError(f"File '{file}' exists.")
174 self.WriteErrorNote("Use '--force' to overwrite it.")
175 continue
177 outputs.append((option, fileFormat, file))
179 return outputs
181 def _Outputs(self, args: Namespace) -> tuple[tuple[str, Nullable[str], type[StringEnum]], ...]:
182 """
183 Return the output options this command offers, as ``(option, value, formats)``.
185 A value naming no format gets the enumeration's ``DEFAULT``.
187 :param args: The parsed command line.
188 :returns: One entry per output option, whether or not it was given.
189 """
190 return (
191 ("--trace-file", args.traceFile, TraceFormat),
192 ("--gantt", args.gantt, GanttFormat),
193 )
195 def _WriteOutputs(self, outputs: list[tuple[str, StringEnum, Path]], trace: Trace) -> None:
196 """
197 Write every output the command line asked for.
199 :param outputs: The outputs, as :meth:`_CheckOutputs` returned them.
200 :param trace: The pipeline as a trace.
201 """
202 for option, fileFormat, file in outputs:
203 if option == "--trace-file":
204 self.WriteVerbose(f"Writing the trace as '{fileFormat}' to '{file}' ...")
205 trace.WriteJSONFile(file)
206 self.WriteNormal(f"Trace: {file}")
207 elif option == "--gantt": 207 ↛ 202line 207 didn't jump to line 202 because the condition on line 207 was always true
208 self._WriteGantt(fileFormat, file, trace)
210 def _WriteGantt(self, fileFormat: GanttFormat, file: Path, trace: Trace) -> None:
211 """
212 Lay the trace out as a Gantt chart and draw it with the backend the format names.
214 A job is colored by its runner, as :func:`~pyTooling.GitLab.Tracing.runnerCategory` names it.
216 :param fileFormat: The format, one of :class:`GanttFormat`.
217 :param file: The file to write.
218 :param trace: The pipeline as a trace.
219 :raises MissingDependencyError: If *matplotlib* isn't installed. |br|
220 :func:`~pyTooling.GitLab.CLI.main` prints it with the commands installing it.
221 """
222 from pyTooling.Tracing.Render.Matplotlib import MatplotlibRenderer
224 self.WriteVerbose(f"Drawing the Gantt chart as '{fileFormat}' to '{file}' ...")
225 layout = GanttLayout(trace, spanFilter=ciSpanFilter(), categorize=runnerCategory)
226 MatplotlibRenderer(layout).Write(file)
227 self.WriteNormal(f"Gantt: {file} ({layout.RowCount} rows)")
229 def _ReadPipeline(self, args: Namespace) -> Trace:
230 """
231 Read the pipeline the command line names into a trace.
233 The token is ``--token``, else ``$GITLAB_TOKEN``; a public project's pipeline is read without one.
235 :param args: The parsed command line.
236 :returns: The pipeline as a trace.
237 """
238 project = args.gitlabProject if args.gitlabProject is not None else getenv(self.ENVIRONMENT_PROJECT)
239 if project is None:
240 self.WriteError("No project given.")
241 self.WriteErrorNote(f"Set '--gitlab-project=group/project', or ${self.ENVIRONMENT_PROJECT}.")
242 self.Exit(2)
243 elif project.strip("/") == "" or "" in project.split("/"):
244 self.WriteError(f"Project '{project}' is neither a path nor an identifier.")
245 self.WriteErrorNote("E.g. '--gitlab-project=gitlab-org/gitlab-development-kit' or '--gitlab-project=74823'.")
246 self.Exit(2)
248 pipelineID = args.gitlabPipelineID if args.gitlabPipelineID is not None else getenv(self.ENVIRONMENT_PIPELINE_ID)
249 if pipelineID is None:
250 self.WriteError("No pipeline given.")
251 self.WriteErrorNote(f"Set '--gitlab-pipeline-id=<ID>', or ${self.ENVIRONMENT_PIPELINE_ID}.")
252 self.Exit(2)
253 elif not pipelineID.isdigit():
254 self.WriteError(f"Pipeline '{pipelineID}' isn't a number.")
255 self.WriteErrorNote("It is the number in the pipeline's URL: .../-/pipelines/2954836704.")
256 self.Exit(2)
258 if (apiURL := args.gitlabAPIURL) is None: 258 ↛ 261line 258 didn't jump to line 261 because the condition on line 258 was always true
259 apiURL = getenv(self.ENVIRONMENT_API_URL, str(GITLAB_API_URL))
261 token = args.token if args.token is not None else getenv(self.ENVIRONMENT_TOKEN)
263 self.WriteVerbose(f"Reading pipeline {pipelineID} of '{project}' from '{apiURL}' ...")
264 reader = PipelineReader(int(project) if project.isdigit() else project, token, apiURL=apiURL)
266 return reader.ReadPipeline(int(pipelineID))
268 def _PrintSummary(self, trace: Trace) -> None:
269 """
270 Print what the pipeline took, so the command says something without being asked to write a file.
272 :param trace: The pipeline as a trace.
273 """
274 self.WriteNormal(f"Pipeline: {trace.Name}")
275 self.WriteNormal(f"Began: {trace.StartTime}")
276 self.WriteNormal(f"Wall time: {trace.Duration:.0f} s")