Coverage for pyTooling/Tracing/Render/__init__.py: 94%

364 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-03 23:02 +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""" 

32Backend independent layout of a software execution trace as a Gantt chart. 

33 

34:class:`GanttLayout` arranges the timespans of a trace in rows, with times in seconds after the trace began, and 

35summarizes the jobs of a CI pipeline per runner category. A renderer like :mod:`pyTooling.Tracing.Render.Matplotlib` 

36only draws these rows and statistics, so every renderer shows the same chart. 

37 

38.. hint:: 

39 

40 See :ref:`high-level help <TRACING/Render>` for explanations and usage examples. 

41""" 

42from __future__ import annotations 

43 

44from datetime import datetime, timedelta 

45from enum import Enum 

46from pathlib import Path 

47from re import IGNORECASE, compile as re_compile 

48from statistics import fmean 

49from typing import Callable, ClassVar, Generic, Iterator, Optional as Nullable, TypeVar, Union 

50 

51from pyTooling.Decorators import export, readonly 

52from pyTooling.MetaClasses import ExtendedType, abstractclass, abstractmethod 

53from pyTooling.Common import getFullyQualifiedName 

54from pyTooling.Diagram.Gantt import Bar, Diagram, Row 

55from pyTooling.Tracing import Span, Trace, TracingError 

56from pyTooling.Tracing.CI import CI, OTLP, Result, SpanKind 

57from pyTooling.Tracing.CI.GitHub import GitHub 

58 

59 

60__all__ = ["SpanFilter", "SpanCategory", "MSYS2_SETUP_STEP", "LINE_LEGEND_LABEL"] 

61 

62_FigureType = TypeVar("_FigureType") 

63"""Type of the drawing a renderer's backend produces, e.g. matplotlib's :class:`~matplotlib.figure.Figure`.""" 

64 

65LINE_LEGEND_LABEL = "pipeline, called workflow" 

66"""The legend's label of the line drawn for the pipeline and every called workflow.""" 

67 

68SpanFilter = Callable[[Span], bool] 

69"""A function deciding whether a timespan - and with it, its sub-spans - is shown.""" 

70 

71SpanCategory = Callable[[Span], str] 

72"""A function returning the category of a timespan, e.g. the runner it ran on. The empty string means no category.""" 

73 

74MSYS2_SETUP_STEP = re_compile(r"Setup MSYS2 for (\w+)", IGNORECASE) 

75"""Pattern of the step name setting up MSYS2 in a CI job, capturing the MSYS2 environment, e.g. ``UCRT64``.""" 

76 

77 

78@export 

79class StepExclusion(Enum): 

80 """ 

81 Which steps of CI jobs a filter created by :func:`ciSpanFilter` hides. 

82 """ 

83 Nothing = 0 #: Show every step. 

84 Skipped = 1 #: Hide the steps that were skipped. 

85 All = 2 #: Hide every step. 

86 

87 

88@export 

89def ciSpanFilter( 

90 excludeSteps: Union[bool, StepExclusion] = StepExclusion.All, 

91 excludeSkippedJobs: bool = True 

92) -> SpanFilter: 

93 """ 

94 Create a span filter for the trace of a CI pipeline. 

95 

96 Steps outnumber jobs by far - a pipeline of 74 jobs has more than 1600 steps, of which a third were skipped - and a 

97 skipped job has neither waited nor run. 

98 

99 :param excludeSteps: Optional, which steps to hide: ``True`` or :attr:`StepExclusion.All` hides every step, 

100 ``False`` or :attr:`StepExclusion.Nothing` none, and :attr:`StepExclusion.Skipped` the 

101 skipped ones. Default: :attr:`StepExclusion.All`. 

102 :param excludeSkippedJobs: Optional, hide the jobs that were skipped. Default: ``True``. 

103 :returns: The span filter. 

104 :raises TypeError: If parameter 'excludeSteps' is neither of type :class:`bool` nor :class:`StepExclusion`. 

105 """ 

106 if isinstance(excludeSteps, bool): 

107 excludeSteps = StepExclusion.All if excludeSteps else StepExclusion.Nothing 

108 elif not isinstance(excludeSteps, StepExclusion): 

109 ex = TypeError("Parameter 'excludeSteps' is neither of type 'bool' nor 'StepExclusion'.") 

110 ex.add_note(f"Got type '{getFullyQualifiedName(excludeSteps)}'.") 

111 raise ex 

112 

113 def spanFilter(span: Span) -> bool: 

114 """ 

115 Nested function hiding steps and skipped jobs as configured. 

116 

117 :param span: The timespan. 

118 :returns: ``False``, if the timespan is hidden. 

119 """ 

