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

368 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-25 12:33 +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 typing import Callable, ClassVar, Generic, Iterator, Optional as Nullable, TypeVar, Union 

49 

50from pyTooling.Decorators import export, readonly 

51from pyTooling.MetaClasses import ExtendedType, abstractclass, abstractmethod 

52from pyTooling.Common import getFullyQualifiedName 

53from pyTooling.DataModel import Predicate 

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

55from pyTooling.Tracing import Span, Trace, TracingError 

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

57 

58 

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

60 

61_FigureType = TypeVar("_FigureType") 

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

63 

64LINE_LEGEND_LABEL = "pipeline, called workflow" 

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

66 

67SpanFilter = Predicate[Span] 

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

69 

70SpanCategory = Callable[[Span], str] 

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

72 

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

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

75 

76 

77@export 

78class StepExclusion(Enum): 

79 """ 

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

81 """ 

82 Nothing = 0 #: Show every step. 

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

84 All = 2 #: Hide every step. 

85 

86 

87@export 

88def ciSpanFilter( 

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

90 excludeSkippedJobs: bool = True 

91) -> SpanFilter: 

92 """ 

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

94 

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

96 skipped job has neither waited nor run. 

97 

98 :param excludeSteps: Optional, which steps to hide; ``True`` stands for :attr:`StepExclusion.All`, ``False`` for 

99 :attr:`StepExclusion.Nothing`. Default: :attr:`StepExclusion.All`. 

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

101 :returns: The span filter. 

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

103 """ 

104 if isinstance(excludeSteps, bool): 

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

106 elif not isinstance(excludeSteps, StepExclusion): 

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

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

109 raise ex 

110 

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

112 """ 

113 Nested function hiding steps and skipped jobs as configured. 

114 

115 :param span: The timespan. 

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

117 """ 

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

119 if kind == SpanKind.Step: 

120 if excludeSteps is StepExclusion.All: 

121 return False 

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

123 elif kind == SpanKind.Job and excludeSkippedJobs: 

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

125 

126 return True 

127 

128 return spanFilter 

129 

130 

131@export 

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

133 """ 

134 Return the MSYS2 environment a CI job used. 

135 

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

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

138 

139 :param job: The job's timespan. 

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

141 """ 

142 for step in job.IterateSubSpans(): 

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

144 continue 

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

146 continue 

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

148 return environment 

149 

150 return None 

151 

152 

153@export 

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

155 """ 

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

157 

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

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

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

161 

162 :param span: The timespan. 

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

164 """ 

165 label = "" 

166 job: Nullable[Span] = None 

167 current: Nullable[Span] = span 

168 while current is not None: 

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

170 job = current 

171 

172 # the key pyTooling.GitHub's trace reader stores a job's runner labels under 

173 if label == "" and "github.runner.labels" in current and len(labels := current["github.runner.labels"]) > 0: 

174 label = str(labels[0]) 

175 current = current.Parent 

176 

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

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

179 

180 return label 

181 

182 

183@export 

184class GanttBar(Bar): 

185 """ 

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

187 

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

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

190 """ 

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

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

193 

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

195 """ 

196 Initializes a bar and appends it to its row. 

197 

198 :param begin: Begin of the bar. 

199 :param end: End of the bar. 

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

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

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

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

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

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

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

207 """ 

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

209 

210 self._queued = queued 

211 self._running = running 

212 

213 @readonly 

214 def IsQueued(self) -> bool: 

215 """ 

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

217 

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

219 """ 

220 return self._queued 

221 

222 @readonly 

223 def IsRunning(self) -> bool: 

224 """ 

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

226 

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

228 """ 

229 return self._running 

230 

231 

232@export 

233class GanttRow(Row): 

234 """ 

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

236 

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

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

239 """ 

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

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

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

243 

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

245 """ 

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

247 

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

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

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

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

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

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

254 """ 

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

256 

257 self._span = span 

258 self._depth = depth 

259 self._category = category 

260 

261 @readonly 

262 def Span(self) -> Span: 

263 """ 

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

265 

266 :returns: The timespan. 

267 """ 

268 return self._span 

269 

270 @readonly 

271 def SpanID(self) -> str: 

272 """ 

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

274 

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

276 """ 

277 return self._span.SpanID 

278 

279 @readonly 

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

281 """ 

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

283 

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

285 """ 

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

287 

288 @readonly 

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

290 """ 

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

292 

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

294 """ 

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

296 

297 @readonly 

298 def Depth(self) -> int: 

299 """ 

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

301 

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

303 """ 

304 return self._depth 

305 

306 @readonly 

307 def Category(self) -> str: 

308 """ 

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

310 

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

312 """ 

313 return self._category 

314 

315 

316@export 

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

318 """ 

319 The waiting and running times of the jobs of one category. 

320 

321 A category is e.g. a runner image. 

322 """ 

323 _category: str #: The category. 

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

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

326 

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

328 """ 

329 Initializes the statistics of a category without jobs. 

330 

331 :param category: The category. 

332 """ 

333 self._category = category 

334 self._waitTimes = [] 

335 self._runTimes = [] 

