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/PGraph.py +4298 -867
- pgraph/__init__.py +33 -1
- pgraph/_plot.py +70 -0
- pgraph/exporters.py +502 -0
- pgraph_python-1.0.0.dist-info/METADATA +277 -0
- pgraph_python-1.0.0.dist-info/RECORD +8 -0
- {pgraph_python-0.6.5.dist-info → pgraph_python-1.0.0.dist-info}/WHEEL +1 -1
- pgraph_python-0.6.5.dist-info/METADATA +0 -176
- pgraph_python-0.6.5.dist-info/RECORD +0 -6
- {pgraph_python-0.6.5.dist-info → pgraph_python-1.0.0.dist-info}/licenses/LICENSE +0 -0
pgraph/__init__.py
CHANGED
|
@@ -1 +1,33 @@
|
|
|
1
|
-
|
|
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("<", "<")
|
|
194
|
+
.replace(">", ">")
|
|
195
|
+
.replace('"', """)
|
|
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
|