120 kind = span.get(CI.Span.Kind) 

121 if kind == SpanKind.Step: 

122 if excludeSteps is StepExclusion.All: 

123 return False 

124 return excludeSteps is StepExclusion.Nothing or span.get(OTLP.CICD.Pipeline.Task.Run.Result) != Result.Skip 

125 elif kind == SpanKind.Job and excludeSkippedJobs: 

126 return span.get(OTLP.CICD.Pipeline.Task.Run.Result) != Result.Skip 

127 

128 return True 

129 

130 return spanFilter 

131 

132 

133@export 

134def msys2Environment(job: Span) -> Nullable[str]: 

135 """ 

136 Return the MSYS2 environment a CI job used. 

137 

138 The environment is taken from a step of the job, which matches :data:`MSYS2_SETUP_STEP` and succeeded. A job running 

139 natively on Windows skips that step or sets up the environment ``native``. 

140 

141 :param job: The job's timespan. 

142 :returns: The environment in upper case, e.g. ``UCRT64``, or ``None`` if the job didn't use an MSYS2 environment. 

143 """ 

144 for step in job.IterateSubSpans(): 

145 if (match := MSYS2_SETUP_STEP.search(step.Name)) is None: 

146 continue 

147 elif step.get(OTLP.CICD.Pipeline.Task.Run.Result) != Result.Success: 

148 continue 

149 elif (environment := match.group(1).upper()) != "NATIVE": 

150 return environment 

151 

152 return None 

153 

154 

155@export 

156def runnerCategory(span: Span) -> str: 

157 """ 

158 Span category naming the runner a timespan ran on, and the MSYS2 environment of a job using MSYS2. 

159 

160 The runner is the first runner label of the timespan or its nearest ancestor. A job using an MSYS2 environment 

161 (see :func:`msys2Environment`) is a category of its own, e.g. ``windows-2025 + UCRT64``, because it takes 

162 significantly longer than a native job on the same runner. 

163 

164 :param span: The timespan. 

165 :returns: The category, or the empty string if neither the timespan nor an ancestor has a runner label. 

166 """ 

167 label = "" 

168 job: Nullable[Span] = None 

169 current: Nullable[Span] = span 

170 while current is not None: 

171 if job is None and current.get(CI.Span.Kind) == SpanKind.Job: 

172 job = current 

173 

174 if label == "" and GitHub.Runner.Labels in current and len(labels := current[GitHub.Runner.Labels]) > 0: 

175 label = str(labels[0]) 

176 current = current.Parent 

177 

178 if label != "" and job is not None and (environment := msys2Environment(job)) is not None: 

179 return f"{label} + {environment}" 

180 

181 return label 

182 

183 

184@export 

185class GanttBar(Bar): 

186 """ 

187 A bar of a trace's Gantt chart: the time range one timespan occupies. 

188 

189 It adds to :class:`~pyTooling.Diagram.Gantt.Bar` what a trace knows about a timespan and a schedule doesn't: 

190 whether the bar is the time a job waited for a runner, and whether its timespan is still running. 

191 """ 

192 _queued: bool #: The bar is the time a job waited for a runner. 

193 _running: bool #: The bar's timespan is still running, so the bar ends at the layout's current time. 

194 

195 def __init__(self, begin: datetime, end: datetime, queued: bool, running: bool, *, parent: GanttRow) -> None: 

196 """ 

197 Initializes a bar and appends it to its row. 

198 

199 :param begin: Begin of the bar. 

200 :param end: End of the bar. 

201 :param queued: The bar is the time a job waited for a runner. 

202 :param running: The bar's timespan is still running. 

203 :param parent: The row the bar sits in. 

204 :raises ValueError: If parameter 'begin', 'end' or 'parent' is None. 

205 :raises TypeError: If parameter 'begin' or 'end' is not of type :class:`~datetime.datetime`. 

206 :raises TypeError: If parameter 'parent' is not of type :class:`~pyTooling.Diagram.Gantt.Row`. 

207 :raises ValueError: If the end precedes the begin. 

208 """ 

209 super().__init__(begin, end, parent=parent) 

210 

211 self._queued = queued 

212 self._running = running 

213 

214 @readonly 

215 def IsQueued(self) -> bool: 

216 """ 

217 Read-only property to access whether the bar is the time a job waited for a runner (:attr:`_queued`). 

218 

219 :returns: ``True``, if the bar shows waiting. 

220 """ 

221 return self._queued 

222 

223 @readonly 

224 def IsRunning(self) -> bool: 