336 

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

338 """ 

339 Add a job's times to the statistics. 

340 

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

342 :param runTime: Seconds the job ran. 

343 """ 

344 self._waitTimes.append(waitTime) 

345 self._runTimes.append(runTime) 

346 

347 @readonly 

348 def Category(self) -> str: 

349 """ 

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

351 

352 :returns: The category. 

353 """ 

354 return self._category 

355 

356 @readonly 

357 def JobCount(self) -> int: 

358 """ 

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

360 

361 :returns: Number of jobs. 

362 """ 

363 return len(self._runTimes) 

364 

365 @readonly 

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

367 """ 

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

369 

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

371 """ 

372 return tuple(self._waitTimes) 

373 

374 @readonly 

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

376 """ 

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

378 

379 :returns: Seconds each job ran. 

380 """ 

381 return tuple(self._runTimes) 

382 

383 @readonly 

384 def MinimumWaitTime(self) -> float: 

385 """ 

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

387 

388 :returns: Seconds. 

389 """ 

390 return min(self._waitTimes) 

391 

392 @readonly 

393 def AverageWaitTime(self) -> float: 

394 """ 

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

396 

397 :returns: Seconds. 

398 """ 

399 from statistics import fmean 

400 

401 return fmean(self._waitTimes) 

402 

403 @readonly 

404 def MaximumWaitTime(self) -> float: 

405 """ 

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

407 

408 :returns: Seconds. 

409 """ 

410 return max(self._waitTimes) 

411 

412 @readonly 

413 def MinimumRunTime(self) -> float: 

414 """ 

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

416 

417 :returns: Seconds. 

418 """ 

419 return min(self._runTimes) 

420 

421 @readonly 

422 def AverageRunTime(self) -> float: 

423 """ 

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

425 

426 :returns: Seconds. 

427 """ 

428 from statistics import fmean 

429 

430 return fmean(self._runTimes) 

431 

432 @readonly 

433 def MaximumRunTime(self) -> float: 

434 """ 

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

436 

437 :returns: Seconds. 

438 """ 

439 return max(self._runTimes) 

440 

441 @readonly 

442 def TotalRunTime(self) -> float: 

443 """ 

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

445 

446 :returns: Seconds. 

447 """ 

448 return sum(self._runTimes) 

449 

450 

451@export 

452class GanttLayout(Diagram): 

453 """ 

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

455 the trace's jobs per category. 

456 

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

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

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

460 

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

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

463 

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

465 deciding which rows are shown. 

466 """ 

467 _trace: Trace #: The trace laid out. 

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

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

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

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

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

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

474 

475 def __init__( 

476 self, 

477 trace: Trace, 

478 *, 

479 spanFilter: Nullable[SpanFilter] = None, 

480 categorize: SpanCategory = runnerCategory, 

481 now: Nullable[datetime] = None 

482 ) -> None: 

483 """ 

484 Initializes the layout of a trace. 

485 

486 :param trace: The trace to lay out. 

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

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

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

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

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

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

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

494 """ 

495 if not isinstance(trace, Trace): 

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

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

498 raise ex 

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

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

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

502 raise ex 

503 

504 if now is None: 

505 now = datetime.now(begin.tzinfo) 

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

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

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

509 raise ex 

510 

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

512 

513 self._trace = trace 

514 self._now = now 

515 self._spanFilter = spanFilter 

516 self._categorize = categorize 

517 self._categories = {} 

518 self._statistics = {} 

519 self._duration = 0.0 

520 

521 self._AddRow(trace, 0, None) 

522 self._AddSubSpans(trace, 1) 

523 self._CollectStatistics(trace) 

524 

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

526 """ 

527 Convert a time into seconds after the trace began. 

528 

529 :param time: The time. 

530 :returns: Seconds after the trace began. 

531 """ 

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

533 

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

535 """ 

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

537 

538 :param span: The timespan. 

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

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

541 """ 

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

543 return 

544 

545 running = span.StopTime is None 

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

547 

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

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

550 

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

552 """ 

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

554 

555 :param span: The timespan. 

556 :param depth: Nesting depth of the timespan. 

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

558 """ 

559 category = self._categorize(span) 

560 if category != "": 

561 self._categories[category] = None 

562 

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

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

565 if barSpan is not None: 

566 self._AddBar(barSpan, isQueued, row) 

567 

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

569 """ 

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

571 

572 :param parent: The timespan. 

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

574 """ 

575 pending: Nullable[Span] = None 

576 for span in parent.IterateSubSpans(): 

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

578 continue 

579 

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

581 if kind == SpanKind.Queued: 

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

583 self._AddRow(pending, depth, None) 

584 pending = span 

585 continue 

586 

587 queued = None 

588 if pending is not None: 

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

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

591 queued = pending 

592 else: 

593 self._AddRow(pending, depth, None) 

594 pending = None 

595 

596 self._AddRow(span, depth, queued) 

597 self._AddSubSpans(span, depth + 1) 

598 

599 if pending is not None: 

600 self._AddRow(pending, depth, None) 

601 

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

