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
« 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.
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.
38.. hint::
40 See :ref:`high-level help <TRACING/Render>` for explanations and usage examples.
41"""
42from __future__ import annotations
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
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
60__all__ = ["SpanFilter", "SpanCategory", "MSYS2_SETUP_STEP", "LINE_LEGEND_LABEL"]
62_FigureType = TypeVar("_FigureType")
63"""Type of the drawing a renderer's backend produces, e.g. matplotlib's :class:`~matplotlib.figure.Figure`."""
65LINE_LEGEND_LABEL = "pipeline, called workflow"
66"""The legend's label of the line drawn for the pipeline and every called workflow."""
68SpanFilter = Callable[[Span], bool]
69"""A function deciding whether a timespan - and with it, its sub-spans - is shown."""
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."""
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``."""
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.
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.
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.
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
113 def spanFilter(span: Span) -> bool:
114 """
115 Nested function hiding steps and skipped jobs as configured.
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
128 return True
130 return spanFilter
133@export
134def msys2Environment(job: Span) -> Nullable[str]:
135 """
136 Return the MSYS2 environment a CI job used.
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``.
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
152 return None
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.
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.
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
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
178 if label != "" and job is not None and (environment := msys2Environment(job)) is not None:
179 return f"{label} + {environment}"
181 return label
184@export
185class GanttBar(Bar):
186 """
187 A bar of a trace's Gantt chart: the time range one timespan occupies.
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.
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.
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)
211 self._queued = queued
212 self._running = running
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`).
219 :returns: ``True``, if the bar shows waiting.
220 """
221 return self._queued
223 @readonly
224 def IsRunning(self) -> bool:
225 """
226 Read-only property to access whether the bar's timespan is still running (:attr:`_running`).
228 :returns: ``True``, if the bar ends at the layout's current time.
229 """
230 return self._running
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.
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.
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.
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)
258 self._span = span
259 self._depth = depth
260 self._category = category
262 @readonly
263 def Span(self) -> Span:
264 """
265 Read-only property to access the timespan shown by the row (:attr:`_span`).
267 :returns: The timespan.
268 """
269 return self._span
271 @readonly
272 def SpanID(self) -> str:
273 """
274 Read-only property to access the identifier of the row's timespan.
276 :returns: The timespan's identifier, as 16 hex digits.
277 """
278 return self._span.SpanID
280 @readonly
281 def ParentSpanID(self) -> Nullable[str]:
282 """
283 Read-only property to return the identifier of the row's parent timespan.
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
289 @readonly
290 def Kind(self) -> Nullable[str]:
291 """
292 Read-only property to return the CI span kind of the row's timespan.
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)
298 @readonly
299 def Depth(self) -> int:
300 """
301 Read-only property to access the nesting depth of the row's timespan (:attr:`_depth`).
303 :returns: The depth, where the trace is at depth 0.
304 """
305 return self._depth
307 @readonly
308 def Category(self) -> str:
309 """
310 Read-only property to access the category of the row's timespan (:attr:`_category`).
312 :returns: The category, or the empty string.
313 """
314 return self._category
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.
326 def __init__(self, category: str) -> None:
327 """
328 Initializes the statistics of a category without jobs.
330 :param category: The category.
331 """
332 self._category = category
333 self._waitTimes = []
334 self._runTimes = []
336 def _AddJob(self, waitTime: float, runTime: float) -> None:
337 """
338 Add a job's times to the statistics.
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)
346 @readonly
347 def Category(self) -> str:
348 """
349 Read-only property to access the category (:attr:`_category`).
351 :returns: The category.
352 """
353 return self._category
355 @readonly
356 def JobCount(self) -> int:
357 """
358 Read-only property to return the number of jobs.
360 :returns: Number of jobs.
361 """
362 return len(self._runTimes)
364 @readonly
365 def WaitTimes(self) -> tuple[float, ...]:
366 """
367 Read-only property to return the waiting times of all jobs (:attr:`_waitTimes`).
369 :returns: Seconds each job waited for a runner.
370 """
371 return tuple(self._waitTimes)
373 @readonly
374 def RunTimes(self) -> tuple[float, ...]:
375 """
376 Read-only property to return the running times of all jobs (:attr:`_runTimes`).
378 :returns: Seconds each job ran.
379 """
380 return tuple(self._runTimes)
382 @readonly
383 def MinimumWaitTime(self) -> float:
384 """
385 Read-only property to return the shortest time a job waited for a runner.
387 :returns: Seconds.
388 """
389 return min(self._waitTimes)
391 @readonly
392 def AverageWaitTime(self) -> float:
393 """
394 Read-only property to return the average time a job waited for a runner.
396 :returns: Seconds.
397 """
398 return fmean(self._waitTimes)
400 @readonly
401 def MaximumWaitTime(self) -> float:
402 """
403 Read-only property to return the longest time a job waited for a runner.
405 :returns: Seconds.
406 """
407 return max(self._waitTimes)
409 @readonly
410 def MinimumRunTime(self) -> float:
411 """
412 Read-only property to return the shortest time a job ran.
414 :returns: Seconds.
415 """
416 return min(self._runTimes)
418 @readonly
419 def AverageRunTime(self) -> float:
420 """
421 Read-only property to return the average time a job ran.
423 :returns: Seconds.
424 """
425 return fmean(self._runTimes)
427 @readonly
428 def MaximumRunTime(self) -> float:
429 """
430 Read-only property to return the longest time a job ran.
432 :returns: Seconds.
433 """
434 return max(self._runTimes)
436 @readonly
437 def TotalRunTime(self) -> float:
438 """
439 Read-only property to return the time all jobs ran, added up.
441 :returns: Seconds.
442 """
443 return sum(self._runTimes)
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.
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.
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.
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.
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.
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
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
506 super().__init__(trace.Name, begin)
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
516 self._AddRow(trace, 0, None)
517 self._AddSubSpans(trace, 1)
518 self._CollectStatistics(trace)
520 def _Offset(self, time: datetime) -> float:
521 """
522 Convert a time into seconds after the trace began.
524 :param time: The time.
525 :returns: Seconds after the trace began.
526 """
527 return (time - self._trace.StartTime).total_seconds()
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.
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
540 running = span.StopTime is None
541 end = self._now if running else span.StopTime
543 bar = GanttBar(begin, max(end, begin), queued, running, parent=row)
544 self._duration = max(self._duration, bar.EndSinceOriginInSeconds)
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.
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
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)
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.
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
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
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
591 self._AddRow(span, depth, queued)
592 self._AddSubSpans(span, depth + 1)
594 if pending is not None:
595 self._AddRow(pending, depth, None)
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.
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)
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))
622 self._CollectStatistics(span)
624 @readonly
625 def Trace(self) -> Trace:
626 """
627 Read-only property to access the trace laid out (:attr:`_trace`).
629 :returns: The trace.
630 """
631 return self._trace
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`).
638 :returns: The current time of the layout.
639 """
640 return self._now
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.
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
651 @readonly
652 def IsRunning(self) -> bool:
653 """
654 Read-only property to return whether the trace is still running.
656 :returns: ``True``, if the trace has no end time.
657 """
658 return self._trace.StopTime is None
660 @readonly
661 def WallTime(self) -> float:
662 """
663 Read-only property to return the time from the trace's begin to its end.
665 :returns: Seconds.
666 """
667 return self._Offset(self.EndTime)
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.
674 :returns: Seconds.
675 """
676 return sum(statistics.TotalRunTime for statistics in self._statistics.values())
678 @readonly
679 def JobCount(self) -> int:
680 """
681 Read-only property to return the number of counted jobs.
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())
687 @readonly
688 def Duration(self) -> float:
689 """
690 Read-only property to access the end of the last bar (:attr:`_duration`).
692 :returns: The end in seconds after the trace began.
693 """
694 return self._duration
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`.
701 :returns: Iterator to iterate the statistics.
702 """
703 return (self._statistics[category] for category in self._categories if category in self._statistics)
705 @readonly
706 def Categories(self) -> tuple[str, ...]:
707 """
708 Read-only property to return the categories of all rows and statistics (:attr:`_categories`).
710 :returns: The categories in the order they first appear, without the empty string.
711 """
712 return tuple(self._categories)
715@export
716@abstractclass
717class Renderer(Generic[_FigureType], metaclass=ExtendedType, slots=True):
718 """
719 Abstract base-class of a renderer drawing a :class:`GanttLayout`.
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.
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.
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.
739 def __init__(self, layout: GanttLayout, *, title: Nullable[str] = None) -> None:
740 """
741 Initializes a renderer for a layout.
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
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
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 }
767 @readonly
768 def Layout(self) -> GanttLayout:
769 """
770 Read-only property to access the layout drawn by this renderer (:attr:`_layout`).
772 :returns: The layout.
773 """
774 return self._layout
776 @readonly
777 def Title(self) -> str:
778 """
779 Read-only property to access the chart's title (:attr:`_title`).
781 :returns: The title.
782 """
783 return self._title
785 @readonly
786 def CategoryWidth(self) -> int:
787 """
788 Read-only property to return the width of the legend's category column.
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)])
794 def Color(self, category: str) -> str:
795 """
796 Return the color a category's bars are drawn in.
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)
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.
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}"
815 hours, minutes = divmod(minutes, 60)
816 return f"{hours}:{minutes:02d}:{rest:02d}"
818 @staticmethod
819 def FormatTime(time: datetime) -> str:
820 """
821 Format an absolute time with its time zone.
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'}"
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.
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")
845 return "\n".join(lines)
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.
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
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 )
868 return label
870 @abstractmethod
871 def Render(self) -> _FigureType:
872 """
873 Draw the layout as a Gantt chart.
875 :returns: The chart, as the backend represents a drawing.
876 """
878 def Write(self, file: Path) -> None:
879 """
880 Draw the layout as a Gantt chart, and write the chart to a file.
882 The file format is chosen by the file's suffix, one of :attr:`FORMATS`. Missing parent directories are created.
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
899 figure = self.Render()
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
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
911 @abstractmethod
912 def _Write(self, figure: _FigureType, file: Path, fileFormat: str) -> None:
913 """
914 Write a drawn chart to a file.
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 """