225 """ 

226 Read-only property to access whether the bar's timespan is still running (:attr:`_running`). 

227 

228 :returns: ``True``, if the bar ends at the layout's current time. 

229 """ 

230 return self._running 

231 

232 

233@export 

234class GanttRow(Row): 

235 """ 

236 A row of a trace's Gantt chart: one timespan, with the bar a job waited for a runner in front of the job's bar. 

237 

238 It adds to :class:`~pyTooling.Diagram.Gantt.Row` the timespan the row shows, where that timespan sits in the 

239 trace's tree, and the category its bars are colored by. 

240 """ 

241 _span: Span #: The timespan shown by the row. 

242 _depth: int #: Nesting depth of the timespan, where the trace is at depth 0. 

243 _category: str #: Category of the timespan, or the empty string. 

244 

245 def __init__(self, span: Span, depth: int, category: str, *, parent: GanttLayout) -> None: 

246 """ 

247 Initializes a row without bars and appends it to its layout. 

248 

249 :param span: The timespan shown by the row. 

250 :param depth: Nesting depth of the timespan, where the trace is at depth 0. 

251 :param category: Category of the timespan, or the empty string. 

252 :param parent: The layout the row belongs to. 

253 :raises ValueError: If parameter 'parent' is None. 

254 :raises TypeError: If parameter 'parent' is not of type :class:`~pyTooling.Diagram.Gantt.Diagram`. 

255 """ 

256 super().__init__(span.Name, parent=parent) 

257 

258 self._span = span 

259 self._depth = depth 

260 self._category = category 

261 

262 @readonly 

263 def Span(self) -> Span: 

264 """ 

265 Read-only property to access the timespan shown by the row (:attr:`_span`). 

266 

267 :returns: The timespan. 

268 """ 

269 return self._span 

270 

271 @readonly 

272 def SpanID(self) -> str: 

273 """ 

274 Read-only property to access the identifier of the row's timespan. 

275 

276 :returns: The timespan's identifier, as 16 hex digits. 

277 """ 

278 return self._span.SpanID 

279 

280 @readonly 

281 def ParentSpanID(self) -> Nullable[str]: 

282 """ 

283 Read-only property to return the identifier of the row's parent timespan. 

284 

285 :returns: The parent's identifier, as 16 hex digits, or ``None`` for the trace. 

286 """ 

287 return None if self._span.Parent is None else self._span.Parent.SpanID 

288 

289 @readonly 

290 def Kind(self) -> Nullable[str]: 

291 """ 

292 Read-only property to return the CI span kind of the row's timespan. 

293 

294 :returns: The value of :data:`~pyTooling.Tracing.CI.CI.Span.Kind`, or ``None`` if the timespan has none. 

295 """ 

296 return self._span.get(CI.Span.Kind) 

297 

298 @readonly 

299 def Depth(self) -> int: 

300 """ 

301 Read-only property to access the nesting depth of the row's timespan (:attr:`_depth`). 

302 

303 :returns: The depth, where the trace is at depth 0. 

304 """ 

305 return self._depth 

306 

307 @readonly 

308 def Category(self) -> str: 

309 """ 

310 Read-only property to access the category of the row's timespan (:attr:`_category`). 

311 

312 :returns: The category, or the empty string. 

313 """ 

314 return self._category 

315 

316 

317@export 

318class CategoryStatistics(metaclass=ExtendedType, slots=True): 

319 """ 

320 The waiting and running times of the jobs of one category, e.g. of one runner image. 

321 """ 

322 _category: str #: The category. 

323 _waitTimes: list[float] #: Seconds each job waited for a runner. 

324 _runTimes: list[float] #: Seconds each job ran. 

325 

326 def __init__(self, category: str) -> None: 

327 """ 

328 Initializes the statistics of a category without jobs. 

329 

330 :param category: The category. 

331 """ 

332 self._category = category 

333 self._waitTimes = [] 

334 self._runTimes = [] 

335 

336 def _AddJob(self, waitTime: float, runTime: float) -> None: 

337 """ 

338 Add a job's times to the statistics. 

339 

340 :param waitTime: Seconds the job waited for a runner. 

341 :param runTime: Seconds the job ran. 

342 """ 

343 self._waitTimes.append(waitTime) 

344 self._runTimes.append(runTime) 

345 

346 @readonly 

347 def Category(self) -> str: 

348 """ 

349 Read-only property to access the category (:attr:`_category`). 

350 

351 :returns: The category. 

352 """ 

353 return self._category 

354 

355 @readonly 

356 def JobCount(self) -> int: 

