Coverage for pyTooling/CLI/Pipeline.py: 77%
102 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-09-29 07:08 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-09-29 07:08 +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#
32"""
33The :pycode:`pipeline` command: read a CI pipeline and write what it took.
35.. rubric:: Usage
37.. code-block:: bash
39 pyTooling pipeline --github-repository=pyTooling/pyTooling \
40 --github-pipeline-id=35479251694 \
41 --trace-file=report/Pipeline.otlp.json
43.. hint::
45 See :ref:`high-level help <CLI/Pipeline>` for explanations and usage examples.
46"""
47from argparse import Namespace
48from os import getenv
49from pathlib import Path
50from typing import ClassVar, Optional as Nullable
52from pyTooling.Common import StringEnum
53from pyTooling.Decorators import export
54from pyTooling.MetaClasses import ExtendedType
55from pyTooling.Attributes.ArgParse import CommandHandler, splitFormat
56from pyTooling.Attributes.ArgParse.Flag import LongFlag
57from pyTooling.Attributes.ArgParse.ValuedFlag import LongValuedFlag
58from pyTooling.Tracing import Trace
59from pyTooling.Tracing.CI.GitHub import WorkflowRunReader
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.CLI.Application`.
88 The command reads one run of a CI pipeline into a :class:`~pyTooling.Tracing.Trace` and writes what was asked
89 of it. Reading and writing are separate steps on purpose: the trace is the intermediate every output is derived
90 from, so a further output is a further option and not a second reader.
91 """
92 ENVIRONMENT_REPOSITORY: ClassVar[str] = "GITHUB_REPOSITORY" #: Variable naming the repository inside a workflow.
93 ENVIRONMENT_RUN_ID: ClassVar[str] = "GITHUB_RUN_ID" #: Variable naming the run inside a workflow.
94 ENVIRONMENT_TOKEN: ClassVar[str] = "GITHUB_TOKEN" #: Variable holding the token to read the run with.
96 @CommandHandler(
97 "pipeline",
98 help="Read a CI pipeline run and write what it took.",
99 description="Read a CI pipeline run and write what it took."
100 )
101 @LongValuedFlag(
102 "--github-repository", dest="githubRepository", metaName="owner/name", optional=True,
103 help=f"Repository the workflow run belongs to. Default: ${ENVIRONMENT_REPOSITORY}."
104 )
105 @LongValuedFlag(
106 "--github-pipeline-id", dest="githubPipelineID", metaName="ID", optional=True,
107 help=f"Identifier of the GitHub Actions workflow run. Default: ${ENVIRONMENT_RUN_ID}."
108 )
109 @LongValuedFlag(
110 "--trace-file", dest="traceFile", metaName="[format:]file", optional=True,
111 help=f"Write the trace. Format: {', '.join(TraceFormat)}. Default: {TraceFormat.DEFAULT}."
112 )
113 @LongValuedFlag(
114 "--gantt", dest="gantt", metaName="[format:]file", optional=True,
115 help=f"Draw a Gantt chart. Format: {', '.join(GanttFormat)}. Default: {GanttFormat.DEFAULT}."
116 )
117 @LongFlag("--force", dest="force", help="Overwrite files that exist.")
118 def HandlePipeline(self, args: Namespace) -> None:
119 """
120 Handle program calls with command ``pipeline``.
122 The outputs are checked **before** the pipeline is read, so a misspelled format or a file that exists is
123 reported at once instead of after a network round-trip.
125 :param args: The parsed command line.
126 """
127 self._PrintHeadline()
129 outputs = self._CheckOutputs(args)
130 self.ExitOnPreviousErrors()
132 trace = self._ReadPipeline(args)
133 self._PrintSummary(trace)
134 self._WriteOutputs(outputs, trace)
136 self.ExitOnPreviousErrors()
138 def _CheckOutputs(self, args: Namespace) -> list[tuple[str, StringEnum, Path]]:
139 """
140 Read every output option, and report what can't be written before anything is read.
142 :param args: The parsed command line.
143 :returns: One ``(option, format, file)`` per output that was asked for and can be written.
144 """
145 outputs: list[tuple[str, StringEnum, Path]] = []
146 for option, value, formats in self._Outputs(args):
147 if value is None:
148 continue
150 try:
151 fileFormat, file = splitFormat(value, formats)
152 except ValueError as ex:
153 self.WriteError(f"Option '{option}': {ex}")
154 for note in ex.__notes__:
155 self.WriteErrorNote(note)
156 continue
158 if option == "--gantt" and file.suffix.lower().lstrip(".") != (suffix := fileFormat.partition("-")[2]):
159 self.WriteError(f"Option '--gantt': format '{fileFormat}' writes a '.{suffix}' file.")
160 self.WriteErrorNote(f"Got '{file.name}'. Name the file '.{suffix}', or state the format it is in.")
161 continue
163 if file.exists() and not args.force: 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true
164 self.WriteError(f"File '{file}' exists.")
165 self.WriteErrorNote("Use '--force' to overwrite it.")
166 continue
168 outputs.append((option, fileFormat, file))
170 return outputs
172 def _Outputs(self, args: Namespace) -> tuple[tuple[str, Nullable[str], type[StringEnum]], ...]:
173 """
174 Return the output options this command offers, as ``(option, value, formats)``.
176 A value naming no format gets the enumeration's ``DEFAULT``.
178 :param args: The parsed command line.
179 :returns: One entry per output option, whether or not it was given.
180 """
181 return (
182 ("--trace-file", args.traceFile, TraceFormat),
183 ("--gantt", args.gantt, GanttFormat),
184 )
186 def _WriteOutputs(self, outputs: list[tuple[str, StringEnum, Path]], trace: Trace) -> None:
187 """
188 Write every output the command line asked for.
190 :param outputs: The outputs, as :meth:`_CheckOutputs` returned them.
191 :param trace: The workflow run as a trace.
192 """
193 for option, fileFormat, file in outputs:
194 if option == "--trace-file":
195 self.WriteVerbose(f"Writing the trace as '{fileFormat}' to '{file}' ...")
196 trace.WriteJSONFile(file)
197 self.WriteNormal(f"Trace: {file}")
198 elif option == "--gantt": 198 ↛ 193line 198 didn't jump to line 193 because the condition on line 198 was always true
199 self._WriteGantt(fileFormat, file, trace)
201 def _WriteGantt(self, fileFormat: GanttFormat, file: Path, trace: Trace) -> None:
202 """
203 Lay the trace out as a Gantt chart and draw it with the backend the format names.
205 The steps of a job are left out: a pipeline of 57 jobs has more than a thousand steps, and a chart of one row
206 per step is a different picture than a chart of one row per job.
208 :param fileFormat: The format, one of :class:`GanttFormat`.
209 :param file: The file to write.
210 :param trace: The workflow run as a trace.
211 :raises MissingDependencyError: If *matplotlib* isn't installed. |br|
212 :func:`~pyTooling.CLI.main` prints it with the commands installing it.
213 """
214 from pyTooling.Tracing.Render.Matplotlib import MatplotlibRenderer
216 self.WriteVerbose(f"Drawing the Gantt chart as '{fileFormat}' to '{file}' ...")
217 layout = GanttLayout(trace, spanFilter=ciSpanFilter())
218 MatplotlibRenderer(layout).Write(file)
219 self.WriteNormal(f"Gantt: {file} ({layout.RowCount} rows)")
221 def _ReadPipeline(self, args: Namespace) -> Trace:
222 """
223 Read the workflow run the command line names into a trace.
225 :param args: The parsed command line.
226 :returns: The workflow run as a trace.
227 """
228 repository = args.githubRepository if args.githubRepository is not None else getenv(self.ENVIRONMENT_REPOSITORY)
229 if repository is None:
230 self.WriteError("No repository given.")
231 self.WriteErrorNote(f"Set '--github-repository=owner/name', or ${self.ENVIRONMENT_REPOSITORY}.")
232 self.Exit(2)
234 runID = args.githubPipelineID if args.githubPipelineID is not None else getenv(self.ENVIRONMENT_RUN_ID)
235 if runID is None:
236 self.WriteError("No workflow run given.")
237 self.WriteErrorNote(f"Set '--github-pipeline-id=<ID>', or ${self.ENVIRONMENT_RUN_ID}.")
238 self.Exit(2)
239 elif not runID.isdigit():
240 self.WriteError(f"Workflow run '{runID}' isn't a number.")
241 self.WriteErrorNote("It is the number in the run's URL: .../actions/runs/35479251694.")
242 self.Exit(2)
244 self.WriteVerbose(f"Reading run {runID} of '{repository}' ...")
245 reader = WorkflowRunReader(repository, getenv(self.ENVIRONMENT_TOKEN))
247 return reader.ReadRun(int(runID))
249 def _PrintSummary(self, trace: Trace) -> None:
250 """
251 Print what the pipeline took, so the command says something without being asked to write a file.
253 :param trace: The workflow run as a trace.
254 """
255 self.WriteNormal(f"Pipeline: {trace.Name}")
256 self.WriteNormal(f"Began: {trace.StartTime}")
257 self.WriteNormal(f"Wall time: {trace.Duration:.0f} s")