Coverage for pyTooling/Tracing/Render/Matplotlib.py: 95%
156 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"""
32Render a software execution trace as a Gantt chart with :term:`matplotlib`.
34.. code-block:: python
36 from pathlib import Path
37 from pyTooling.Tracing.Render import GanttLayout, ciSpanFilter
38 from pyTooling.Tracing.Render.Matplotlib import MatplotlibRenderer
40 layout = GanttLayout(trace, spanFilter=ciSpanFilter())
41 MatplotlibRenderer(layout).Write(Path("report/Pipeline.svg"))
43Every bar or line of a timespan in an SVG file is a group with the identifier ``span-<SpanID>``, a waiting bar
44``span-<SpanID>-queued``, the end marks of a line ``span-<SpanID>-ends`` and a row's label ``label-<SpanID>``, so a
45script can find the elements of a timespan.
47.. hint::
49 See :ref:`high-level help <TRACING/Render>` for explanations and usage examples.
50"""
51from io import StringIO
52from json import dumps as json_dumps
53from pathlib import Path
54from typing import ClassVar, Iterable, Optional as Nullable, Union
56from pyTooling.Decorators import export, readonly
57from pyTooling.Common import getFullyQualifiedName, readResourceFile
58from pyTooling.Exceptions import MissingDependencyError
59from pyTooling.Resources import Tracing as TracingResources
60from pyTooling.Tracing.CI import SpanKind
61from pyTooling.Tracing.Render import GanttLayout, LINE_LEGEND_LABEL, Renderer
63try:
64 from matplotlib import rc_context
65 from matplotlib.figure import Figure
66 from matplotlib.font_manager import FontProperties, findfont, fontManager
67 from matplotlib.ft2font import FT2Font
68 from matplotlib.lines import Line2D
69 from matplotlib.patches import Patch
70 from matplotlib.ticker import FuncFormatter
71except ImportError as ex: # pragma: no cover
72 raise MissingDependencyError(dependency="matplotlib", extra="diagram") from ex
75__all__ = ["FONT_FAMILIES", "MONOSPACE_FONT_FAMILY", "COLLAPSE_SCRIPT", "COLLAPSE_STYLESHEET"]
77FONT_FAMILIES = ("DejaVu Sans", "Noto Emoji", "Symbola")
78"""The font families tried in order for a character - a later emoji font supplies the emoji of job names."""
80MONOSPACE_FONT_FAMILY = "DejaVu Sans Mono"
81"""The font family of the legend, whose statistics are aligned in columns. matplotlib ships it."""
83COLLAPSE_SCRIPT = "CollapsibleGantt.js"
84"""Name of the script of a collapsible SVG file, in :mod:`pyTooling.Resources.Tracing`. ``/*DATA*/null`` is replaced by
85the rows and the distance between two rows."""
87COLLAPSE_STYLESHEET = "CollapsibleGantt.css"
88"""Name of the stylesheet of a collapsible SVG file, in :mod:`pyTooling.Resources.Tracing`."""
91@export
92class MatplotlibRenderer(Renderer[Figure]):
93 """
94 Draws a :class:`~pyTooling.Tracing.Render.GanttLayout` with :term:`matplotlib`.
96 Every row shows its timespan's name, indented by its depth. The pipeline and a called workflow are a line from
97 their begin to their end; a job is a bar, colored by its row's category, a waiting bar light gray and a running
98 bar hatched. A bar too short to see is widened to a visible minimum.
100 matplotlib's object API is used throughout, so no global :mod:`~matplotlib.pyplot` state is touched and no
101 display is needed.
103 A **collapsible** SVG file carries a script: a click on the label, bar or line of a row with sub-rows hides the
104 rows below it and moves the following rows up, and a second click shows them again. A marker in front of the label
105 shows the state. The script runs when the file is opened in a browser, or embedded with ``<object>`` or inline -
106 not when it is shown as an image, e.g. by ``<img>`` or in Markdown.
107 """
108 FORMATS: ClassVar[tuple[str, ...]] = ("svg", "png", "pdf") #: The file formats this renderer writes.
110 _width: float #: Width of the figure in inches.
111 _rowHeight: float #: Height of a row in inches.
112 _fontSize: float #: Font size of labels in points.
113 _legendLocation: str #: The legend's location, as matplotlib's ``loc`` names it.
114 _collapsible: bool #: Whether an SVG file carries the script collapsing and expanding rows.
115 _collapsedKinds: frozenset[str] #: The CI span kinds of rows, which start collapsed in a collapsible SVG file.
116 _dpi: int #: Resolution of a PNG file in dots per inch.
117 _families: list[str] #: The installed font families of ``fontFamilies``, in their order.
118 _fonts: list[FT2Font] #: The fonts of those families, to ask whether they can draw a character.
120 def __init__(
121 self,
122 layout: GanttLayout,
123 *,
124 title: Nullable[str] = None,
125 width: float = 16.0,
126 rowHeight: float = 0.22,
127 fontSize: float = 7.0,
128 fontFamilies: Iterable[str] = FONT_FAMILIES,
129 legendLocation: str = "upper right",
130 collapsible: bool = False,
131 collapsedKinds: Iterable[str] = (SpanKind.Job,),
132 dpi: int = 150
133 ) -> None:
134 """
135 Initializes a matplotlib renderer for a layout.
137 :param layout: The layout to draw.
138 :param title: Optional, the chart's title. Default: the trace's name and wall time.
139 :param width: Optional, width of the figure in inches. Default: ``16.0``.
140 :param rowHeight: Optional, height of a row in inches. Default: ``0.22``.
141 :param fontSize: Optional, font size of labels in points. Default: ``7.0``.
142 :param fontFamilies: Optional, font families tried in order for every character. Families that aren't
143 installed are skipped. Default: :data:`FONT_FAMILIES`.
144 :param legendLocation: Optional, the legend's location, as matplotlib's ``loc`` names it, e.g.
145 ``'lower right'`` or ``'outside right upper'``. Default: ``'upper right'``.
146 :param collapsible: Optional, add the script collapsing and expanding rows to an SVG file. Default: ``False``.
147 :param collapsedKinds: Optional, the CI span kinds of rows, which start collapsed in a collapsible SVG file.
148 Default: jobs, so their steps are hidden until a job is expanded.
149 :param dpi: Optional, resolution of a PNG file in dots per inch. Default: ``150``.
150 :raises TypeError: If parameter 'layout' is not of type :class:`~pyTooling.Tracing.Render.GanttLayout`.
151 :raises TypeError: If parameter 'title' is not of type :class:`str`.
152 :raises ValueError: If parameter 'width', 'rowHeight', 'fontSize' or 'dpi' isn't positive.
153 """
154 super().__init__(layout, title=title)
156 for parameter, value in (("width", width), ("rowHeight", rowHeight), ("fontSize", fontSize), ("dpi", dpi)):
157 if not value > 0:
158 ex = ValueError(f"Parameter '{parameter}' isn't positive.")
159 ex.add_note(f"Got value '{value}'.")
160 raise ex
162 self._width = width
163 self._rowHeight = rowHeight
164 self._fontSize = fontSize
165 self._legendLocation = legendLocation
166 self._collapsible = collapsible
167 self._collapsedKinds = frozenset(collapsedKinds)
168 self._dpi = dpi
169 self._families, self._fonts = self._LoadFonts(fontFamilies)
171 @readonly
172 def Width(self) -> float:
173 """
174 Read-only property to access the width of the figure (:attr:`_width`).
176 :returns: Width in inches.
177 """
178 return self._width
180 @readonly
181 def RowHeight(self) -> float:
182 """
183 Read-only property to access the height of a row (:attr:`_rowHeight`).
185 :returns: Height in inches.
186 """
187 return self._rowHeight
189 @readonly
190 def FontSize(self) -> float:
191 """
192 Read-only property to access the font size of labels (:attr:`_fontSize`).
194 :returns: Font size in points.
195 """
196 return self._fontSize
198 @readonly
199 def Collapsible(self) -> bool:
200 """
201 Read-only property to access whether an SVG file is collapsible (:attr:`_collapsible`).
203 :returns: ``True``, if an SVG file carries the script collapsing and expanding rows.
204 """
205 return self._collapsible
207 @readonly
208 def CollapsedKinds(self) -> frozenset[str]:
209 """
210 Read-only property to access the CI span kinds of rows, which start collapsed (:attr:`_collapsedKinds`).
212 :returns: The span kinds.
213 """
214 return self._collapsedKinds
216 @readonly
217 def FontFamilies(self) -> tuple[str, ...]:
218 """
219 Read-only property to return the font families that are installed (:attr:`_families`).
221 :returns: The families the chart is drawn with, in the order they are tried.
222 """
223 return tuple(self._families)
225 @staticmethod
226 def _LoadFonts(fontFamilies: Iterable[str]) -> tuple[list[str], list[FT2Font]]:
227 """
228 Load the fonts of the font families, which are installed.
230 Installation is checked against matplotlib's font list first, because looking up a missing family logs a
231 warning.
233 :param fontFamilies: The font families.
234 :returns: The installed families and their fonts, in the order of the families.
235 """
236 installed = {font.name for font in fontManager.ttflist}
237 families: list[str] = []
238 fonts: list[FT2Font] = []
239 for family in fontFamilies:
240 if family not in installed:
241 continue
243 try:
244 fonts.append(FT2Font(findfont(FontProperties(family=family), fallback_to_default=False)))
245 families.append(family)
246 except (ValueError, OSError):
247 pass
249 return families, fonts
251 def _Renderable(self, text: str) -> str:
252 """
253 Remove the characters none of the loaded fonts has a glyph for, like emoji without an emoji font.
255 matplotlib would draw an empty box for each of them and warn about every one.
257 :param text: The text.
258 :returns: The text without characters that can't be drawn, and without the whitespace left behind.
259 """
260 if len(self._fonts) == 0: 260 ↛ 261line 260 didn't jump to line 261 because the condition on line 260 was never true
261 return text
263 characters = (
264 character for character in text
265 if character.isspace() or any(font.get_char_index(ord(character)) != 0 for font in self._fonts)
266 )
267 return " ".join("".join(characters).split())
269 def Render(self) -> Figure:
270 """
271 Draw the layout as a Gantt chart.
273 The legend's title shows when the trace began and ended, its wall time, the runner time - the time all jobs
274 ran, added up - and the number of jobs. Every category shows the number of its jobs, and their minimum,
275 average and maximum waiting and running times.
277 :returns: The chart as a matplotlib figure, which isn't registered with :mod:`matplotlib.pyplot`.
278 """
279 layout = self._layout
280 rows = list(layout.IterateRows())
281 duration = max(layout.Duration, 1.0)
282 minimumWidth = duration / 1000
283 rcParameters = {"font.size": self._fontSize}
284 if len(self._families) > 0: 284 ↛ 287line 284 didn't jump to line 287 because the condition on line 284 was always true
285 rcParameters["font.family"] = self._families
287 with rc_context(rcParameters):
288 figure = Figure(figsize=(self._width, 1.6 + self._rowHeight * max(len(rows), 12)), layout="constrained")
289 axes = figure.add_subplot()
291 hasQueued = False
292 hasLines = False
293 for position, row in enumerate(rows):
294 if row.Kind in (SpanKind.Pipeline, SpanKind.Workflow):
295 for bar in row.Bars:
296 hasLines = True
297 style = "dashed" if bar.IsRunning else "solid"
298 begin = bar.BeginSinceOriginInSeconds
299 end = bar.EndSinceOriginInSeconds
300 line = axes.hlines(position, begin, end, colors=self.LINE, linewidth=1.0, linestyles=style)
301 ends = axes.vlines([begin, end], position - 0.3, position + 0.3, colors=self.LINE, linewidth=1.0)
302 line.set_gid(f"span-{row.SpanID}")
303 ends.set_gid(f"span-{row.SpanID}-ends")
304 continue
306 color = self.Color(row.Category)
307 for bar in row.Bars:
308 hasQueued |= bar.IsQueued
309 collection = axes.broken_barh(
310 [(bar.BeginSinceOriginInSeconds, max(bar.DurationInSeconds, minimumWidth))],
311 (position - 0.4, 0.8),
312 facecolors=self.QUEUED if bar.IsQueued else color,
313 hatch="///" if bar.IsRunning else None,
314 linewidth=0
315 )
316 collection.set_gid(f"span-{row.SpanID}-queued" if bar.IsQueued else f"span-{row.SpanID}")
318 labels = [f"{' ' * row.Depth}{self._Renderable(row.Name)}" for row in rows]
319 axes.set_yticks(range(len(rows)), labels=labels)
320 for label, row in zip(axes.get_yticklabels(), rows):
321 label.set_gid(f"label-{row.SpanID}")
323 if self._collapsible:
324 axes.tick_params(axis="y", length=0)
325 axes.set_ylim(len(rows) - 0.5, -0.5)
326 axes.set_xlim(0, duration)
327 axes.xaxis.set_major_formatter(FuncFormatter(lambda value, position: self.FormatSeconds(value)))
328 axes.set_xlabel("time since the trace began")
329 axes.grid(axis="x", linewidth=0.3, alpha=0.5)
331 handles: list[Union[Patch, Line2D]] = [
332 Patch(facecolor=self.Color(category), label=self.LegendLabel(category))
333 for category in layout.Categories
334 ]
335 if hasQueued:
336 handles.append(Patch(facecolor=self.QUEUED, label="waiting for a runner"))
338 if hasLines:
339 handles.append(Line2D(
340 [], [], color=self.LINE, linewidth=1.0, marker="|", markersize=6, label=LINE_LEGEND_LABEL
341 ))
343 monospace = {"family": MONOSPACE_FONT_FAMILY, "size": self._fontSize}
344 axes.legend(
345 handles=handles,
346 title=self.LegendTitle(),
347 loc=self._legendLocation,
348 prop=monospace,
349 title_fontproperties=monospace,
350 alignment="left",
351 framealpha=0.95
352 )
354 figure.suptitle(self._Renderable(self._title), fontsize=self._fontSize + 2)
356 return figure
358 def Write(self, file: Path) -> None:
359 """
360 Draw the layout as a Gantt chart, and write the chart to a file.
362 The file format is chosen by the file's suffix, one of :attr:`FORMATS`. Missing parent directories are created.
364 :param file: Path of the file to write.
365 :raises TypeError: If parameter 'file' is not of type :class:`~pathlib.Path`.
366 :raises ValueError: If the file's suffix isn't one of :attr:`FORMATS`.
367 :raises ValueError: If the renderer is collapsible, but the file isn't an SVG file.
368 :raises TracingError: If the parent directories couldn't be created.
369 :raises TracingError: If the file couldn't be written.
370 """
371 if self._collapsible and isinstance(file, Path) and file.suffix.lower() in (".png", ".pdf"):
372 ex = ValueError(f"File '{file}' can't be collapsible.")
373 ex.add_note("Only an SVG file can carry the script collapsing rows.")
374 raise ex
376 super().Write(file)
378 def _CollapsibleSVG(self, svg: str, figure: Figure) -> str:
379 """
380 Add the script collapsing and expanding rows to the content of an SVG file.
382 The script knows every row's identifier, its parent among the shown rows, and whether it starts collapsed. The
383 distance between two rows is converted from matplotlib's display coordinates into the SVG file's coordinates,
384 which have 72 units per inch.
386 :param svg: The content of the SVG file, as written by matplotlib.
387 :param figure: The drawn chart, after it was written.
388 :returns: The content of the SVG file, with the style and script.
389 """
390 axes = figure.get_axes()[0]
391 pitch = abs(axes.transData.transform((0, 1))[1] - axes.transData.transform((0, 0))[1]) * 72 / figure.dpi
393 rows = list(self._layout.IterateRows())
394 shown = {row.SpanID for row in rows}
395 data = {
396 "pitch": round(pitch, 6),
397 "rows": [
398 {
399 "id": row.SpanID,
400 "parent": row.ParentSpanID if row.ParentSpanID in shown else None,
401 "collapsed": row.Kind in self._collapsedKinds
402 } for row in rows
403 ]
404 }
406 script = readResourceFile(TracingResources, COLLAPSE_SCRIPT).replace("/*DATA*/null", json_dumps(data))
407 style = readResourceFile(TracingResources, COLLAPSE_STYLESHEET)
408 addition = (
409 f'<style type="text/css">{style}</style>\n'
410 f'<script type="text/ecmascript"><![CDATA[{script}]]></script>\n'
411 )
412 position = svg.rindex("</svg>")
413 return svg[:position] + addition + svg[position:]
415 def _Write(self, figure: Figure, file: Path, fileFormat: str) -> None:
416 """
417 Write a drawn chart to a file.
419 :param figure: The chart, as :meth:`Render` returned it.
420 :param file: Path of the file to write, whose parent directories exist.
421 :param fileFormat: The file format, one of :attr:`FORMATS`.
422 :raises OSError: If the file couldn't be written.
423 """
424 with rc_context({} if len(self._families) == 0 else {"font.family": self._families}):
425 if not self._collapsible:
426 figure.savefig(file, format=fileFormat, dpi=self._dpi)
427 else:
428 buffer = StringIO()
429 figure.savefig(buffer, format="svg")
430 file.write_text(self._CollapsibleSVG(buffer.getvalue(), figure), encoding="utf-8")