357 """ 

358 Read-only property to return the number of jobs. 

359 

360 :returns: Number of jobs. 

361 """ 

362 return len(self._runTimes) 

363 

364 @readonly 

365 def WaitTimes(self) -> tuple[float, ...]: 

366 """ 

367 Read-only property to return the waiting times of all jobs (:attr:`_waitTimes`). 

368 

369 :returns: Seconds each job waited for a runner. 

370 """ 

371 return tuple(self._waitTimes) 

372 

373 @readonly 

374 def RunTimes(self) -> tuple[float, ...]: 

375 """ 

376 Read-only property to return the running times of all jobs (:attr:`_runTimes`). 

377 

378 :returns: Seconds each job ran. 

379 """ 

380 return tuple(self._runTimes) 

381 

382 @readonly 

383 def MinimumWaitTime(self) -> float: 

384 """ 

385 Read-only property to return the shortest time a job waited for a runner. 

386 

387 :returns: Seconds. 

388 """ 

389 return min(self._waitTimes) 

390 

391 @readonly 

392 def AverageWaitTime(self) -> float: 

393 """ 

394 Read-only property to return the average time a job waited for a runner. 

395 

396 :returns: Seconds. 

397 """ 

398 return fmean(self._waitTimes) 

399 

400 @readonly 

401 def MaximumWaitTime(self) -> float: 

402 """ 

403 Read-only property to return the longest time a job waited for a runner. 

404 

405 :returns: Seconds. 

406 """ 

407 return max(self._waitTimes) 

408 

409 @readonly 

410 def MinimumRunTime(self) -> float: 

411 """ 

412 Read-only property to return the shortest time a job ran. 

413 

414 :returns: Seconds. 

415 """ 

416 return min(self._runTimes) 

417 

418 @readonly 

419 def AverageRunTime(self) -> float: 

420 """ 

421 Read-only property to return the average time a job ran. 

422 

423 :returns: Seconds. 

424 """ 

425 return fmean(self._runTimes) 

426 

427 @readonly 

428 def MaximumRunTime(self) -> float: 

429 """ 

430 Read-only property to return the longest time a job ran. 

431 

432 :returns: Seconds. 

433 """ 

434 return max(self._runTimes) 

435 

436 @readonly 

437 def TotalRunTime(self) -> float: 

438 """ 

439 Read-only property to return the time all jobs ran, added up. 

440 

441 :returns: Seconds. 

442 """ 

443 return sum(self._runTimes) 

444 

445 

446@export 

447class GanttLayout(Diagram): 

448 """ 

449 The layout of a trace as a Gantt chart: one row per shown timespan, in the trace's tree order, and the statistics of 

450 the trace's jobs per category. 

451 

452 It is a :class:`~pyTooling.Diagram.Gantt.Diagram` whose origin is the time the trace began, so everything a chart 

453 is drawn from - rows, bars and the offsets between them - is answered by the diagram, and this class adds what 

454 only a trace has: the filter deciding which timespans are shown, and the statistics of its jobs. 

455 

456 A timespan of kind ``queued`` is drawn on the row of the job directly following it, if that job has the same task 

457 name - otherwise the job is still waiting, and the waiting timespan gets a row of its own. 

458 

459 The statistics count every job of the trace, which wasn't skipped and has a category - independently of the filter 

460 deciding which rows are shown. 

461 """ 

462 _trace: Trace #: The trace laid out. 

463 _now: datetime #: The time a running timespan's bar ends at. 

464 _spanFilter: Nullable[SpanFilter] #: The function deciding which timespans are shown, or ``None`` for all. 

465 _categorize: SpanCategory #: The function returning a timespan's category. 

466 _categories: dict[str, None] #: The categories of rows and statistics, as an ordered set. 

467 _statistics: dict[str, CategoryStatistics] #: The statistics of the jobs per category. 

468 _duration: float #: The end of the last bar in seconds after the trace began. 

469 

470 def __init__( 

471 self, 

472 trace: Trace, 

473 *, 

474 spanFilter: Nullable[SpanFilter] = None, 

475 categorize: SpanCategory = runnerCategory, 

476 now: Nullable[datetime] = None 

477 ) -> None: 

478 """ 

479 Initializes the layout of a trace. 

480 

481 :param trace: The trace to lay out. 

482 :param spanFilter: Optional, function deciding which timespans are shown. A hidden timespan hides its 

483 sub-spans. Default: all timespans are shown. 

484 :param categorize: Optional, function returning a timespan's category. Default: :func:`runnerCategory`. 

485 :param now: Optional, the time a running timespan's bar ends at. Default: the current system time. 

486 :raises TypeError: If parameter 'trace' is not of type :class:`~pyTooling.Tracing.Trace`. 

487 :raises TypeError: If parameter 'now' is not of type :class:`~datetime.datetime`. 

488 :raises TracingError: If the trace has no begin time. 

489 """ 

