-
Notifications
You must be signed in to change notification settings - Fork 2
/
gvutils.py
323 lines (293 loc) · 9.88 KB
/
gvutils.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
#!/usr/bin/env python3
''' Graphviz utility functions.
See also the [https://www.graphviz.org/documentation/](graphviz documentation)
and particularly the [https://graphviz.org/doc/info/lang.html](DOT language specification)
and the [https://www.graphviz.org/doc/info/command.html](`dot` command line tool).
'''
from base64 import b64encode
from os.path import exists as existspath
from subprocess import Popen, PIPE
import sys
from threading import Thread
from typing import Mapping
from urllib.parse import quote as urlquote
from cs.lex import cutprefix, cutsuffix, r
__version__ = '20230816-post'
DISTINFO = {
'keywords': ["python3"],
'classifiers': [
"Programming Language :: Python",
"Programming Language :: Python :: 3",
],
'install_requires': [
'cs.lex',
],
}
def quote(s):
''' Quote a string for use in DOT syntax.
This implementation passes identifiers and sequences of decimal numerals
through unchanged and double quotes other strings.
'''
if s.isalnum() or s.replace('_', '').isalnum():
return s
return (
'"' + s.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n') +
'"'
)
# special value to capture the output of gvprint as binary data
GVCAPTURE = object()
# special value to capture the output of gvprint as a data: URL
GVDATAURL = object()
# pylint: disable=too-many-branches,too-many-statements,too-many-locals
def gvprint(
dot_s, file=None, fmt=None, layout=None, dataurl_encoding=None, **dot_kw
):
''' Print the graph specified by `dot_s`, a graph in graphViz DOT syntax,
to `file` (default `sys.stdout`)
in format `fmt` using the engine specified by `layout` (default `'dot'`).
If `fmt` is unspecified it defaults to `'png'` unless `file`
is a terminal in which case it defaults to `'sixel'`.
In addition to being a file or file descriptor,
`file` may also take the following special values:
* `GVCAPTURE`: causes `gvprint` to return the image data as `bytes`
* `GVDATAURL`: causes `gvprint` to return the image data as a `data:` URL
For `GVDATAURL`, the parameter `dataurl_encoding` may be used
to override the default encoding, which is `'utf8'` for `fmt`
values `'dot'` and `'svg'`, otherwise `'base64'`.
This uses the graphviz utility `dot` to draw graphs.
If printing in SIXEL format the `img2sixel` utility is required,
see [https://saitoha.github.io/libsixel/](libsixel).
Example:
data_url = gvprint('digraph FOO {A->B}', file=GVDATAURL, fmt='svg')
'''
if file is None:
file = sys.stdout
if isinstance(file, str):
with open(file, 'xb') as f:
return gvprint(dot_s, file=f, fmt=fmt, layout=layout, **dot_kw)
if file is GVDATAURL:
if dataurl_encoding is None:
dataurl_encoding = 'utf8' if fmt in (
'dot',
'svg',
) else 'base64'
gvdata = gvprint(dot_s, file=GVCAPTURE, fmt=fmt, layout=layout, **dot_kw)
data_content_type = f'image/{"svg+xml" if fmt == "svg" else fmt}'
if dataurl_encoding == 'utf8':
gv_data_s = gvdata.decode('utf8')
data_part = urlquote(gv_data_s.replace('\n', ''), safe=':/<>{}')
elif dataurl_encoding == 'base64':
data_part = b64encode(gvdata).decode('ascii')
else:
raise ValueError(
"invalid data URL encoding %r; I accept 'utf8' or 'base64'" %
(dataurl_encoding,)
)
return f'data:{data_content_type};{dataurl_encoding},{data_part}'
if file is GVCAPTURE:
capture_mode = True
file = PIPE
else:
capture_mode = False
if layout is None:
layout = 'dot'
if fmt is None:
if file.isatty():
fmt = 'sixel'
else:
fmt = 'png'
graph_modes = dict(layout=layout, splines='true')
node_modes = {}
edge_modes = {}
for dot_mode, value in dot_kw.items():
try:
modetype, mode = dot_mode.split('_', 1)
except ValueError:
if dot_mode in ('fg',):
node_modes.update(color=value)
edge_modes.update(color=value)
elif dot_mode in ('fontcolor',):
node_modes.update(fontcolor=value)
else:
graph_modes[dot_mode] = value
else:
if modetype == 'graph':
graph_modes[mode] = value
elif modetype == 'node':
node_modes[mode] = value
elif modetype == 'edge':
edge_modes[mode] = value
else:
raise ValueError(
"%s=%r: unknown mode type %r,"
" expected one of graph, node, edge" % (dot_mode, value, modetype)
)
dot_fmt = 'png' if fmt == 'sixel' else fmt
dot_argv = ['dot', f'-T{dot_fmt}']
for gmode, gvalue in sorted(graph_modes.items()):
dot_argv.append(f'-G{gmode}={gvalue}')
for nmode, nvalue in sorted(node_modes.items()):
dot_argv.append(f'-N{nmode}={nvalue}')
for emode, evalue in sorted(edge_modes.items()):
dot_argv.append(f'-E{emode}={evalue}')
# make sure any preceeding output gets out first
if file is not PIPE:
file.flush()
# subprocesses to wait for in order
subprocs = []
output_popen = None
if fmt == 'sixel':
# pipeline to pipe "dot" through "img2sixel"
# pylint: disable=consider-using-with
img2sixel_popen = Popen(['img2sixel'], stdin=PIPE, stdout=file)
dot_output = img2sixel_popen.stdin
subprocs.append(img2sixel_popen)
output_popen = img2sixel_popen
else:
img2sixel_popen = None
dot_output = file
# pylint: disable=consider-using-with
dot_popen = Popen(dot_argv, stdin=PIPE, stdout=dot_output)
if output_popen is None:
output_popen = dot_popen
subprocs.insert(0, dot_popen)
if img2sixel_popen is not None:
# release our handle to img2sixel
img2sixel_popen.stdin.close()
if capture_mode:
captures = []
T = Thread(
target=lambda: captures.append(output_popen.stdout.read()),
daemon=True,
)
T.start()
dot_bs = dot_s.encode('ascii')
dot_popen.stdin.write(dot_bs)
dot_popen.stdin.close()
for subp in subprocs:
subp.wait()
if capture_mode:
# get the captured bytes
T.join()
bs, = captures
return bs
return None
## Nothing renders this :-(
##
##gvprint.__doc__ += (
## '\n produces a `data:` URL rendering as:\n <img src="' + gvprint(
## 'digraph FOO {A->B}',
## file=GVDATAURL,
## fmt='svg',
## dataurl_encoding='base64',
## ) + '">'
##)
def gvdata(dot_s, **kw):
''' Convenience wrapper for `gvprint` which returns the binary image data.
'''
return gvprint(dot_s, file=GVCAPTURE, **kw)
def gvdataurl(dot_s, **kw):
''' Convenience wrapper for `gvprint` which returns the binary image data
as a `data:` URL.
'''
return gvprint(dot_s, file=GVDATAURL, **kw)
def gvsvg(dot_s, **gvdata_kw):
''' Convenience wrapper for `gvprint` which returns an SVG string.
'''
svg = gvdata(dot_s, fmt='svg', **gvdata_kw).decode('utf-8')
svg = svg[svg.find('<svg'):].rstrip() # trim header and tail
return svg
class DOTNodeMixin:
''' A mixin providing methods for things which can be drawn as
nodes in a DOT graph description.
'''
DOT_NODE_FONTCOLOR_PALETTE = {}
DOT_NODE_FILLCOLOR_PALETTE = {}
def __getattr__(self, attr: str):
''' Recognise various `dot_node_*` attributes.
`dot_node_*color` is an attribute derives from `self.DOT_NODE_COLOR_*PALETTE`.
'''
dot_node_suffix = cutprefix(attr, 'dot_node_')
if dot_node_suffix is not attr:
# dot_node_*
colourname = cutsuffix(dot_node_suffix, 'color')
if colourname is not dot_node_suffix:
# dot_node_*color
palette_name = f'DOT_NODE_{colourname.upper()}COLOR_PALETTE'
try:
palette = getattr(self, palette_name)
except AttributeError:
# no colour palette
pass
else:
try:
colour = palette[self.dot_node_palette_key]
except KeyError:
colour = palette.get(None)
return colour
try:
sga = super().__getattr__
except AttributeError as e:
raise AttributeError(
"no %s.%s attribute" % (self.__class__.__name__, attr)
) from e
return sga(attr)
@property
def dot_node_id(self):
''' An id for this DOT node.
'''
return str(id(self))
@property
def dot_node_palette_key(self):
''' Default palette index is `self.fsm_state`,
overriding `DOTNodeMixin.dot_node_palette_key`.
'''
return self.dot_node_id
@staticmethod
def dot_node_attrs_str(attrs):
''' An attributes mapping transcribed for DOT,
ready for insertion between `[]` in a node definition.
'''
strs = []
for attr, value in attrs.items():
if isinstance(value, (int, float)):
value_s = str(value)
elif isinstance(value, str):
value_s = quote(value)
else:
raise TypeError(
"attrs[%r]=%s: expected int,float,str" % (attr, r(value))
)
strs.append(quote(attr) + '=' + value_s)
attrs_s = ','.join(strs)
return attrs_s
def dot_node(self, label=None, **node_attrs) -> str:
''' A DOT syntax node definition for `self`.
'''
if label is None:
label = self.dot_node_label()
attrs = dict(self.dot_node_attrs())
attrs.update(label=label)
attrs.update(node_attrs)
if not attrs:
return quote(label)
return f'{quote(self.dot_node_id)}[{self.dot_node_attrs_str(attrs)}]'
# pylint: disable=no-self-use
def dot_node_attrs(self) -> Mapping[str, str]:
''' The default DOT node attributes.
'''
attrs = dict(style='solid')
fontcolor = self.dot_node_fontcolor
if fontcolor is not None:
attrs.update(fontcolor=fontcolor)
fillcolor = self.dot_node_fillcolor
if fillcolor is not None:
attrs.update(style='filled')
attrs.update(fillcolor=fillcolor)
return attrs
def dot_node_label(self) -> str:
''' The default node label.
This implementation returns `str(serlf)`
and a common implementation might return `self.name` or similar.
'''
return str(self)