603 """ 

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

605 

606 :param parent: The timespan. 

607 """ 

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

609 for span in parent.IterateSubSpans(): 

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

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

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

613 queued[taskName] = span 

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

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

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

617 waitTime = 0.0 

618 if waiting is not None and waiting.StartTime is not None: 618 ↛ 620line 618 didn't jump to line 620 because the condition on line 618 was always true

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

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

621 

622 if category not in self._statistics: 

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

624 self._categories[category] = None 

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

626 

627 self._CollectStatistics(span) 

628 

629 @readonly 

630 def Trace(self) -> Trace: 

631 """ 

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

633 

634 :returns: The trace. 

635 """ 

636 return self._trace 

637 

638 @readonly 

639 def Now(self) -> datetime: 

640 """ 

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

642 

643 :returns: The current time of the layout. 

644 """ 

645 return self._now 

646 

647 @readonly 

648 def EndTime(self) -> datetime: 

649 """ 

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

651 

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

653 """ 

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

655 

656 @readonly 

657 def IsRunning(self) -> bool: 

658 """ 

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

660 

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

662 """ 

663 return self._trace.StopTime is None 

664 

665 @readonly 

666 def WallTime(self) -> float: 

667 """ 

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

669 

670 :returns: Seconds. 

671 """ 

672 return self._Offset(self.EndTime) 

673 

674 @readonly 

675 def RunnerTime(self) -> float: 

676 """ 

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

678 

679 :returns: Seconds. 

680 """ 

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

682 

683 @readonly 

684 def JobCount(self) -> int: 

685 """ 

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

687 

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

689 """ 

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

691 

692 @readonly 

693 def Duration(self) -> timedelta: 

694 """ 

695 Read-only property to return the end of the last bar (:attr:`_duration`) as a time after the trace began. 

696 

697 :returns: The end after the trace began. 

698 """ 

699 return timedelta(seconds=self._duration) 

700 

701 @readonly 

702 def DurationInSeconds(self) -> float: 

703 """ 

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

705 

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

707 """ 

708 return self._duration 

709 

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

711 """ 

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

713 :attr:`Categories`. 

714 

715 :returns: Iterator to iterate the statistics. 

716 """ 

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

718 

719 @readonly 

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

721 """ 

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

723 

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

725 """ 

726 return tuple(self._categories) 

727 

728 

729@export 

730@abstractclass 

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

732 """ 

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

734 

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

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

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

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

739 """ 

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

741 

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

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

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

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

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

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

748 

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

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

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

752 

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

754 """ 

755 Initializes a renderer for a layout. 

756 

757 :param layout: The layout to draw. 

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

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

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

761 """ 

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

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

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

765 raise ex 

766 

767 if title is None: 

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

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

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

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

772 raise ex 

773 

774 self._layout = layout 

775 self._title = title 

776 self._colors = { 

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

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

779 } 

780 

781 @readonly 

782 def Layout(self) -> GanttLayout: 

783 """ 

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

785 

786 :returns: The layout. 

787 """ 

788 return self._layout 

789 

790 @readonly 

791 def Title(self) -> str: 

792 """ 

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

794 

795 :returns: The title. 

796 """ 

797 return self._title 

798 

799 @readonly 

800 def CategoryWidth(self) -> int: 

801 """ 

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

803 

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

805 """ 

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

807 

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

809 """ 

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

811 

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

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

814 """ 

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

816 

817 @staticmethod 

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

819 """ 

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

821 

822 :param seconds: The seconds. 

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

824 """ 

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

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

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

828 

829 hours, minutes = divmod(minutes, 60) 

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

831 

832 @staticmethod 

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

834 """ 

835 Format an absolute time with its time zone. 

836 

837 :param time: The time. 

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

839 """ 

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

841 

842 def LegendTitle(self) -> str: 

843 """ 

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

845 

846 :returns: The title's lines. 

847 """ 

848 layout = self._layout 

849 lines = [ 

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

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

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

853 f" {layout.JobCount} jobs", 

854 ] 

855 if layout.JobCount > 0: 

856 lines.append("") 

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

858 

859 return "\n".join(lines) 

860 

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

862 """ 

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

864 

865 :param category: The category. 

866 :returns: The label. 

867 """ 

868 categoryWidth = self.CategoryWidth 

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

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

871 if entry.Category != category: 

872 continue 

873 

874 label += ( 

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

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

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

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

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

880 ) 

881 

882 return label 

883 

884 @abstractmethod 

885 def Render(self) -> _FigureType: 

886 """ 

887 Draw the layout as a Gantt chart. 

888 

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

890 """ 

891 

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

893 """ 

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

895 

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

897 

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

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

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

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

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

903 """ 

904 if not isinstance(file, Path): 

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

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

907 raise ex 

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

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

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

911 raise ex 

912 

913 figure = self.Render() 

914 

915 try: 

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

917 except OSError as ex: 

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

919 

920 try: 

921 self._Write(figure, file, fileFormat) 

922 except OSError as ex: 

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

924 

925 @abstractmethod 

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

927 """ 

928 Write a drawn chart to a file. 

929 

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

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

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

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

934 """