490 if not isinstance(trace, Trace): 

491 ex = TypeError("Parameter 'trace' is not of type 'Trace'.") 

492 ex.add_note(f"Got type '{getFullyQualifiedName(trace)}'.") 

493 raise ex 

494 elif (begin := trace.StartTime) is None: 

495 ex = TracingError(f"Trace '{trace.Name}' has no begin time, so it can't be laid out.") 

496 ex.add_note("Lay out a trace after it was entered, or construct it with recorded times.") 

497 raise ex 

498 

499 if now is None: 

500 now = datetime.now(begin.tzinfo) 

501 elif not isinstance(now, datetime): 501 ↛ 502line 501 didn't jump to line 502 because the condition on line 501 was never true

502 ex = TypeError("Parameter 'now' is not of type 'datetime'.") 

503 ex.add_note(f"Got type '{getFullyQualifiedName(now)}'.") 

504 raise ex 

505 

506 super().__init__(trace.Name, begin) 

507 

508 self._trace = trace 

509 self._now = now 

510 self._spanFilter = spanFilter 

511 self._categorize = categorize 

512 self._categories = {} 

513 self._statistics = {} 

514 self._duration = 0.0 

515 

516 self._AddRow(trace, 0, None) 

517 self._AddSubSpans(trace, 1) 

518 self._CollectStatistics(trace) 

519 

520 def _Offset(self, time: datetime) -> float: 

521 """ 

522 Convert a time into seconds after the trace began. 

523 

524 :param time: The time. 

525 :returns: Seconds after the trace began. 

526 """ 

527 return (time - self._trace.StartTime).total_seconds() 

528 

529 def _AddBar(self, span: Span, queued: bool, row: GanttRow) -> None: 

530 """ 

531 Append the bar of a timespan to a row, unless the timespan has no begin time. 

532 

533 :param span: The timespan. 

534 :param queued: The timespan is the time a job waited for a runner. 

535 :param row: The row the bar is appended to. 

536 """ 

537 if (begin := span.StartTime) is None: 

538 return 

539 

540 running = span.StopTime is None 

541 end = self._now if running else span.StopTime 

542 

543 bar = GanttBar(begin, max(end, begin), queued, running, parent=row) 

544 self._duration = max(self._duration, bar.EndSinceOriginInSeconds) 

545 

546 def _AddRow(self, span: Span, depth: int, queued: Nullable[Span]) -> None: 

547 """ 

548 Add a row for a timespan, with the bar of its waiting timespan in front, if given. 

549 

550 :param span: The timespan. 

551 :param depth: Nesting depth of the timespan. 

552 :param queued: The timespan the job waited for a runner in, or ``None``. 

553 """ 

554 category = self._categorize(span) 

555 if category != "": 

556 self._categories[category] = None 

557 

558 row = GanttRow(span, depth, category, parent=self) 

559 for barSpan, isQueued in ((queued, True), (span, span.get(CI.Span.Kind) == SpanKind.Queued)): 

560 if barSpan is not None: 

561 self._AddBar(barSpan, isQueued, row) 

562 

563 def _AddSubSpans(self, parent: Span, depth: int) -> None: 

564 """ 

565 Add rows for the shown sub-spans of a timespan, and recursively for theirs. 

566 

567 :param parent: The timespan. 

568 :param depth: Nesting depth of the sub-spans. 

569 """ 

570 pending: Nullable[Span] = None 

571 for span in parent.IterateSubSpans(): 

572 if self._spanFilter is not None and not self._spanFilter(span): 

573 continue 

574 

575 kind = span.get(CI.Span.Kind) 

576 if kind == SpanKind.Queued: 

577 if pending is not None: 577 ↛ 578line 577 didn't jump to line 578 because the condition on line 577 was never true

578 self._AddRow(pending, depth, None) 

579 pending = span 

580 continue 

581 

582 queued = None 

583 if pending is not None: 

584 taskName = span.get(OTLP.CICD.Pipeline.Task.Name) 

585 if kind == SpanKind.Job and taskName is not None and taskName == pending.get(OTLP.CICD.Pipeline.Task.Name): 

586 queued = pending 

587 else: 

588 self._AddRow(pending, depth, None) 

589 pending = None 

590 

591 self._AddRow(span, depth, queued) 

592 self._AddSubSpans(span, depth + 1) 

593 

