Coverage for pyTooling/GitHub/Tracing.py: 96%
156 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"""
32Read the timing of a GitHub Actions workflow run into a software execution trace.
34A workflow run becomes a :class:`~pyTooling.Tracing.Trace`, and each job a :class:`~pyTooling.Tracing.Span` with one
35sub-span per step. The time a job waited for a runner is a separate timespan in front of the job. Jobs of a called
36(reusable) workflow - which GitHub names ``Caller / Job`` - are grouped below a timespan named like the calling job.
38.. code-block:: python
40 from os import getenv
41 from pyTooling.GitHub.Tracing import WorkflowRunReader
43 reader = WorkflowRunReader("pyTooling", "Actions", token=getenv("GITHUB_TOKEN"))
44 trace = reader.ReadRun(34937615362)
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
52from typing import Any, ClassVar, Iterable, Optional as Nullable, Self
54from pyTooling.CI import Base, JobGroup, Matrix
55from pyTooling.GitHub import Conclusion, GitHubError, Job, MatrixJob, Pipeline, RepositoryClient, Step
56from pyTooling.Common import getFullyQualifiedName
57from pyTooling.Decorators import export
58from pyTooling.MetaClasses import ExtendedType
59from pyTooling.REST import JSONObject, RESTError
60from pyTooling.Tracing import Span, Trace
61from pyTooling.Tracing.CI import Result, JobSpan as CIJobSpan, MatrixSpan as CIMatrixSpan, \
62 PipelineTrace as CIPipelineTrace, QueuedSpan as CIQueuedSpan, \
63 StepSpan as CIStepSpan, WorkflowSpan as CIWorkflowSpan
66@export
67class GitHub(metaclass=ExtendedType, slots=True):
68 """
69 Attribute keys naming what only GitHub Actions reports, beside the keys of
70 :class:`~pyTooling.Tracing.CI.OTLP`.
72 The nesting mirrors the key itself, as it does there: :attr:`GitHub.Runner.Labels` spells
73 ``'github.runner.labels'``.
74 """
76 Conclusion: ClassVar[str] = "github.conclusion" #: GitHub's own conclusion, next to the result it maps to.
77 Event: ClassVar[str] = "github.event" #: The event that started the run, e.g. ``'push'``.
79 class Run(metaclass=ExtendedType, slots=True):
80 """Attribute keys naming a workflow run."""
82 Attempt: ClassVar[str] = "github.run.attempt" #: Which attempt of the run this is.
83 Number: ClassVar[str] = "github.run.number" #: The run's number within its workflow.
85 class Workflow(metaclass=ExtendedType, slots=True):
86 """Attribute keys naming the workflow a run belongs to."""
88 Path: ClassVar[str] = "github.workflow.path" #: The workflow's YAML file in the repository.
90 class Matrix(metaclass=ExtendedType, slots=True):
91 """Attribute keys naming a matrix."""
93 Dimensions: ClassVar[str] = "github.matrix.dimensions" #: One instance's values, e.g. ``['ubuntu-26.04', '3.14']``.
95 class Runner(metaclass=ExtendedType, slots=True):
96 """Attribute keys naming the runner a job ran on."""
98 Labels: ClassVar[str] = "github.runner.labels" #: The labels the job asked for, e.g. ``['ubuntu-26.04']``.
99 Group: ClassVar[str] = "github.runner.group" #: The runner group the runner belongs to.
101 class Step(metaclass=ExtendedType, slots=True):
102 """Attribute keys naming a step of a job."""
104 Number: ClassVar[str] = "github.step.number" #: The step's position in its job, counted from one.
107@export
108class GitHubTimespanMixin(metaclass=ExtendedType, mixin=True):
109 """
110 Mixin-class for a timespan built from :mod:`pyTooling.GitHub`'s model of a workflow run.
112 It holds what every flavour needs to read that model: the result an element's outcome is, and the span a group's
113 contents have. Everything else the model answers itself - the order its elements are in, and the times of a job, which
114 are wider than the ones GitHub reports because a step may run outside the job containing it.
115 """
117 @staticmethod
118 def _Result(element: Base) -> Nullable[Result]:
119 """
120 Return the CI/CD result of an element of the model.
122 The result is the member of the same value as the element's :attr:`~pyTooling.CI.Base.Outcome`. A
123 GitHub conclusion is mapped onto an outcome by
124 :meth:`Conclusion.ToOutcome <pyTooling.GitHub.Conclusion.ToOutcome>`.
126 :param element: The workflow run, job or step.
127 :returns: The CI/CD result, or ``None`` while the element hasn't ended.
128 """
129 if element.Outcome is None:
130 return None
132 return Result(element.Outcome.value)
134 @staticmethod
135 def _NotBefore(end: Nullable[datetime], begin: datetime) -> Nullable[datetime]:
136 """
137 Clamp an end time so it doesn't precede a begin time.
139 :param end: Optional, the end time. Default: ``None``.
140 :param begin: The begin time.
141 :returns: The end time, but not before the begin time.
142 """
143 return None if end is None else max(end, begin)
145 @classmethod
146 def _Timespan(
147 cls,
148 created: Nullable[datetime],
149 started: Nullable[datetime],
150 completed: Nullable[datetime]
151 ) -> tuple[Nullable[datetime], Nullable[datetime]]:
152 """
153 Return the timespan the model's three times describe.
155 The model spans what a group holds; this is where the span becomes a timespan: it begins when the first
156 element was queued, or started if it was never queued, and it doesn't end before it begins.
158 :param created: When the first element was created, or ``None`` if none reports a time.
159 :param started: When the first element started, or ``None`` if none has started.
160 :param completed: When the last element completed, or ``None`` while one hasn't.
161 :returns: When the timespan begins and ends, each ``None`` if there is nothing to place on a
162 timeline respectively while it is still running.
163 """
164 begin = created if created is not None else started
165 if begin is None:
166 return None, None
168 return begin, cls._NotBefore(completed, begin)
170 @classmethod
171 def _AddContents(cls, group: JobGroup, parent: Span) -> None:
172 """
173 Add the jobs, matrices and called workflows of a group to a timespan, ordered by the time they were queued.
175 :param group: The group - a workflow run, a called workflow or a matrix.
176 :param parent: The timespan the group's contents are added to.
177 """
178 for item in group.IterateElements():
179 if isinstance(item, Job):
180 JobSpan.FromJob(item, parent)
181 elif isinstance(item, Matrix):
182 MatrixSpan.FromGroup(item, parent)
183 else:
184 WorkflowSpan.FromGroup(item, parent)
187@export
188class StepSpan(CIStepSpan, GitHubTimespanMixin):
189 """The timespan of a step of a GitHub Actions job."""
191 @classmethod
192 def FromStep(cls, step: Step, parent: Span) -> Nullable[Self]:
193 """
194 Build the timespan of a step, below the timespan of its job.
196 A step that never started has no timespan - there is nothing to place on a timeline.
198 :param step: The step.
199 :param parent: The timespan of the job containing the step.
200 :returns: The step's timespan, or ``None`` if the step never started.
201 """
202 if step.StartedAt is None:
203 return None
205 return cls(
206 step.Name,
207 step.StartedAt,
208 cls._NotBefore(step.CompletedAt, step.StartedAt),
209 parent=parent,
210 result=cls._Result(step),
211 attributes={
212 GitHub.Step.Number: step.Number,
213 GitHub.Conclusion: None if step.Conclusion is None else step.Conclusion.value
214 }
215 )
218@export
219class QueuedSpan(CIQueuedSpan, GitHubTimespanMixin):
220 """The timespan a GitHub Actions job waited for a runner, in front of the job's own timespan."""
222 @classmethod
223 def FromJob(cls, job: Job, created: datetime, started: Nullable[datetime], parent: Span) -> Self:
224 """
225 Build the timespan a job waited for a runner.
227 :param job: The job that waited.
228 :param created: When the job was created, which is when it started waiting.
229 :param started: Optional, when the job started, or ``None`` while it is still waiting.
230 :param parent: The timespan of the trace, workflow or matrix containing the job.
231 :returns: The waiting timespan.
232 """
233 return cls(
234 f"{job!s} (queued)",
235 created,
236 cls._NotBefore(started, created),
237 parent=parent,
238 taskName=job.QualifiedName,
239 attributes={GitHub.Runner.Labels: list(job.Labels)}
240 )
243@export
244class JobSpan(CIJobSpan, GitHubTimespanMixin):
245 """The timespan of a GitHub Actions job, from the moment it started on a runner until it completed."""
247 @classmethod
248 def FromJob(cls, job: Job, parent: Span) -> Nullable[Self]:
249 """
250 Build the timespans of a job: the time it waited for a runner, the job itself, and one per step.
252 A step may be reported as starting before, or completing after, the job containing it, so the job's timespan is
253 widened to hold its steps - :mod:`pyTooling.Tracing` requires a timespan to lie within its parent.
255 :param job: The job.
256 :param parent: The timespan of the trace, workflow or matrix containing the job.
257 :returns: The job's timespan, or ``None`` if the job hasn't started and therefore only waited.
258 """
259 created, started, completed = job.CreatedAt, job.StartedAt, job.CompletedAt
261 # a matrix instance carries its dimension values, so two of them are distinguishable
262 displayName = str(job)
263 if job.Conclusion is Conclusion.Skipped:
264 # A skipped job neither waited for a runner nor ran on one.
265 begin = started if started is not None else created
266 end = None if begin is None else cls._NotBefore(completed if completed is not None else begin, begin)
267 else:
268 if created is not None and (started is None or created < started):
269 QueuedSpan.FromJob(job, created, started, parent)
271 if started is None:
272 return None
274 begin = started
275 end = cls._NotBefore(completed, started)
277 attributes = {
278 GitHub.Conclusion: None if job.Conclusion is None else job.Conclusion.value,
279 GitHub.Runner.Group: job.RunnerGroupName,
280 GitHub.Runner.Labels: list(job.Labels)
281 }
282 if isinstance(job, MatrixJob):
283 attributes[GitHub.Matrix.Dimensions] = list(job.Dimensions.values())
285 jobSpan = cls(
286 displayName,
287 begin,
288 end,
289 parent=parent,
290 taskName=job.QualifiedName,
291 runID=None if job.ID is None else str(job.ID),
292 runURL=None if job.URL is None else str(job.URL),
293 result=cls._Result(job),
294 workerName=job.RunnerName,
295 attributes=attributes
296 )
298 for step in job.Steps:
299 StepSpan.FromStep(step, jobSpan)
301 return jobSpan
304@export
305class GitHubGroupMixin(metaclass=ExtendedType, mixin=True):
306 """
307 Mixin-class for a timespan holding other timespans - a called workflow or a matrix.
309 Both are built the same way and differ only in what they are, which their class says. Its
310 :meth:`~GitHubTimespanMixin._Timespan` and :meth:`~GitHubTimespanMixin._AddContents` come from
311 :class:`GitHubTimespanMixin`.
312 """
314 @classmethod
315 def FromGroup(cls, group: JobGroup, parent: Span) -> Self:
316 """
317 Build the timespan of a group and everything below it.
319 :param group: The called workflow or the matrix.
320 :param parent: The timespan containing the group.
321 :returns: The group's timespan.
322 """
323 begin, end = cls._Timespan(group.CreatedAt, group.StartedAt, group.CompletedAt)
324 span = cls(str(group), begin, end, parent=parent)
325 cls._AddContents(group, span)
327 return span
330@export
331class WorkflowSpan(CIWorkflowSpan, GitHubTimespanMixin, GitHubGroupMixin):
332 """The jobs of a called workflow, grouped into one timespan."""
335@export
336class MatrixSpan(CIMatrixSpan, GitHubTimespanMixin, GitHubGroupMixin):
337 """The instances a matrix produced, grouped into one timespan."""
340@export
341class WorkflowRunTrace(CIPipelineTrace, GitHubTimespanMixin):
342 """A GitHub Actions workflow run as a trace."""
344 @classmethod
345 def Parse(cls, run: JSONObject, jobs: Nullable[Iterable[JSONObject]] = None) -> Self:
346 """
347 Build the trace of a workflow run from the payloads the GitHub REST API answers with.
349 The payloads are read into a :class:`~pyTooling.GitHub.Pipeline` first, so the tree - a called workflow, a
350 matrix, the jobs and their steps - is reconstructed by the model rather than here, and :meth:`FromPipeline`
351 turns it into timespans.
353 :param run: The workflow run, as returned by ``GET /repos/{owner}/{repo}/actions/runs/{run_id}``.
354 :param jobs: Optional, the run's jobs, as listed by ``GET .../actions/runs/{run_id}/jobs``.
355 :returns: The workflow run as a trace.
356 :raises TypeError: If parameter 'run' is not of type :class:`dict`.
357 :raises GitHubError: If a mandatory field is missing, or a field holds a value GitHub doesn't document.
358 """
359 return cls.FromPipeline(Pipeline.Parse(run, jobs))
361 @classmethod
362 def FromPipeline(cls, pipeline: Pipeline) -> Self:
363 """
364 Build the trace of a workflow run, as :mod:`pyTooling.GitHub` models it.
366 * The run becomes the trace, widened where a job was queued earlier or completed later than the run reports.
367 * A called workflow and a matrix become a timespan holding the jobs below them.
368 * A job becomes a timespan from its start to its completion, preceded by ``<job> (queued)`` from its creation to
369 its start, if it waited. A skipped job has no waiting timespan, and a job that hasn't started yet only a running
370 waiting timespan.
371 * A step that started becomes a sub-span of its job.
372 * Every timespan is classified by :attr:`CI.Span.Kind <pyTooling.Tracing.CI.CI>` and carries the OpenTelemetry CI/CD
373 attributes, GitHub's conclusion (:data:`CONCLUSION`), and for jobs the runner's labels and group.
375 :param pipeline: The workflow run.
376 :returns: The workflow run as a trace.
377 :raises TypeError: If parameter 'pipeline' is not of type :class:`~pyTooling.GitHub.Pipeline`.
378 """
379 if not isinstance(pipeline, Pipeline): 379 ↛ 380line 379 didn't jump to line 380 because the condition on line 379 was never true
380 ex = TypeError("Parameter 'pipeline' is not of type 'Pipeline'.")
381 ex.add_note(f"Got type '{getFullyQualifiedName(pipeline)}'.")
382 raise ex
384 beginTime = pipeline.StartedAt if pipeline.StartedAt is not None else pipeline.CreatedAt
385 endTime = cls._NotBefore(pipeline.CompletedAt, beginTime) if beginTime is not None else pipeline.CompletedAt
387 # a job may be queued before the run reports itself started, and may complete after the run's last update
388 jobsBegin, jobsEnd = cls._Timespan(
389 pipeline.ContentsCreatedAt, pipeline.ContentsStartedAt, pipeline.ContentsCompletedAt
390 )
391 if jobsBegin is not None:
392 beginTime = jobsBegin if beginTime is None else min(beginTime, jobsBegin)
394 if endTime is not None and jobsEnd is not None:
395 endTime = max(endTime, jobsEnd)
397 trace = cls(
398 pipeline.Name,
399 beginTime,
400 endTime,
401 runID=None if pipeline.ID is None else str(pipeline.ID),
402 runURL=None if pipeline.URL is None else str(pipeline.URL),
403 result=cls._Result(pipeline),
404 reference=pipeline.GitReference,
405 revision=pipeline.SHA,
406 attributes={
407 GitHub.Conclusion: None if pipeline.Conclusion is None else pipeline.Conclusion.value,
408 GitHub.Run.Attempt: pipeline.RunAttempt,
409 GitHub.Run.Number: pipeline.RunNumber,
410 GitHub.Workflow.Path: pipeline.Path,
411 GitHub.Event: None if pipeline.Event is None else pipeline.Event.value
412 }
413 )
414 cls._AddContents(pipeline, trace)
416 return trace
419@export
420class WorkflowRunReader(RepositoryClient):
421 """
422 Reads workflow runs of a GitHub repository through the GitHub REST API and converts them into traces.
424 A token is needed for a private repository, and it raises the rate limit for a public one - inside a workflow,
425 ``GITHUB_TOKEN`` with the ``actions: read`` permission suffices.
426 """
428 def ReadRun(self, runID: int, attempt: Nullable[int] = None) -> Trace:
429 """
430 Read a workflow run and all its jobs, and convert them into a trace.
432 :param runID: The workflow run's identifier.
433 :param attempt: Optional, the run attempt to read. Default: the latest attempt.
434 :returns: The workflow run as a trace (see :meth:`WorkflowRunTrace.Parse`).
435 :raises ValueError: If parameter 'runID' is ``None``.
436 :raises TypeError: If parameter 'runID' is not of type :class:`int`.
437 :raises ValueError: If parameter 'runID' isn't positive.
438 :raises TypeError: If parameter 'attempt' is not of type :class:`int`.
439 :raises ValueError: If parameter 'attempt' isn't positive.
440 :raises RESTError: If a request fails, or GitHub's answer isn't a JSON object.
441 :raises GitHubError: If GitHub's answer lacks a mandatory field.
442 """
443 if runID is None:
444 raise ValueError("Parameter 'runID' is None.")
445 elif isinstance(runID, bool) or not isinstance(runID, int):
446 ex = TypeError("Parameter 'runID' is not of type 'int'.")
447 ex.add_note(f"Got type '{getFullyQualifiedName(runID)}'.")
448 raise ex
449 elif runID <= 0:
450 ex = ValueError("Parameter 'runID' isn't positive.")
451 ex.add_note(f"Got value '{runID}'.")
452 raise ex
454 if attempt is not None:
455 if isinstance(attempt, bool) or not isinstance(attempt, int): 455 ↛ 456line 455 didn't jump to line 456 because the condition on line 455 was never true
456 ex = TypeError("Parameter 'attempt' is not of type 'int'.")
457 ex.add_note(f"Got type '{getFullyQualifiedName(attempt)}'.")
458 raise ex
459 elif attempt <= 0:
460 ex = ValueError("Parameter 'attempt' isn't positive.")
461 ex.add_note(f"Got value '{attempt}'.")
462 raise ex
464 runPath = f"repos/{self._owner}/{self._repository}/actions/runs/{runID}"
465 if attempt is None:
466 run, _ = self.GetJSONObject(runPath)
467 jobsPath = f"{runPath}/jobs?filter=latest&per_page=100"
468 else:
469 run, _ = self.GetJSONObject(f"{runPath}/attempts/{attempt}")
470 jobsPath = f"{runPath}/attempts/{attempt}/jobs?per_page=100"
472 jobs: list[JSONObject] = []
473 nextPath: Nullable[str] = jobsPath
474 while nextPath is not None:
475 page, nextPath = self.GetJSONObject(nextPath)
476 if not isinstance(pageJobs := page.get("jobs", None), list):
477 raise GitHubError(f"Field 'jobs' is missing in the answer of '{jobsPath}'.")
478 jobs.extend(pageJobs)
480 return WorkflowRunTrace.Parse(run, jobs)
482 def _AddErrorNotes(self, error: RESTError, status: int) -> None:
483 """
484 Add a note saying what a failing status means for the GitHub REST API.
486 :param error: The error the note is added to.
487 :param status: The HTTP status the request failed with.
488 """
489 if status in (401, 403, 404): 489 ↛ exitline 489 didn't return from function '_AddErrorNotes' because the condition on line 489 was always true
490 error.add_note("Check the repository's name, and that the token may read the repository's actions.")