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

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

33 

34.. code-block:: python 

35 

36 from pathlib import Path 

37 from pyTooling.Tracing.Render import GanttLayout, ciSpanFilter 

38 from pyTooling.Tracing.Render.Matplotlib import MatplotlibRenderer 

39 

40 layout = GanttLayout(trace, spanFilter=ciSpanFilter()) 

41 MatplotlibRenderer(layout).Write(Path("report/Pipeline.svg")) 

42 

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. 

46 

47.. hint:: 

48 

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 

55 

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 

62 

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 

73 

74 

75__all__ = ["FONT_FAMILIES", "MONOSPACE_FONT_FAMILY", "COLLAPSE_SCRIPT", "COLLAPSE_STYLESHEET"] 

76 

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

79 

80MONOSPACE_FONT_FAMILY = "DejaVu Sans Mono" 

81"""The font family of the legend, whose statistics are aligned in columns. matplotlib ships it.""" 

82 

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

86 

87COLLAPSE_STYLESHEET = "CollapsibleGantt.css" 

88"""Name of the stylesheet of a collapsible SVG file, in :mod:`pyTooling.Resources.Tracing`.""" 

89 

90 

91@export 

92class MatplotlibRenderer(Renderer[Figure]): 

93 """ 

94 Draws a :class:`~pyTooling.Tracing.Render.GanttLayout` with :term:`matplotlib`. 

95 

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. 

99 

100 matplotlib's object API is used throughout, so no global :mod:`~matplotlib.pyplot` state is touched and no 

101 display is needed. 

102 

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. 

109 

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. 

119 

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. 

136 

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) 

155 

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 

161 

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) 

170 

171 @readonly 

172 def Width(self) -> float: 

173 """ 

174 Read-only property to access the width of the figure (:attr:`_width`). 

175 

176 :returns: Width in inches. 

177 """ 

178 return self._width 

179 

180 @readonly 

181 def RowHeight(self) -> float: 

182 """ 

183 Read-only property to access the height of a row (:attr:`_rowHeight`). 

184 

185 :returns: Height in inches. 

186 """ 

187 return self._rowHeight 

188 

189 @readonly 

190 def FontSize(self) -> float: 

191 """ 

192 Read-only property to access the font size of labels (:attr:`_fontSize`). 

193 

194 :returns: Font size in points. 

195 """ 

196 return self._fontSize 

197 

198 @readonly 

199 def Collapsible(self) -> bool: 

200 """ 

201 Read-only property to access whether an SVG file is collapsible (:attr:`_collapsible`). 

202 

203 :returns: ``True``, if an SVG file carries the script collapsing and expanding rows. 

204 """ 

205 return self._collapsible 

206 

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`). 

211 

212 :returns: The span kinds. 

213 """ 

214 return self._collapsedKinds 

215 

216 @readonly 

217 def FontFamilies(self) -> tuple[str, ...]: 

218 """ 

219 Read-only property to return the font families that are installed (:attr:`_families`). 

220 

221 :returns: The families the chart is drawn with, in the order they are tried. 

222 """ 

223 return tuple(self._families) 

224 

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. 

229 

230 Installation is checked against matplotlib's font list first, because looking up a missing family logs a 

231 warning. 

232 

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 

242 

243 try: 

244 fonts.append(FT2Font(findfont(FontProperties(family=family), fallback_to_default=False))) 

245 families.append(family) 

246 except (ValueError, OSError): 

247 pass 

248 

249 return families, fonts 

250 

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. 

254 

255 matplotlib would draw an empty box for each of them and warn about every one. 

256 

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 

262 

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()) 

268 

269 def Render(self) -> Figure: 

270 """ 

271 Draw the layout as a Gantt chart. 

272 

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. 

276 

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 

286 

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() 

290 

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 

305 

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}") 

317 

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}") 

322 

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) 

330 

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")) 

337 

338 if hasLines: 

339 handles.append(Line2D( 

340 [], [], color=self.LINE, linewidth=1.0, marker="|", markersize=6, label=LINE_LEGEND_LABEL 

341 )) 

342 

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 ) 

353 

354 figure.suptitle(self._Renderable(self._title), fontsize=self._fontSize + 2) 

355 

356 return figure 

357 

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

359 """ 

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

361 

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

363 

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 

375 

376 super().Write(file) 

377 

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. 

381 

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. 

385 

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 

392 

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 } 

405 

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:] 

414 

415 def _Write(self, figure: Figure, file: Path, fileFormat: str) -> None: 

416 """ 

417 Write a drawn chart to a file. 

418 

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")