594 if pending is not None: 

595 self._AddRow(pending, depth, None) 

596 

597 def _CollectStatistics(self, parent: Span) -> None: 

598 """ 

599 Add the waiting and running times of the jobs below a timespan to the statistics of their categories. 

600 

601 :param parent: The timespan. 

602 """ 

603 queued: dict[str, Span] = {} 

604 for span in parent.IterateSubSpans(): 

605 kind = span.get(CI.Span.Kind) 

606 result = span.get(OTLP.CICD.Pipeline.Task.Run.Result) 

607 if kind == SpanKind.Queued and (taskName := span.get(OTLP.CICD.Pipeline.Task.Name)) is not None: 

608 queued[taskName] = span 

609 elif kind == SpanKind.Job and result != Result.Skip and span.StartTime is not None: 

610 if (category := self._categorize(span)) != "": 

611 waiting = queued.get(span.get(OTLP.CICD.Pipeline.Task.Name), None) 

612 waitTime = 0.0 

613 if waiting is not None and waiting.StartTime is not None: 

614 waitTime = self._Offset(span.StartTime) - self._Offset(waiting.StartTime) 

615 runTime = self._Offset(self._now if span.StopTime is None else span.StopTime) - self._Offset(span.StartTime) 

616 

617 if category not in self._statistics: 

618 self._statistics[category] = CategoryStatistics(category) 

619 self._categories[category] = None 

620 self._statistics[category]._AddJob(max(waitTime, 0.0), max(runTime, 0.0)) 

621 

622 self._CollectStatistics(span) 

623 

624 @readonly 

625 def Trace(self) -> Trace: 

626 """ 

627 Read-only property to access the trace laid out (:attr:`_trace`). 

628 

629 :returns: The trace. 

630 """ 

631 return self._trace 

632 

633 @readonly 

634 def Now(self) -> datetime: 

635 """ 

636 Read-only property to access the time a running timespan's bar ends at (:attr:`_now`). 

637 

638 :returns: The current time of the layout. 

639 """ 

640 return self._now 

641 

642 @readonly 

643 def EndTime(self) -> datetime: 

644 """ 

645 Read-only property to return the time the trace ended, or the layout's current time while it is running. 

646 

647 :returns: The end time, with the trace's time zone. 

648 """ 

649 return self._now if self._trace.StopTime is None else self._trace.StopTime 

650 

651 @readonly 

652 def IsRunning(self) -> bool: 

653 """ 

654 Read-only property to return whether the trace is still running. 

655 

656 :returns: ``True``, if the trace has no end time. 

657 """ 

658 return self._trace.StopTime is None 

659 

660 @readonly 

661 def WallTime(self) -> float: 

662 """ 

663 Read-only property to return the time from the trace's begin to its end. 

664 

665 :returns: Seconds. 

666 """ 

667 return self._Offset(self.EndTime) 

668 

669 @readonly 

670 def RunnerTime(self) -> float: 

671 """ 

672 Read-only property to return the time all counted jobs ran, added up - the time runners were occupied. 

673 

674 :returns: Seconds. 

675 """ 

676 return sum(statistics.TotalRunTime for statistics in self._statistics.values()) 

677 

678 @readonly 

679 def JobCount(self) -> int: 

680 """ 

681 Read-only property to return the number of counted jobs. 

682 

683 :returns: Number of jobs, which weren't skipped and have a category. 

684 """ 

685 return sum(statistics.JobCount for statistics in self._statistics.values()) 

686 

687 @readonly 

688 def Duration(self) -> float: 

689 """ 

690 Read-only property to access the end of the last bar (:attr:`_duration`). 

691 

692 :returns: The end in seconds after the trace began. 

693 """ 

694 return self._duration 

695 

696 def IterateStatistics(self) -> Iterator[CategoryStatistics]: 

697 """ 

698 Returns an iterator to iterate the statistics of all categories with counted jobs, in the order of 

699 :attr:`Categories`. 

700 

701 :returns: Iterator to iterate the statistics. 

702 """ 

703 return (self._statistics[category] for category in self._categories if category in self._statistics) 

704 

705 @readonly 

706 def Categories(self) -> tuple[str, ...]: 

707 """ 

708 Read-only property to return the categories of all rows and statistics (:attr:`_categories`). 

709 

710 :returns: The categories in the order they first appear, without the empty string. 

711 """ 

712 return tuple(self._categories) 

713 

714 

715@export 

716@abstractclass 

717class Renderer(Generic[_FigureType], metaclass=ExtendedType, slots=True): 

