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

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. 

33 

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. 

37 

38.. code-block:: python 

39 

40 from os import getenv 

41 from pyTooling.GitHub.Tracing import WorkflowRunReader 

42 

43 reader = WorkflowRunReader("pyTooling", "Actions", token=getenv("GITHUB_TOKEN")) 

44 trace = reader.ReadRun(34937615362) 

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 

52from typing import Any, ClassVar, Iterable, Optional as Nullable, Self 

53 

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 

64 

65 

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

71 

72 The nesting mirrors the key itself, as it does there: :attr:`GitHub.Runner.Labels` spells 

73 ``'github.runner.labels'``. 

74 """ 

75 

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

78 

79 class Run(metaclass=ExtendedType, slots=True): 

80 """Attribute keys naming a workflow run.""" 

81 

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. 

84 

85 class Workflow(metaclass=ExtendedType, slots=True): 

86 """Attribute keys naming the workflow a run belongs to.""" 

87 

88 Path: ClassVar[str] = "github.workflow.path" #: The workflow's YAML file in the repository. 

89 

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

91 """Attribute keys naming a matrix.""" 

92 

93 Dimensions: ClassVar[str] = "github.matrix.dimensions" #: One instance's values, e.g. ``['ubuntu-26.04', '3.14']``. 

94 

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

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

97 

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. 

100 

101 class Step(metaclass=ExtendedType, slots=True): 

102 """Attribute keys naming a step of a job.""" 

103 

104 Number: ClassVar[str] = "github.step.number" #: The step's position in its job, counted from one. 

105 

106 

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. 

111 

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

116 

117 @staticmethod 

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

119 """ 

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

121 

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

125 

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 

131 

132 return Result(element.Outcome.value) 

133 

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. 

138 

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) 

144 

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. 

154 

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. 

157 

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 

167 

168 return begin, cls._NotBefore(completed, begin) 

169 

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. 

174 

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) 

185 

186 

187@export 

188class StepSpan(CIStepSpan, GitHubTimespanMixin): 

189 """The timespan of a step of a GitHub Actions job.""" 

190 

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. 

195 

196 A step that never started has no timespan - there is nothing to place on a timeline. 

197 

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 

204 

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 ) 

216 

217 

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

221 

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. 

226 

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 ) 

241 

242 

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

246 

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. 

251 

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. 

254 

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 

260 

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) 

270 

271 if started is None: 

272 return None 

273 

274 begin = started 

275 end = cls._NotBefore(completed, started) 

276 

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

284 

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 ) 

297 

298 for step in job.Steps: 

299 StepSpan.FromStep(step, jobSpan) 

300 

301 return jobSpan 

302 

303 

304@export 

305class GitHubGroupMixin(metaclass=ExtendedType, mixin=True): 

306 """ 

307 Mixin-class for a timespan holding other timespans - a called workflow or a matrix. 

308 

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

313 

314 @classmethod 

315 def FromGroup(cls, group: JobGroup, parent: Span) -> Self: 

316 """ 

317 Build the timespan of a group and everything below it. 

318 

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) 

326 

327 return span 

328 

329 

330@export 

331class WorkflowSpan(CIWorkflowSpan, GitHubTimespanMixin, GitHubGroupMixin): 

332 """The jobs of a called workflow, grouped into one timespan.""" 

333 

334 

335@export 

336class MatrixSpan(CIMatrixSpan, GitHubTimespanMixin, GitHubGroupMixin): 

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

338 

339 

340@export 

341class WorkflowRunTrace(CIPipelineTrace, GitHubTimespanMixin): 

342 """A GitHub Actions workflow run as a trace.""" 

343 

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. 

348 

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. 

352 

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

360 

361 @classmethod 

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

363 """ 

364 Build the trace of a workflow run, as :mod:`pyTooling.GitHub` models it. 

365 

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. 

374 

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 

383 

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 

386 

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) 

393 

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

395 endTime = max(endTime, jobsEnd) 

396 

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) 

415 

416 return trace 

417 

418 

419@export 

420class WorkflowRunReader(RepositoryClient): 

421 """ 

422 Reads workflow runs of a GitHub repository through the GitHub REST API and converts them into traces. 

423 

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

427 

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. 

431 

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 

453 

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 

463 

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" 

471 

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) 

479 

480 return WorkflowRunTrace.Parse(run, jobs) 

481 

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. 

485 

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