Coverage for pyTooling/GitHub/CLI/Pipeline.py: 87%
109 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 00:39 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 00:39 +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-github 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.GitHub.Tracing 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.GitHub.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 "--token", dest="token", metaName="token", optional=True,
111 help=f"Token to read the workflow run with. Default: ${ENVIRONMENT_TOKEN}."
112 )
113 @LongValuedFlag(
114 "--trace-file", dest="traceFile", metaName="[format:]file", optional=True,
115 help=f"Write the trace. Format: {', '.join(TraceFormat)}. Default: {TraceFormat.DEFAULT}."
116 )
117 @LongValuedFlag(
118 "--gantt", dest="gantt", metaName="[format:]file", optional=True,
119 help=f"Draw a Gantt chart. Format: {', '.join(GanttFormat)}. Default: {GanttFormat.DEFAULT}."
120 )
121 @LongFlag("--force", dest="force", help="Overwrite files that exist.")
122 def HandlePipeline(self, args: Namespace) -> None:
123 """
124 Handle program calls with command ``pipeline``.
126 The outputs are checked **before** the pipeline is read, so a misspelled format or a file that exists is
127 reported at once instead of after a network round-trip.
129 :param args: The parsed command line.
130 """
131 self._PrintHeadline()
133 outputs = self._CheckOutputs(args)
134 self.ExitOnPreviousErrors()
136 trace = self._ReadPipeline(args)
137 self._PrintSummary(trace)
138 self._WriteOutputs(outputs, trace)
140 self.ExitOnPreviousErrors()
142 def _CheckOutputs(self, args: Namespace) -> list[tuple[str, StringEnum, Path]]:
143 """
144 Read every output option, and report what can't be written before anything is read.
146 :param args: The parsed command line.
147 :returns: One ``(option, format, file)`` per output that was asked for and can be written.
148 """
149 outputs: list[tuple[str, StringEnum, Path]] = []
150 for option, value, formats in self._Outputs(args):
151 if value is None:
152 continue
154 try:
155 fileFormat, file = splitFormat(value, formats)
156 except ValueError as ex:
157 self.WriteError(f"Option '{option}': {ex}")
158 for note in ex.__notes__:
159 self.WriteErrorNote(note)
160 continue
162 if option == "--gantt" and file.suffix.lower().lstrip(".") != (suffix := fileFormat.partition("-")[2]):
163 self.WriteError(f"Option '--gantt': format '{fileFormat}' writes a '.{suffix}' file.")
164 self.WriteErrorNote(f"Got '{file.name}'. Name the file '.{suffix}', or state the format it is in.")
165 continue
167 if file.exists() and not args.force: 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true
168 self.WriteError(f"File '{file}' exists.")
169 self.WriteErrorNote("Use '--force' to overwrite it.")
170 continue
172 outputs.append((option, fileFormat, file))
174 return outputs
176 def _Outputs(self, args: Namespace) -> tuple[tuple[str, Nullable[str], type[StringEnum]], ...]:
177 """
178 Return the output options this command offers, as ``(option, value, formats)``.
180 A value naming no format gets the enumeration's ``DEFAULT``.
182 :param args: The parsed command line.
183 :returns: One entry per output option, whether or not it was given.
184 """
185 return (
186 ("--trace-file", args.traceFile, TraceFormat),
187 ("--gantt", args.gantt, GanttFormat),
188 )
190 def _WriteOutputs(self, outputs: list[tuple[str, StringEnum, Path]], trace: Trace) -> None:
191 """
192 Write every output the command line asked for.
194 :param outputs: The outputs, as :meth:`_CheckOutputs` returned them.
195 :param trace: The workflow run as a trace.
196 """
197 for option, fileFormat, file in outputs:
198 if option == "--trace-file":
199 self.WriteVerbose(f"Writing the trace as '{fileFormat}' to '{file}' ...")
200 trace.WriteJSONFile(file)
201 self.WriteNormal(f"Trace: {file}")
202 elif option == "--gantt": 202 ↛ 197line 202 didn't jump to line 197 because the condition on line 202 was always true
203 self._WriteGantt(fileFormat, file, trace)
205 def _WriteGantt(self, fileFormat: GanttFormat, file: Path, trace: Trace) -> None:
206 """
207 Lay the trace out as a Gantt chart and draw it with the backend the format names.
209 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
210 per step is a different picture than a chart of one row per job.
212 :param fileFormat: The format, one of :class:`GanttFormat`.
213 :param file: The file to write.
214 :param trace: The workflow run as a trace.
215 :raises MissingDependencyError: If *matplotlib* isn't installed. |br|
216 :func:`~pyTooling.GitHub.CLI.main` prints it with the commands installing it.
217 """
218 from pyTooling.Tracing.Render.Matplotlib import MatplotlibRenderer
220 self.WriteVerbose(f"Drawing the Gantt chart as '{fileFormat}' to '{file}' ...")
221 layout = GanttLayout(trace, spanFilter=ciSpanFilter())
222 MatplotlibRenderer(layout).Write(file)
223 self.WriteNormal(f"Gantt: {file} ({layout.RowCount} rows)")
225 def _ReadPipeline(self, args: Namespace) -> Trace:
226 """
227 Read the workflow run the command line names into a trace.
229 :param args: The parsed command line.
230 :returns: The workflow run as a trace.
231 """
232 repository = args.githubRepository if args.githubRepository is not None else getenv(self.ENVIRONMENT_REPOSITORY)
233 if repository is None: 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true
234 self.WriteError("No repository given.")
235 self.WriteErrorNote(f"Set '--github-repository=owner/name', or ${self.ENVIRONMENT_REPOSITORY}.")
236 self.Exit(2)
238 owner, _, name = repository.partition("/")
239 if owner == "" or name == "" or "/" in name:
240 self.WriteError(f"Repository '{repository}' isn't of the form 'owner/name'.")
241 self.WriteErrorNote("E.g. '--github-repository=pyTooling/pyTooling'.")
242 self.Exit(2)
244 runID = args.githubPipelineID if args.githubPipelineID is not None else getenv(self.ENVIRONMENT_RUN_ID)
245 if runID is None: 245 ↛ 246line 245 didn't jump to line 246 because the condition on line 245 was never true
246 self.WriteError("No workflow run given.")
247 self.WriteErrorNote(f"Set '--github-pipeline-id=<ID>', or ${self.ENVIRONMENT_RUN_ID}.")
248 self.Exit(2)
249 elif not runID.isdigit(): 249 ↛ 250line 249 didn't jump to line 250 because the condition on line 249 was never true
250 self.WriteError(f"Workflow run '{runID}' isn't a number.")
251 self.WriteErrorNote("It is the number in the run's URL: .../actions/runs/35479251694.")
252 self.Exit(2)
254 self.WriteVerbose(f"Reading run {runID} of '{repository}' ...")
255 token = args.token if args.token is not None else getenv(self.ENVIRONMENT_TOKEN)
256 reader = WorkflowRunReader(owner, name, token)
258 return reader.ReadRun(int(runID))
260 def _PrintSummary(self, trace: Trace) -> None:
261 """
262 Print what the pipeline took, so the command says something without being asked to write a file.
264 :param trace: The workflow run as a trace.
265 """
266 self.WriteNormal(f"Pipeline: {trace.Name}")
267 self.WriteNormal(f"Began: {trace.StartTime}")
268 self.WriteNormal(f"Wall time: {trace.DurationInSeconds:.0f} s")