718 """ 

719 Abstract base-class of a renderer drawing a :class:`GanttLayout`. 

720 

721 It holds what no drawing library decides: which layout is drawn, the chart's title, the color of every category, 

722 and the texts of the legend. A derived class draws what these describe and names the file formats it writes in 

723 :attr:`FORMATS` - :class:`~pyTooling.Tracing.Render.Matplotlib.MatplotlibRenderer` does so with matplotlib - so a 

724 second backend renders the same chart without repeating any of it. 

725 """ 

726 FORMATS: ClassVar[tuple[str, ...]] #: The file formats this renderer writes, by file suffix. 

727 

728 PALETTE: ClassVar[tuple[str, ...]] = ( 

729 "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#bcbd22", "#17becf" 

730 ) #: Colors of the categories, in the order the categories appear. 

731 NEUTRAL: ClassVar[str] = "#7f7f7f" #: Color of a bar without a category. 

732 LINE: ClassVar[str] = "#404040" #: Color of the line spanning the pipeline or a called workflow. 

733 QUEUED: ClassVar[str] = "#d3d3d3" #: Color of a bar showing the time a job waited for a runner. 

734 

735 _layout: GanttLayout #: The layout drawn by this renderer. 

736 _title: str #: The chart's title. 

737 _colors: dict[str, str] #: The color of every category of the layout. 

738 

739 def __init__(self, layout: GanttLayout, *, title: Nullable[str] = None) -> None: 

740 """ 

741 Initializes a renderer for a layout. 

742 

743 :param layout: The layout to draw. 

744 :param title: Optional, the chart's title. Default: the trace's name and wall time. 

745 :raises TypeError: If parameter 'layout' is not of type :class:`GanttLayout`. 

746 :raises TypeError: If parameter 'title' is not of type :class:`str`. 

747 """ 

748 if not isinstance(layout, GanttLayout): 748 ↛ 749line 748 didn't jump to line 749 because the condition on line 748 was never true

749 ex = TypeError("Parameter 'layout' is not of type 'GanttLayout'.") 

750 ex.add_note(f"Got type '{getFullyQualifiedName(layout)}'.") 

751 raise ex 

752 

753 if title is None: 

754 title = f"{layout.Title} ({self.FormatSeconds(layout.WallTime)})" 

755 elif not isinstance(title, str): 755 ↛ 756line 755 didn't jump to line 756 because the condition on line 755 was never true

756 ex = TypeError("Parameter 'title' is not of type 'str'.") 

757 ex.add_note(f"Got type '{getFullyQualifiedName(title)}'.") 

758 raise ex 

759 

760 self._layout = layout 

761 self._title = title 

762 self._colors = { 

763 category: self.PALETTE[position % len(self.PALETTE)] 

764 for position, category in enumerate(layout.Categories) 

765 } 

766 

767 @readonly 

768 def Layout(self) -> GanttLayout: 

769 """ 

770 Read-only property to access the layout drawn by this renderer (:attr:`_layout`). 

771 

772 :returns: The layout. 

773 """ 

774 return self._layout 

775 

776 @readonly 

777 def Title(self) -> str: 

778 """ 

779 Read-only property to access the chart's title (:attr:`_title`). 

780 

781 :returns: The title. 

782 """ 

783 return self._title 

784 

785 @readonly 

786 def CategoryWidth(self) -> int: 

787 """ 

788 Read-only property to return the width of the legend's category column. 

789 

790 :returns: Width in characters, at least as wide as the legend's own entries. 

791 """ 

792 return max([len(category) for category in self._layout.Categories] + [len(LINE_LEGEND_LABEL)]) 

793 

794 def Color(self, category: str) -> str: 

795 """ 

796 Return the color a category's bars are drawn in. 

797 

798 :param category: The category, or the empty string for a timespan without one. 

799 :returns: The color as a hex triplet, :attr:`NEUTRAL` for a category the layout doesn't know. 

800 """ 

801 return self._colors.get(category, self.NEUTRAL) 

802 

803 @staticmethod 

804 def FormatSeconds(seconds: float) -> str: 

805 """ 

806 Format seconds as minutes and seconds, or as hours, minutes and seconds from one hour on. 

807 

808 :param seconds: The seconds. 

809 :returns: The time as ``m:ss`` or ``h:mm:ss``. 

810 """ 

811 minutes, rest = divmod(int(round(seconds)), 60) 

812 if minutes < 60: 812 ↛ 815line 812 didn't jump to line 815 because the condition on line 812 was always true

813 return f"{minutes}:{rest:02d}" 

814 

815 hours, minutes = divmod(minutes, 60) 

816 return f"{hours}:{minutes:02d}:{rest:02d}" 

