pgraph-python 0.6.5__py3-none-any.whl → 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pgraph/__init__.py CHANGED
@@ -1 +1,33 @@
1
- from pgraph.PGraph import UGraph, DGraph, UVertex, DVertex, Edge
1
+ import warnings
2
+ from typing import Any
3
+
4
+ from pgraph.PGraph import (
5
+ Graph,
6
+ Vertex,
7
+ UGraph,
8
+ DGraph,
9
+ Edge,
10
+ SE2Metric,
11
+ )
12
+
13
+ __all__ = [
14
+ "Graph",
15
+ "Vertex",
16
+ "UGraph",
17
+ "DGraph",
18
+ "Edge",
19
+ "SE2Metric",
20
+ ]
21
+
22
+
23
+ # UVertex and DVertex are deprecated names for Vertex, see pgraph.PGraph
24
+ def __getattr__(name: str) -> Any:
25
+ if name in ("UVertex", "DVertex"):
26
+ warnings.warn(
27
+ f"pgraph.{name} is deprecated, use pgraph.Vertex instead: a vertex "
28
+ "has no direction of its own, the graph it belongs to decides",
29
+ DeprecationWarning,
30
+ stacklevel=2,
31
+ )
32
+ return Vertex
33
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
pgraph/_plot.py ADDED
@@ -0,0 +1,70 @@
1
+ """
2
+ Matplotlib pieces of the plot of a graph (private module)
3
+
4
+ Imported by :meth:`pgraph.Graph.plot` and the methods that draw an edge, so that
5
+ ``import pgraph`` does not load matplotlib: this module is the only part of
6
+ the package whose classes need it to be defined at all.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+ from matplotlib.patches import FancyArrowPatch
15
+ from matplotlib.path import Path
16
+ from matplotlib.transforms import Affine2D, ScaledTranslation
17
+ from mpl_toolkits.mplot3d import proj3d
18
+ from numpy.typing import ArrayLike
19
+
20
+
21
+ def _loop_path(angle: float, radius: float, markersize: float) -> Path:
22
+ """
23
+ The path of a loop at a vertex, in points from the vertex (private function)
24
+
25
+ A circle of ``radius`` whose centre is that far from the vertex, in the
26
+ direction ``angle`` degrees, less the part inside the vertex's marker. It
27
+ is counter-clockwise, so a directed loop has its head where it returns.
28
+ """
29
+ m = markersize / 2
30
+ phi = np.degrees(np.arccos(np.clip(1 - m * m / (2 * radius * radius), -1, 1)))
31
+ arc = Path.arc(angle + 180 + phi, angle + 540 - phi)
32
+ centre = radius * np.array([np.cos(np.radians(angle)), np.sin(np.radians(angle))])
33
+ return Path(np.asarray(arc.vertices) * radius + centre, arc.codes)
34
+
35
+
36
+ class _Arrow3D(FancyArrowPatch):
37
+ """An edge between two points of a 3D plot, which follows a rotation"""
38
+
39
+ def __init__(self, p1: ArrayLike, p2: ArrayLike, **kwargs: Any):
40
+ super().__init__((0, 0), (0, 0), **kwargs)
41
+ self._p1, self._p2 = np.asarray(p1, float), np.asarray(p2, float)
42
+
43
+ def do_3d_projection(self, renderer: Any = None) -> float:
44
+ ax: Any = self.axes
45
+ xs, ys, zs = proj3d.proj_transform(
46
+ [self._p1[0], self._p2[0]],
47
+ [self._p1[1], self._p2[1]],
48
+ [self._p1[2], self._p2[2]],
49
+ ax.M,
50
+ )
51
+ self.set_positions((xs[0], ys[0]), (xs[1], ys[1]))
52
+ return float(np.min(zs))
53
+
54
+
55
+ class _Loop3D(FancyArrowPatch):
56
+ """A loop at a vertex of a 3D plot, which follows a rotation"""
57
+
58
+ def __init__(self, anchor: ArrayLike, path: Path, **kwargs: Any):
59
+ super().__init__(path=path, **kwargs)
60
+ self._anchor = np.asarray(anchor, float)
61
+
62
+ def do_3d_projection(self, renderer: Any = None) -> float:
63
+ ax: Any = self.axes
64
+ x, y, z = proj3d.proj_transform(*self._anchor, ax.M)
65
+ self.set_transform(
66
+ Affine2D().scale(1 / 72)
67
+ + ax.figure.dpi_scale_trans
68
+ + ScaledTranslation(x, y, ax.transData)
69
+ )
70
+ return float(z)
pgraph/exporters.py ADDED
@@ -0,0 +1,502 @@
1
+ """
2
+ Export a graph as text (GraphViz DOT, Mermaid, GraphML, ELK JSON) or as a
3
+ rendered image (via the GraphViz command line tools).
4
+
5
+ This module is reached through :meth:`~pgraph.PGraph.Graph.export`, which
6
+ dispatches on the format name or the file extension. Each text exporter is a
7
+ plain function ``f(graph, **options) -> str``, registered with
8
+ :func:`register_exporter`, so a user can add a format without touching the
9
+ package::
10
+
11
+ from pgraph.exporters import register_exporter
12
+
13
+ @register_exporter("gexf", ".gexf")
14
+ def gexf(graph, *, version="1.3") -> str:
15
+ ...
16
+
17
+ None of the text formats needs a dependency beyond the standard library. Only
18
+ the rendered formats (:data:`RENDER_FORMATS`) need the GraphViz executables.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import shutil
26
+ import subprocess # nosec B404 - GraphViz is run without a shell, see render()
27
+ from typing import IO, TYPE_CHECKING, Any, Callable
28
+
29
+ if TYPE_CHECKING:
30
+ from .PGraph import Vertex, Edge, Graph
31
+
32
+ TextExporter = Callable[..., str]
33
+
34
+ _TEXT: dict[str, TextExporter] = {}
35
+ _EXTENSIONS: dict[str, str] = {}
36
+
37
+ #: GraphViz layout programs that may be run to render an image
38
+ ENGINES = ("dot", "neato", "fdp", "sfdp", "twopi", "circo", "osage", "patchwork")
39
+
40
+ #: image formats produced by running the GraphViz ``dot``/``neato`` executable,
41
+ #: mapped to whether the result is text (``True``) or binary (``False``)
42
+ RENDER_FORMATS: dict[str, bool] = {
43
+ "svg": True,
44
+ "ps": True,
45
+ "pdf": False,
46
+ "png": False,
47
+ "jpg": False,
48
+ "jpeg": False,
49
+ "gif": False,
50
+ }
51
+
52
+
53
+ def register_exporter(
54
+ name: str, *extensions: str
55
+ ) -> Callable[[TextExporter], TextExporter]:
56
+ """
57
+ Register a text exporter (decorator)
58
+
59
+ :param name: format name used as ``export(format=name)``
60
+ :param extensions: file extensions, with the leading dot, that select this
61
+ format when ``format`` is not given
62
+ :return: the decorator
63
+
64
+ The decorated function is called as ``f(graph, **options)`` and returns the
65
+ text. Declare its options as keyword-only parameters so that an unknown
66
+ option is a ``TypeError``.
67
+ """
68
+
69
+ def decorator(func: TextExporter) -> TextExporter:
70
+ _TEXT[name] = func
71
+ for ext in extensions:
72
+ _EXTENSIONS[ext.lower()] = name
73
+ return func
74
+
75
+ return decorator
76
+
77
+
78
+ def get_exporter(name: str) -> TextExporter:
79
+ """
80
+ Look up a text exporter by format name
81
+
82
+ :param name: format name, eg. ``"dot"``
83
+ :return: the function ``f(graph, **options) -> str``
84
+ :raises ValueError: no text exporter has that name; the message lists those
85
+ that do
86
+ """
87
+ try:
88
+ return _TEXT[name]
89
+ except KeyError:
90
+ raise ValueError(
91
+ f"unknown export format {name!r}, expected one of {formats()}"
92
+ ) from None
93
+
94
+
95
+ def formats() -> list[str]:
96
+ """
97
+ Names of all supported export formats
98
+
99
+ :return: text formats followed by rendered image formats
100
+ :rtype: list of str
101
+ """
102
+ return sorted(_TEXT) + sorted(RENDER_FORMATS)
103
+
104
+
105
+ def format_from_filename(filename: str | os.PathLike | IO | None) -> str | None:
106
+ """
107
+ Infer an export format from a file name's extension
108
+
109
+ :param filename: path, or a file object with a ``name`` attribute
110
+ :return: the format name, or None if it cannot be decided
111
+ :rtype: str or None
112
+
113
+ Compound extensions such as ``.elk.json`` are tried before the plain
114
+ suffix.
115
+ """
116
+ if isinstance(filename, (str, os.PathLike)):
117
+ name = os.fspath(filename)
118
+ else:
119
+ name = getattr(filename, "name", None)
120
+ if not isinstance(name, str):
121
+ return None
122
+ lower = name.lower()
123
+ for ext in sorted(_EXTENSIONS, key=len, reverse=True):
124
+ if lower.endswith(ext):
125
+ return _EXTENSIONS[ext]
126
+ suffix = os.path.splitext(lower)[1].lstrip(".")
127
+ return suffix if suffix in RENDER_FORMATS else None
128
+
129
+
130
+ # --------------------------------------------------------------------------- #
131
+ # helpers shared by the exporters
132
+
133
+
134
+ def _is_directed(graph: Graph) -> bool:
135
+ return graph.directed
136
+
137
+
138
+ def _ordered_edges(graph: Graph) -> list[tuple[Edge, Vertex, Vertex]]:
139
+ """
140
+ Edges in a reproducible order
141
+
142
+ Walk the vertices in creation order and each vertex's own outgoing edges,
143
+ keeping the first sight of every edge, so that edges are grouped by the
144
+ vertex they leave. Identity is by object, not by endpoints, so parallel
145
+ edges are all kept, and an undirected edge (held by both ends, twice for a
146
+ self-loop) is listed once.
147
+
148
+ Each item is ``(edge, start, end)``.
149
+ """
150
+ seen: set[int] = set()
151
+ edges: list[tuple[Edge, Vertex, Vertex]] = []
152
+ for vertex in graph:
153
+ for e in vertex.out_edges():
154
+ if id(e) in seen or e.v1 is None or e.v2 is None:
155
+ continue
156
+ seen.add(id(e))
157
+ edges.append((e, e.v1, e.v2))
158
+ return edges
159
+
160
+
161
+ def _component_colors(graph: Graph) -> dict[Vertex, str]:
162
+ """
163
+ Map each vertex to a ``#rrggbb`` color for its component, as plot() does
164
+ """
165
+ import matplotlib.pyplot as plt
166
+ import numpy as np
167
+
168
+ nc = graph.nc # refreshes vertex.label
169
+ cmap = plt.cm.coolwarm(np.linspace(0, 1, max(nc, 1)))
170
+ colors = {}
171
+ for vertex in graph:
172
+ if vertex.label is not None:
173
+ r, g, b, _ = cmap[vertex.label]
174
+ colors[vertex] = "#{:02x}{:02x}{:02x}".format(
175
+ int(r * 255), int(g * 255), int(b * 255)
176
+ )
177
+ return colors
178
+
179
+
180
+ def _xml_text(value: Any) -> str:
181
+ """
182
+ Escape a value for XML element content or a double-quoted attribute
183
+
184
+ Characters that XML 1.0 cannot represent at all (most control characters)
185
+ are replaced with U+FFFD, since escaping cannot make them valid.
186
+ """
187
+ text = str(value)
188
+ text = "".join(
189
+ c if c in "\t\n\r" or ord(c) >= 0x20 else "\ufffd" for c in text
190
+ )
191
+ return (
192
+ text.replace("&", "&")
193
+ .replace("<", "&lt;")
194
+ .replace(">", "&gt;")
195
+ .replace('"', "&quot;")
196
+ )
197
+
198
+
199
+ def _dot_quote(text: str) -> str:
200
+ """Quote a string for use as a DOT identifier or attribute value"""
201
+ escaped = text.replace("\\", "\\\\").replace('"', '\\"')
202
+ return '"' + escaped.replace("\r\n", "\\n").replace("\n", "\\n") + '"'
203
+
204
+
205
+ # --------------------------------------------------------------------------- #
206
+ # text exporters
207
+
208
+
209
+ @register_exporter("dot", ".dot", ".gv")
210
+ def dot(
211
+ graph: Graph,
212
+ *,
213
+ direction: str | None = None,
214
+ color_components: bool = False,
215
+ costs: bool = True,
216
+ ) -> str:
217
+ """
218
+ GraphViz DOT text
219
+
220
+ :param direction: layout direction, ``rankdir`` in DOT, eg. ``"LR"``
221
+ :param color_components: fill vertices by connected component
222
+ :param costs: label edges with their cost
223
+ """
224
+ directed = _is_directed(graph)
225
+ link = "->" if directed else "--"
226
+ colors = _component_colors(graph) if color_components else {}
227
+
228
+ lines = ["digraph {" if directed else "graph {"]
229
+ if direction is not None:
230
+ lines.append(f" rankdir={_dot_quote(direction)}")
231
+
232
+ for vertex in graph:
233
+ attrs = []
234
+ if vertex.coord is not None and len(vertex.coord) >= 2:
235
+ # the "!" pins the position when the neato engine is used
236
+ attrs.append(
237
+ 'pos="{:g},{:g}!"'.format(vertex.coord[0], vertex.coord[1])
238
+ )
239
+ if vertex in colors:
240
+ attrs.append('style="filled"')
241
+ attrs.append(f'fillcolor="{colors[vertex]}"')
242
+ suffix = f" [{', '.join(attrs)}]" if attrs else ""
243
+ lines.append(f" {_dot_quote(str(vertex.name))}{suffix}")
244
+
245
+ for e, v1, v2 in _ordered_edges(graph):
246
+ attrs = []
247
+ if costs and e.cost is not None:
248
+ attrs.append(f'label="{e.cost:.4g}"')
249
+ suffix = f" [{', '.join(attrs)}]" if attrs else ""
250
+ lines.append(
251
+ f" {_dot_quote(str(v1.name))} {link} {_dot_quote(str(v2.name))}{suffix}"
252
+ )
253
+
254
+ lines.append("}")
255
+ return "\n".join(lines) + "\n"
256
+
257
+
258
+ @register_exporter("mermaid", ".mmd", ".mermaid")
259
+ def mermaid(
260
+ graph: Graph,
261
+ *,
262
+ direction: str = "TB",
263
+ color_components: bool = False,
264
+ costs: bool = True,
265
+ fence: bool = False,
266
+ ) -> str:
267
+ """
268
+ Mermaid flowchart text
269
+
270
+ :param direction: ``"TB"``, ``"BT"``, ``"LR"`` or ``"RL"``
271
+ :param color_components: fill vertices by connected component
272
+ :param costs: label edges with their cost
273
+ :param fence: wrap in a Markdown code fence, three backticks followed by
274
+ ``mermaid``
275
+
276
+ Mermaid flowcharts are laid out automatically and cannot pin node
277
+ positions, so vertex coordinates are not represented. Vertices are given
278
+ generated ids (``n0``, ``n1``, ...) with the real name as the label, since
279
+ names are arbitrary strings but Mermaid ids are not.
280
+ """
281
+ if direction not in ("TB", "TD", "BT", "LR", "RL"):
282
+ raise ValueError(f"direction must be TB, BT, LR or RL, got {direction!r}")
283
+
284
+ arrow = "-->" if _is_directed(graph) else "---"
285
+ ids: dict[Vertex, str] = {}
286
+ lines = [f"flowchart {direction}"]
287
+ for i, vertex in enumerate(graph):
288
+ ids[vertex] = f"n{i}"
289
+ label = str(vertex.name).replace('"', "#quot;")
290
+ lines.append(f' n{i}["{label}"]')
291
+
292
+ for e, v1, v2 in _ordered_edges(graph):
293
+ if costs and e.cost is not None:
294
+ lines.append(f' {ids[v1]} {arrow}|"{e.cost:.4g}"| {ids[v2]}')
295
+ else:
296
+ lines.append(f" {ids[v1]} {arrow} {ids[v2]}")
297
+
298
+ if color_components:
299
+ colors = _component_colors(graph)
300
+ for vertex, color in colors.items():
301
+ lines.append(f" style {ids[vertex]} fill:{color}")
302
+
303
+ text = "\n".join(lines) + "\n"
304
+ if fence:
305
+ text = f"```mermaid\n{text}```\n"
306
+ return text
307
+
308
+
309
+ @register_exporter("graphml", ".graphml")
310
+ def graphml(graph: Graph) -> str:
311
+ """
312
+ GraphML XML text
313
+
314
+ Name, component, coordinate (``x``, ``y`` and the full ``coord`` string for
315
+ higher dimensions) and edge cost are written as ``<data>`` elements with
316
+ ``<key>`` declarations. ``edgedefault`` follows the graph kind. GraphML
317
+ has no standard node position outside the proprietary yFiles extension,
318
+ which is not used.
319
+ """
320
+ graph.nc # refresh vertex.label
321
+ has_coords = any(v.coord is not None for v in graph)
322
+ edges = _ordered_edges(graph)
323
+ has_cost = any(e.cost is not None for e, _, _ in edges)
324
+
325
+ def key(key_id: str, domain: str, name: str, type_: str) -> str:
326
+ return (
327
+ f' <key id="{key_id}" for="{domain}" '
328
+ f'attr.name="{name}" attr.type="{type_}"/>'
329
+ )
330
+
331
+ kind = "directed" if _is_directed(graph) else "undirected"
332
+ lines = [
333
+ '<?xml version="1.0" encoding="UTF-8"?>',
334
+ '<graphml xmlns="http://graphml.graphdrawing.org/xmlns">',
335
+ key("node_label", "node", "label", "string"),
336
+ key("component", "node", "component", "int"),
337
+ ]
338
+ if has_coords:
339
+ lines += [
340
+ key("x", "node", "x", "double"),
341
+ key("y", "node", "y", "double"),
342
+ key("coord", "node", "coord", "string"),
343
+ ]
344
+ if has_cost:
345
+ lines.append(key("edge_cost", "edge", "cost", "double"))
346
+ lines.append(f' <graph id="G" edgedefault="{kind}">')
347
+
348
+ ids: dict[Vertex, str] = {}
349
+ for i, vertex in enumerate(graph):
350
+ ids[vertex] = f"n{i}"
351
+ lines.append(f' <node id="n{i}">')
352
+ lines.append(f' <data key="node_label">{_xml_text(vertex.name)}</data>')
353
+ if vertex.label is not None:
354
+ lines.append(f' <data key="component">{vertex.label}</data>')
355
+ if vertex.coord is not None:
356
+ if len(vertex.coord) >= 2:
357
+ lines.append(f' <data key="x">{vertex.coord[0]:.6g}</data>')
358
+ lines.append(f' <data key="y">{vertex.coord[1]:.6g}</data>')
359
+ coord = ",".join(f"{c:.6g}" for c in vertex.coord)
360
+ lines.append(f' <data key="coord">{coord}</data>')
361
+ lines.append(" </node>")
362
+
363
+ for i, (e, v1, v2) in enumerate(edges):
364
+ lines.append(f' <edge id="e{i}" source="{ids[v1]}" target="{ids[v2]}">')
365
+ if e.cost is not None:
366
+ lines.append(f' <data key="edge_cost">{e.cost:.6g}</data>')
367
+ lines.append(" </edge>")
368
+
369
+ lines += [" </graph>", "</graphml>"]
370
+ return "\n".join(lines) + "\n"
371
+
372
+
373
+ @register_exporter("elk", ".elk.json", ".elk")
374
+ def elk(
375
+ graph: Graph,
376
+ *,
377
+ size: tuple[float, float] = (60, 40),
378
+ layout_options: dict[str, str] | None = None,
379
+ ) -> str:
380
+ """
381
+ Eclipse Layout Kernel (ELK) JSON graph text
382
+
383
+ :param size: width and height given to every node, which ELK requires
384
+ :param layout_options: ELK layout options, merged over the defaults
385
+
386
+ Unlike GraphML, ELK has native ``x`` and ``y`` node fields. If any vertex
387
+ has a coordinate the ``fixed`` algorithm is selected so that a consumer
388
+ keeps the positions; otherwise ``layered`` computes a layout.
389
+ """
390
+ graph.nc # refresh vertex.label
391
+ has_coords = any(v.coord is not None for v in graph)
392
+
393
+ ids: dict[Vertex, str] = {}
394
+ children = []
395
+ for i, vertex in enumerate(graph):
396
+ ids[vertex] = f"n{i}"
397
+ node: dict[str, Any] = {
398
+ "id": f"n{i}",
399
+ "labels": [{"text": str(vertex.name)}],
400
+ "width": size[0],
401
+ "height": size[1],
402
+ }
403
+ properties: dict[str, Any] = {}
404
+ if vertex.coord is not None:
405
+ if len(vertex.coord) >= 2:
406
+ node["x"] = float(vertex.coord[0])
407
+ node["y"] = float(vertex.coord[1])
408
+ properties["coord"] = [float(c) for c in vertex.coord]
409
+ if vertex.label is not None:
410
+ properties["component"] = vertex.label
411
+ if properties:
412
+ node["properties"] = properties
413
+ children.append(node)
414
+
415
+ edges = []
416
+ for i, (e, v1, v2) in enumerate(_ordered_edges(graph)):
417
+ edge: dict[str, Any] = {
418
+ "id": f"e{i}",
419
+ "sources": [ids[v1]],
420
+ "targets": [ids[v2]],
421
+ }
422
+ if e.cost is not None:
423
+ edge["labels"] = [{"text": f"{e.cost:.4g}"}]
424
+ edge["properties"] = {"cost": e.cost}
425
+ edges.append(edge)
426
+
427
+ options = {
428
+ "org.eclipse.elk.algorithm": "fixed" if has_coords else "layered",
429
+ "org.eclipse.elk.direction": "DOWN" if _is_directed(graph) else "UNDEFINED",
430
+ }
431
+ options.update(layout_options or {})
432
+ return (
433
+ json.dumps(
434
+ {
435
+ "id": "root",
436
+ "layoutOptions": options,
437
+ "children": children,
438
+ "edges": edges,
439
+ },
440
+ indent=2,
441
+ )
442
+ + "\n"
443
+ )
444
+
445
+
446
+ # --------------------------------------------------------------------------- #
447
+ # rendered images
448
+
449
+
450
+ def render(
451
+ graph: Graph,
452
+ fmt: str,
453
+ *,
454
+ engine: str | None = None,
455
+ direction: str | None = None,
456
+ color_components: bool = False,
457
+ costs: bool = True,
458
+ ) -> bytes:
459
+ """
460
+ Render the graph to an image with the GraphViz executables
461
+
462
+ :param fmt: one of :data:`RENDER_FORMATS`
463
+ :param engine: GraphViz layout program, one of :data:`ENGINES`. Defaults
464
+ to ``neato`` if any vertex has a coordinate, since it honours pinned
465
+ positions, else ``dot``
466
+ :return: the image
467
+ :rtype: bytes
468
+ :raises ValueError: ``fmt`` or ``engine`` is not a known name
469
+ :raises RuntimeError: the layout program is not installed, or failed
470
+
471
+ Both names are checked against fixed lists and the program is run with an
472
+ argument list and no shell, so neither can carry a command.
473
+ """
474
+ if fmt not in RENDER_FORMATS:
475
+ raise ValueError(
476
+ f"unknown image format {fmt!r}, expected one of {sorted(RENDER_FORMATS)}"
477
+ )
478
+ if engine is None:
479
+ engine = "neato" if any(v.coord is not None for v in graph) else "dot"
480
+ if engine not in ENGINES:
481
+ raise ValueError(f"unknown GraphViz engine {engine!r}, expected one of {ENGINES}")
482
+ exe = shutil.which(engine)
483
+ if exe is None:
484
+ raise RuntimeError(
485
+ f"GraphViz layout program {engine!r} was not found on PATH; "
486
+ "install GraphViz (https://graphviz.org/download/) to render "
487
+ f"{fmt} output, or export the text with format='dot'"
488
+ )
489
+
490
+ text = dot(graph, direction=direction, color_components=color_components, costs=costs)
491
+ result = subprocess.run( # nosec B603 nosemgrep - allow-listed program and format, no shell
492
+ [exe, f"-T{fmt}"],
493
+ input=text.encode("utf-8"),
494
+ stdout=subprocess.PIPE,
495
+ stderr=subprocess.PIPE,
496
+ )
497
+ if result.returncode != 0:
498
+ raise RuntimeError(
499
+ f"{engine} failed with exit code {result.returncode}: "
500
+ f"{result.stderr.decode('utf-8', 'replace').strip()}"
501
+ )
502
+ return result.stdout