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
« 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.
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 typing import Callable, ClassVar, Generic, Iterator, Optional as Nullable, TypeVar, Union
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
59__all__ = ["SpanFilter", "SpanCategory", "MSYS2_SETUP_STEP", "LINE_LEGEND_LABEL"]
61_FigureType = TypeVar("_FigureType")
62"""Type of the drawing a renderer's backend produces, e.g. matplotlib's :class:`~matplotlib.figure.Figure`."""
64LINE_LEGEND_LABEL = "pipeline, called workflow"
65"""The legend's label of the line drawn for the pipeline and every called workflow."""
67SpanFilter = Predicate[Span]
68"""A function deciding whether a timespan - and with it, its sub-spans - is shown."""
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."""
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``."""
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.
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.
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.
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
111 def spanFilter(span: Span) -> bool:
112 """
113 Nested function hiding steps and skipped jobs as configured.
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
126 return True
128 return spanFilter
131@export
132def msys2Environment(job: Span) -> Nullable[str]:
133 """
134 Return the MSYS2 environment a CI job used.
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``.
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
150 return None
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.
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.
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
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
177 if label != "" and job is not None and (environment := msys2Environment(job)) is not None:
178 return f"{label} + {environment}"
180 return label
183@export
184class GanttBar(Bar):
185 """
186 A bar of a trace's Gantt chart: the time range one timespan occupies.
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.
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.
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)
210 self._queued = queued
211 self._running = running
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`).
218 :returns: ``True``, if the bar shows waiting.
219 """
220 return self._queued
222 @readonly
223 def IsRunning(self) -> bool:
224 """
225 Read-only property to access whether the bar's timespan is still running (:attr:`_running`).
227 :returns: ``True``, if the bar ends at the layout's current time.
228 """
229 return self._running
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.
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.
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.
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)
257 self._span = span
258 self._depth = depth
259 self._category = category
261 @readonly
262 def Span(self) -> Span:
263 """
264 Read-only property to access the timespan shown by the row (:attr:`_span`).
266 :returns: The timespan.
267 """
268 return self._span
270 @readonly
271 def SpanID(self) -> str:
272 """
273 Read-only property to access the identifier of the row's timespan.
275 :returns: The timespan's identifier, as 16 hex digits.
276 """
277 return self._span.SpanID
279 @readonly
280 def ParentSpanID(self) -> Nullable[str]:
281 """
282 Read-only property to return the identifier of the row's parent timespan.
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
288 @readonly
289 def Kind(self) -> Nullable[str]:
290 """
291 Read-only property to return the CI span kind of the row's timespan.
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)
297 @readonly
298 def Depth(self) -> int:
299 """
300 Read-only property to access the nesting depth of the row's timespan (:attr:`_depth`).
302 :returns: The depth, where the trace is at depth 0.
303 """
304 return self._depth
306 @readonly
307 def Category(self) -> str:
308 """
309 Read-only property to access the category of the row's timespan (:attr:`_category`).
311 :returns: The category, or the empty string.
312 """
313 return self._category
316@export
317class CategoryStatistics(metaclass=ExtendedType, slots=True):
318 """
319 The waiting and running times of the jobs of one category.
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.
327 def __init__(self, category: str) -> None:
328 """
329 Initializes the statistics of a category without jobs.
331 :param category: The category.
332 """
333 self._category = category
334 self._waitTimes = []
335 self._runTimes = []
337 def _AddJob(self, waitTime: float, runTime: float) -> None:
338 """
339 Add a job's times to the statistics.
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)
347 @readonly
348 def Category(self) -> str:
349 """
350 Read-only property to access the category (:attr:`_category`).
352 :returns: The category.
353 """
354 return self._category
356 @readonly
357 def JobCount(self) -> int:
358 """
359 Read-only property to return the number of jobs.
361 :returns: Number of jobs.
362 """
363 return len(self._runTimes)
365 @readonly
366 def WaitTimes(self) -> tuple[float, ...]:
367 """
368 Read-only property to return the waiting times of all jobs (:attr:`_waitTimes`).
370 :returns: Seconds each job waited for a runner.
371 """
372 return tuple(self._waitTimes)
374 @readonly
375 def RunTimes(self) -> tuple[float, ...]:
376 """
377 Read-only property to return the running times of all jobs (:attr:`_runTimes`).
379 :returns: Seconds each job ran.
380 """
381 return tuple(self._runTimes)
383 @readonly
384 def MinimumWaitTime(self) -> float:
385 """
386 Read-only property to return the shortest time a job waited for a runner.
388 :returns: Seconds.
389 """
390 return min(self._waitTimes)
392 @readonly
393 def AverageWaitTime(self) -> float:
394 """
395 Read-only property to return the average time a job waited for a runner.
397 :returns: Seconds.
398 """
399 from statistics import fmean
401 return fmean(self._waitTimes)
403 @readonly
404 def MaximumWaitTime(self) -> float:
405 """
406 Read-only property to return the longest time a job waited for a runner.
408 :returns: Seconds.
409 """
410 return max(self._waitTimes)
412 @readonly
413 def MinimumRunTime(self) -> float:
414 """
415 Read-only property to return the shortest time a job ran.
417 :returns: Seconds.
418 """
419 return min(self._runTimes)
421 @readonly
422 def AverageRunTime(self) -> float:
423 """
424 Read-only property to return the average time a job ran.
426 :returns: Seconds.
427 """
428 from statistics import fmean
430 return fmean(self._runTimes)
432 @readonly
433 def MaximumRunTime(self) -> float:
434 """
435 Read-only property to return the longest time a job ran.
437 :returns: Seconds.
438 """
439 return max(self._runTimes)
441 @readonly
442 def TotalRunTime(self) -> float:
443 """
444 Read-only property to return the time all jobs ran, added up.
446 :returns: Seconds.
447 """
448 return sum(self._runTimes)
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.
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.
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.
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.
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.
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
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
511 super().__init__(trace.Name, begin)
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
521 self._AddRow(trace, 0, None)
522 self._AddSubSpans(trace, 1)
523 self._CollectStatistics(trace)
525 def _Offset(self, time: datetime) -> float:
526 """
527 Convert a time into seconds after the trace began.
529 :param time: The time.
530 :returns: Seconds after the trace began.
531 """
532 return (time - self._trace.StartTime).total_seconds()
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.
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
545 running = span.StopTime is None
546 end = self._now if running else span.StopTime
548 bar = GanttBar(begin, max(end, begin), queued, running, parent=row)
549 self._duration = max(self._duration, bar.EndSinceOriginInSeconds)
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.
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
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)
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.
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
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
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
596 self._AddRow(span, depth, queued)
597 self._AddSubSpans(span, depth + 1)
599 if pending is not None:
600 self._AddRow(pending, depth, None)
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.
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)
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))
627 self._CollectStatistics(span)
629 @readonly
630 def Trace(self) -> Trace:
631 """
632 Read-only property to access the trace laid out (:attr:`_trace`).
634 :returns: The trace.
635 """
636 return self._trace
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`).
643 :returns: The current time of the layout.
644 """
645 return self._now
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.
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
656 @readonly
657 def IsRunning(self) -> bool:
658 """
659 Read-only property to return whether the trace is still running.
661 :returns: ``True``, if the trace has no end time.
662 """
663 return self._trace.StopTime is None
665 @readonly
666 def WallTime(self) -> float:
667 """
668 Read-only property to return the time from the trace's begin to its end.
670 :returns: Seconds.
671 """
672 return self._Offset(self.EndTime)
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.
679 :returns: Seconds.
680 """
681 return sum(statistics.TotalRunTime for statistics in self._statistics.values())
683 @readonly
684 def JobCount(self) -> int:
685 """
686 Read-only property to return the number of counted jobs.
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())
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.
697 :returns: The end after the trace began.
698 """
699 return timedelta(seconds=self._duration)
701 @readonly
702 def DurationInSeconds(self) -> float:
703 """
704 Read-only property to access the end of the last bar (:attr:`_duration`).
706 :returns: The end in seconds after the trace began.
707 """
708 return self._duration
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`.
715 :returns: Iterator to iterate the statistics.
716 """
717 return (self._statistics[category] for category in self._categories if category in self._statistics)
719 @readonly
720 def Categories(self) -> tuple[str, ...]:
721 """
722 Read-only property to return the categories of all rows and statistics (:attr:`_categories`).
724 :returns: The categories in the order they first appear, without the empty string.
725 """
726 return tuple(self._categories)
729@export
730@abstractclass
731class Renderer(Generic[_FigureType], metaclass=ExtendedType, slots=True):
732 """
733 Abstract base-class of a renderer drawing a :class:`GanttLayout`.
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.
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.
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.
753 def __init__(self, layout: GanttLayout, *, title: Nullable[str] = None) -> None:
754 """
755 Initializes a renderer for a layout.
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
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
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 }
781 @readonly
782 def Layout(self) -> GanttLayout:
783 """
784 Read-only property to access the layout drawn by this renderer (:attr:`_layout`).
786 :returns: The layout.
787 """
788 return self._layout
790 @readonly
791 def Title(self) -> str:
792 """
793 Read-only property to access the chart's title (:attr:`_title`).
795 :returns: The title.
796 """
797 return self._title
799 @readonly
800 def CategoryWidth(self) -> int:
801 """
802 Read-only property to return the width of the legend's category column.
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)])
808 def Color(self, category: str) -> str:
809 """
810 Return the color a category's bars are drawn in.
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)
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.
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}"
829 hours, minutes = divmod(minutes, 60)
830 return f"{hours}:{minutes:02d}:{rest:02d}"
832 @staticmethod
833 def FormatTime(time: datetime) -> str:
834 """
835 Format an absolute time with its time zone.
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'}"
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.
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")
859 return "\n".join(lines)
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.
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
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 )
882 return label
884 @abstractmethod
885 def Render(self) -> _FigureType:
886 """
887 Draw the layout as a Gantt chart.
889 :returns: The chart, as the backend represents a drawing.
890 """
892 def Write(self, file: Path) -> None:
893 """
894 Draw the layout as a Gantt chart, and write the chart to a file.
896 The file format is chosen by the file's suffix, one of :attr:`FORMATS`. Missing parent directories are created.
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
913 figure = self.Render()
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
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
925 @abstractmethod
926 def _Write(self, figure: _FigureType, file: Path, fileFormat: str) -> None:
927 """
928 Write a drawn chart to a file.
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 """