817 

818 @staticmethod 

819 def FormatTime(time: datetime) -> str: 

820 """ 

821 Format an absolute time with its time zone. 

822 

823 :param time: The time. 

824 :returns: The time as ``YYYY-MM-DD hh:mm:ss <zone>``. 

825 """ 

826 return f"{time:%Y-%m-%d %H:%M:%S} {time.tzname() or 'local time'}" 

827 

828 def LegendTitle(self) -> str: 

829 """ 

830 Compose the legend's title: the trace's times and totals, and the header of the statistics' columns. 

831 

832 :returns: The title's lines. 

833 """ 

834 layout = self._layout 

835 lines = [ 

836 f"started {self.FormatTime(layout.Origin)}", 

837 f"{'running at' if layout.IsRunning else 'finished':<11} {self.FormatTime(layout.EndTime)}", 

838 f"wall time {self.FormatSeconds(layout.WallTime)} runner time {self.FormatSeconds(layout.RunnerTime)}" 

839 f" {layout.JobCount} jobs", 

840 ] 

841 if layout.JobCount > 0: 

842 lines.append("") 

843 lines.append(f"{'':<{self.CategoryWidth}} jobs wait min / avg / max run min / avg / max") 

844 

845 return "\n".join(lines) 

846 

847 def LegendLabel(self, category: str) -> str: 

848 """ 

849 Compose the legend's label of a category: the category and the statistics of its jobs. 

850 

851 :param category: The category. 

852 :returns: The label. 

853 """ 

854 categoryWidth = self.CategoryWidth 

855 label = f"{category:<{categoryWidth}}" 

856 for entry in self._layout.IterateStatistics(): 

857 if entry.Category != category: 

858 continue 

859 

860 label += ( 

861 f" {entry.JobCount:>4} " 

862 f"{self.FormatSeconds(entry.MinimumWaitTime):>9} /{self.FormatSeconds(entry.AverageWaitTime):>5} /" 

863 f"{self.FormatSeconds(entry.MaximumWaitTime):>5} " 

864 f"{self.FormatSeconds(entry.MinimumRunTime):>11} /{self.FormatSeconds(entry.AverageRunTime):>5} /" 

865 f"{self.FormatSeconds(entry.MaximumRunTime):>5}" 

866 ) 

867 

868 return label 

869 

870 @abstractmethod 

871 def Render(self) -> _FigureType: 

872 """ 

873 Draw the layout as a Gantt chart. 

874 

875 :returns: The chart, as the backend represents a drawing. 

876 """ 

877 

878 def Write(self, file: Path) -> None: 

879 """ 

880 Draw the layout as a Gantt chart, and write the chart to a file. 

881 

882 The file format is chosen by the file's suffix, one of :attr:`FORMATS`. Missing parent directories are created. 

883 

884 :param file: Path of the file to write. 

885 :raises TypeError: If parameter 'file' is not of type :class:`~pathlib.Path`. 

886 :raises ValueError: If the file's suffix isn't one of :attr:`FORMATS`. 

887 :raises TracingError: If the parent directories couldn't be created. 

888 :raises TracingError: If the file couldn't be written. 

889 """ 

890 if not isinstance(file, Path): 

891 ex = TypeError("Parameter 'file' is not of type 'Path'.") 

892 ex.add_note(f"Got type '{getFullyQualifiedName(file)}'.") 

893 raise ex 

894 elif (fileFormat := file.suffix.lower().lstrip(".")) not in self.FORMATS: 

895 ex = ValueError(f"File '{file}' has an unsupported format.") 

896 ex.add_note(f"Supported file suffixes: {', '.join(f'.{suffix}' for suffix in self.FORMATS)}") 

897 raise ex 

898 

899 figure = self.Render() 

900 

901 try: 

902 file.parent.mkdir(parents=True, exist_ok=True) 

903 except OSError as ex: 

904 raise TracingError(f"Directory '{file.parent}' couldn't be created.") from ex 

905 

906 try: 

907 self._Write(figure, file, fileFormat) 

908 except OSError as ex: 

909 raise TracingError(f"File '{file}' couldn't be written.") from ex 

910 

911 @abstractmethod 

912 def _Write(self, figure: _FigureType, file: Path, fileFormat: str) -> None: 

913 """ 

914 Write a drawn chart to a file. 

915 

916 :param figure: The chart, as :meth:`Render` returned it. 

917 :param file: Path of the file to write, whose parent directories exist. 

918 :param fileFormat: The file format, one of :attr:`FORMATS`. 

919 :raises OSError: If the file couldn't be written. 

920 """