cs-gvutils 20260531__tar.gz

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.
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.4
2
+ Name: cs-gvutils
3
+ Version: 20260531
4
+ Summary: Graphviz utility functions.
5
+ Keywords: python3
6
+ Author-email: Cameron Simpson <cs@cskk.id.au>
7
+ Description-Content-Type: text/markdown
8
+ Classifier: Programming Language :: Python
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
15
+ Requires-Dist: cs.lex>=20260526
16
+ Project-URL: MonoRepo Commits, https://bitbucket.org/cameron_simpson/css/commits/branch/main
17
+ Project-URL: Monorepo Git Mirror, https://github.com/cameron-simpson/css
18
+ Project-URL: Monorepo Hg/Mercurial Mirror, https://hg.sr.ht/~cameron-simpson/css
19
+ Project-URL: Source, https://github.com/cameron-simpson/css/blob/main/lib/python/cs/gvutils.py
20
+
21
+ Graphviz utility functions.
22
+
23
+ *Latest release 20260531*:
24
+ * New DOT_KEYWORDS, update quote() to quote keywords.
25
+ * New Node and Graph classes for constructing a graphviz style node graph.
26
+ * gvprint: print a final newline after the sixel output.
27
+
28
+ See also the [https://www.graphviz.org/documentation/](graphviz documentation)
29
+ and particularly the [https://graphviz.org/doc/info/lang.html](DOT language specification)
30
+ and the [https://www.graphviz.org/doc/info/command.html](`dot` command line tool).
31
+
32
+ Short summary:
33
+ * `DOTNodeMixin`: A mixin providing methods for things which can be drawn as nodes in a DOT graph description.
34
+ * `Graph`: A representation of a graphviz graph suitable for transcribing as DOT.
35
+ * `gvdata`: Convenience wrapper for `gvprint` which returns the binary image data.
36
+ * `gvdataurl`: Convenience wrapper for `gvprint` which returns the binary image data as a `data:` URL.
37
+ * `gvprint`: 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'`).
38
+ * `gvsvg`: Convenience wrapper for `gvprint` which returns an SVG string.
39
+ * `Node`: Node(id: str, rankdir: str = 'LR', shape: str = 'rect', attrs: dict = <factory>).
40
+ * `quote`: Quote a string for use in DOT syntax. This implementation passes non-keyword identifiers and sequences of decimal numerals through unchanged and double quotes other strings.
41
+
42
+ Module contents:
43
+ - <a name="DOTNodeMixin"></a>`class DOTNodeMixin`: A mixin providing methods for things which can be drawn as
44
+ nodes in a DOT graph description.
45
+
46
+ *`DOTNodeMixin.__getattr__(self, attr: str)`*:
47
+ Recognise various `dot_node_*` attributes.
48
+
49
+ `dot_node_*color` is an attribute derives from `self.DOT_NODE_COLOR_*PALETTE`.
50
+
51
+ *`DOTNodeMixin.dot_node(self, label=None, **node_attrs) -> str`*:
52
+ A DOT syntax node definition for `self`.
53
+
54
+ *`DOTNodeMixin.dot_node_attrs(self) -> Mapping[str, str]`*:
55
+ The default DOT node attributes.
56
+
57
+ *`DOTNodeMixin.dot_node_attrs_str(attrs)`*:
58
+ An attributes mapping transcribed for DOT,
59
+ ready for insertion between `[]` in a node definition.
60
+
61
+ *`DOTNodeMixin.dot_node_id`*:
62
+ An id for this DOT node, also the default index into the palettes.
63
+
64
+ *`DOTNodeMixin.dot_node_label(self) -> str`*:
65
+ The default node label.
66
+ This implementation returns `str(self)`
67
+ and a common implementation might return `self.name` or similar.
68
+
69
+ *`DOTNodeMixin.dot_node_palette_key`*:
70
+ The default palette index is `self.dot_node_id``.
71
+ - <a name="Graph"></a>`class Graph`: A representation of a graphviz graph suitable for transcribing as DOT.
72
+
73
+ *`Graph.add(self, *items)`*:
74
+ Add a `Node` id or a `Node`s or `Graph`s to `self.nodes`.
75
+
76
+ *`Graph.as_dot(self, *, fold=False, indent='', subindent=' ', graphtype=None) -> str`*:
77
+ Return a DOT representation of this `Graph`.
78
+
79
+ Parameters:
80
+ * `fold`: default `False`; if true then produce indented multiline text
81
+ * `indent`: the prevailing indent if `fold`, default `""`
82
+ * `subindent`: incremental indent of nested items if `fold`, default `" "`
83
+
84
+ *`Graph.join(self, *items, **attrs)`*:
85
+ Join the specified `Node`s, `Node` ids or `Graph`s in an edge.
86
+
87
+ *`Graph.mapping_as_dot(kv: Mapping[str, Any])`*:
88
+ Transcribe a mapping as DOT i.e. an `a_list`.
89
+ - <a name="gvdata"></a>`gvdata(dot_s, **kw)`: Convenience wrapper for `gvprint` which returns the binary image data.
90
+ - <a name="gvdataurl"></a>`gvdataurl(dot_s, **kw)`: Convenience wrapper for `gvprint` which returns the binary image data
91
+ as a `data:` URL.
92
+ - <a name="gvprint"></a>`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,
93
+ to `file` (default `sys.stdout`)
94
+ in format `fmt` using the engine specified by `layout` (default `'dot'`).
95
+
96
+ If `fmt` is unspecified it defaults to `'png'` unless `file`
97
+ is a terminal in which case it defaults to `'sixel'`.
98
+
99
+ In addition to being a file or file descriptor,
100
+ `file` may also take the following special values:
101
+ * `GVCAPTURE`: causes `gvprint` to return the image data as `bytes`
102
+ * `GVDATAURL`: causes `gvprint` to return the image data as a `data:` URL
103
+
104
+ For `GVDATAURL`, the parameter `dataurl_encoding` may be used
105
+ to override the default encoding, which is `'utf8'` for `fmt`
106
+ values `'dot'` and `'svg'`, otherwise `'base64'`.
107
+
108
+ This uses the graphviz utility `dot` to draw graphs.
109
+ If printing in SIXEL format the `img2sixel` utility is required,
110
+ see [https://saitoha.github.io/libsixel/](libsixel).
111
+
112
+ Example:
113
+
114
+ data_url = gvprint('digraph FOO {A->B}', file=GVDATAURL, fmt='svg')
115
+ - <a name="gvsvg"></a>`gvsvg(dot_s, **gvdata_kw)`: Convenience wrapper for `gvprint` which returns an SVG string.
116
+ - <a name="Node"></a>`class Node(DOTNodeMixin)`: Node(id: str, rankdir: str = 'LR', shape: str = 'rect', attrs: dict = <factory>)
117
+ - <a name="quote"></a>`quote(s)`: Quote a string for use in DOT syntax.
118
+ This implementation passes non-keyword identifiers and sequences
119
+ of decimal numerals through unchanged and double quotes other
120
+ strings.
121
+
122
+ # Release Log
123
+
124
+
125
+
126
+ *Release 20260531*:
127
+ * New DOT_KEYWORDS, update quote() to quote keywords.
128
+ * New Node and Graph classes for constructing a graphviz style node graph.
129
+ * gvprint: print a final newline after the sixel output.
130
+
131
+ *Release 20230816*:
132
+ DOTNodeMixin: new dot_node_attrs_str for transcribing a node attributes list.
133
+
134
+ *Release 20221207*:
135
+ New gvsvg() convenience function to return SVG.
136
+
137
+ *Release 20221118*:
138
+ * quote: provide escape sequence for newline.
139
+ * DOTNodeMixin: provide .dot_node_id property, default `str(id(self))`.
140
+ * DOTNodeMixin.dot_node: omit [attrs] if they are empty.
141
+ * DOTNodeMixin: new .dot_node_palette_key property, new __getattr__ for .dot_node_*color attributes, new empty default DOT_NODE_COLOR_PALETTE and DOT_NODE_FILLCOLOR_PALETTE class attributes.
142
+ * DOTNodeMixin.dot_node: include the node label in the attributes.
143
+ * Add colours to DOTNodeMixin.dot_node_attrs and fix "fontcolor".
144
+
145
+ *Release 20220827.1*:
146
+ gvprint: new optional parameter dataurl_encoding to specify the data URL encoding.
147
+
148
+ *Release 20220827*:
149
+ * Remove dependency on cs.lex - now we need only the stdlib.
150
+ * New GVCAPTURE value for gvprint(file=) to return the binary image data as a bytes object; associated gvdata() convenience function.
151
+ * New GVDATAURL value for gvprint(file=) to return the binary image data as a data URL; associated gvdataurl() convenience function.
152
+
153
+ *Release 20220805.1*:
154
+ New DOTNodeMixin, a mixin for classes which can be rendered as a DOT node.
155
+
156
+ *Release 20220805*:
157
+ Initial PyPI release.
@@ -0,0 +1,181 @@
1
+ [project]
2
+ name = "cs-gvutils"
3
+ description = "Graphviz utility functions."
4
+ authors = [
5
+ { name = "Cameron Simpson", email = "cs@cskk.id.au" },
6
+ ]
7
+ keywords = [
8
+ "python3",
9
+ ]
10
+ dependencies = [
11
+ "cs.lex>=20260526",
12
+ ]
13
+ classifiers = [
14
+ "Programming Language :: Python",
15
+ "Programming Language :: Python :: 3",
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Topic :: Software Development :: Libraries :: Python Modules",
20
+ "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
21
+ ]
22
+ version = "20260531"
23
+
24
+ [project.license]
25
+ text = "GNU General Public License v3 or later (GPLv3+)"
26
+
27
+ [project.urls]
28
+ "Monorepo Hg/Mercurial Mirror" = "https://hg.sr.ht/~cameron-simpson/css"
29
+ "Monorepo Git Mirror" = "https://github.com/cameron-simpson/css"
30
+ "MonoRepo Commits" = "https://bitbucket.org/cameron_simpson/css/commits/branch/main"
31
+ Source = "https://github.com/cameron-simpson/css/blob/main/lib/python/cs/gvutils.py"
32
+
33
+ [project.readme]
34
+ text = """
35
+ Graphviz utility functions.
36
+
37
+ *Latest release 20260531*:
38
+ * New DOT_KEYWORDS, update quote() to quote keywords.
39
+ * New Node and Graph classes for constructing a graphviz style node graph.
40
+ * gvprint: print a final newline after the sixel output.
41
+
42
+ See also the [https://www.graphviz.org/documentation/](graphviz documentation)
43
+ and particularly the [https://graphviz.org/doc/info/lang.html](DOT language specification)
44
+ and the [https://www.graphviz.org/doc/info/command.html](`dot` command line tool).
45
+
46
+ Short summary:
47
+ * `DOTNodeMixin`: A mixin providing methods for things which can be drawn as nodes in a DOT graph description.
48
+ * `Graph`: A representation of a graphviz graph suitable for transcribing as DOT.
49
+ * `gvdata`: Convenience wrapper for `gvprint` which returns the binary image data.
50
+ * `gvdataurl`: Convenience wrapper for `gvprint` which returns the binary image data as a `data:` URL.
51
+ * `gvprint`: 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'`).
52
+ * `gvsvg`: Convenience wrapper for `gvprint` which returns an SVG string.
53
+ * `Node`: Node(id: str, rankdir: str = 'LR', shape: str = 'rect', attrs: dict = <factory>).
54
+ * `quote`: Quote a string for use in DOT syntax. This implementation passes non-keyword identifiers and sequences of decimal numerals through unchanged and double quotes other strings.
55
+
56
+ Module contents:
57
+ - <a name=\"DOTNodeMixin\"></a>`class DOTNodeMixin`: A mixin providing methods for things which can be drawn as
58
+ nodes in a DOT graph description.
59
+
60
+ *`DOTNodeMixin.__getattr__(self, attr: str)`*:
61
+ Recognise various `dot_node_*` attributes.
62
+
63
+ `dot_node_*color` is an attribute derives from `self.DOT_NODE_COLOR_*PALETTE`.
64
+
65
+ *`DOTNodeMixin.dot_node(self, label=None, **node_attrs) -> str`*:
66
+ A DOT syntax node definition for `self`.
67
+
68
+ *`DOTNodeMixin.dot_node_attrs(self) -> Mapping[str, str]`*:
69
+ The default DOT node attributes.
70
+
71
+ *`DOTNodeMixin.dot_node_attrs_str(attrs)`*:
72
+ An attributes mapping transcribed for DOT,
73
+ ready for insertion between `[]` in a node definition.
74
+
75
+ *`DOTNodeMixin.dot_node_id`*:
76
+ An id for this DOT node, also the default index into the palettes.
77
+
78
+ *`DOTNodeMixin.dot_node_label(self) -> str`*:
79
+ The default node label.
80
+ This implementation returns `str(self)`
81
+ and a common implementation might return `self.name` or similar.
82
+
83
+ *`DOTNodeMixin.dot_node_palette_key`*:
84
+ The default palette index is `self.dot_node_id``.
85
+ - <a name=\"Graph\"></a>`class Graph`: A representation of a graphviz graph suitable for transcribing as DOT.
86
+
87
+ *`Graph.add(self, *items)`*:
88
+ Add a `Node` id or a `Node`s or `Graph`s to `self.nodes`.
89
+
90
+ *`Graph.as_dot(self, *, fold=False, indent='', subindent=' ', graphtype=None) -> str`*:
91
+ Return a DOT representation of this `Graph`.
92
+
93
+ Parameters:
94
+ * `fold`: default `False`; if true then produce indented multiline text
95
+ * `indent`: the prevailing indent if `fold`, default `\"\"`
96
+ * `subindent`: incremental indent of nested items if `fold`, default `\" \"`
97
+
98
+ *`Graph.join(self, *items, **attrs)`*:
99
+ Join the specified `Node`s, `Node` ids or `Graph`s in an edge.
100
+
101
+ *`Graph.mapping_as_dot(kv: Mapping[str, Any])`*:
102
+ Transcribe a mapping as DOT i.e. an `a_list`.
103
+ - <a name=\"gvdata\"></a>`gvdata(dot_s, **kw)`: Convenience wrapper for `gvprint` which returns the binary image data.
104
+ - <a name=\"gvdataurl\"></a>`gvdataurl(dot_s, **kw)`: Convenience wrapper for `gvprint` which returns the binary image data
105
+ as a `data:` URL.
106
+ - <a name=\"gvprint\"></a>`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,
107
+ to `file` (default `sys.stdout`)
108
+ in format `fmt` using the engine specified by `layout` (default `'dot'`).
109
+
110
+ If `fmt` is unspecified it defaults to `'png'` unless `file`
111
+ is a terminal in which case it defaults to `'sixel'`.
112
+
113
+ In addition to being a file or file descriptor,
114
+ `file` may also take the following special values:
115
+ * `GVCAPTURE`: causes `gvprint` to return the image data as `bytes`
116
+ * `GVDATAURL`: causes `gvprint` to return the image data as a `data:` URL
117
+
118
+ For `GVDATAURL`, the parameter `dataurl_encoding` may be used
119
+ to override the default encoding, which is `'utf8'` for `fmt`
120
+ values `'dot'` and `'svg'`, otherwise `'base64'`.
121
+
122
+ This uses the graphviz utility `dot` to draw graphs.
123
+ If printing in SIXEL format the `img2sixel` utility is required,
124
+ see [https://saitoha.github.io/libsixel/](libsixel).
125
+
126
+ Example:
127
+
128
+ data_url = gvprint('digraph FOO {A->B}', file=GVDATAURL, fmt='svg')
129
+ - <a name=\"gvsvg\"></a>`gvsvg(dot_s, **gvdata_kw)`: Convenience wrapper for `gvprint` which returns an SVG string.
130
+ - <a name=\"Node\"></a>`class Node(DOTNodeMixin)`: Node(id: str, rankdir: str = 'LR', shape: str = 'rect', attrs: dict = <factory>)
131
+ - <a name=\"quote\"></a>`quote(s)`: Quote a string for use in DOT syntax.
132
+ This implementation passes non-keyword identifiers and sequences
133
+ of decimal numerals through unchanged and double quotes other
134
+ strings.
135
+
136
+ # Release Log
137
+
138
+
139
+
140
+ *Release 20260531*:
141
+ * New DOT_KEYWORDS, update quote() to quote keywords.
142
+ * New Node and Graph classes for constructing a graphviz style node graph.
143
+ * gvprint: print a final newline after the sixel output.
144
+
145
+ *Release 20230816*:
146
+ DOTNodeMixin: new dot_node_attrs_str for transcribing a node attributes list.
147
+
148
+ *Release 20221207*:
149
+ New gvsvg() convenience function to return SVG.
150
+
151
+ *Release 20221118*:
152
+ * quote: provide escape sequence for newline.
153
+ * DOTNodeMixin: provide .dot_node_id property, default `str(id(self))`.
154
+ * DOTNodeMixin.dot_node: omit [attrs] if they are empty.
155
+ * DOTNodeMixin: new .dot_node_palette_key property, new __getattr__ for .dot_node_*color attributes, new empty default DOT_NODE_COLOR_PALETTE and DOT_NODE_FILLCOLOR_PALETTE class attributes.
156
+ * DOTNodeMixin.dot_node: include the node label in the attributes.
157
+ * Add colours to DOTNodeMixin.dot_node_attrs and fix \"fontcolor\".
158
+
159
+ *Release 20220827.1*:
160
+ gvprint: new optional parameter dataurl_encoding to specify the data URL encoding.
161
+
162
+ *Release 20220827*:
163
+ * Remove dependency on cs.lex - now we need only the stdlib.
164
+ * New GVCAPTURE value for gvprint(file=) to return the binary image data as a bytes object; associated gvdata() convenience function.
165
+ * New GVDATAURL value for gvprint(file=) to return the binary image data as a data URL; associated gvdataurl() convenience function.
166
+
167
+ *Release 20220805.1*:
168
+ New DOTNodeMixin, a mixin for classes which can be rendered as a DOT node.
169
+
170
+ *Release 20220805*:
171
+ Initial PyPI release."""
172
+ content-type = "text/markdown"
173
+
174
+ [build-system]
175
+ build-backend = "flit_core.buildapi"
176
+ requires = [
177
+ "flit_core >=3.2,<4",
178
+ ]
179
+
180
+ [tool.flit.module]
181
+ name = "cs.gvutils"
@@ -0,0 +1,536 @@
1
+ #!/usr/bin/env python3
2
+
3
+ ''' Graphviz utility functions.
4
+
5
+ See also the [https://www.graphviz.org/documentation/](graphviz documentation)
6
+ and particularly the [https://graphviz.org/doc/info/lang.html](DOT language specification)
7
+ and the [https://www.graphviz.org/doc/info/command.html](`dot` command line tool).
8
+ '''
9
+
10
+ from base64 import b64encode
11
+ from dataclasses import dataclass, field
12
+ from subprocess import Popen, PIPE
13
+ import sys
14
+ from threading import Thread
15
+ from typing import Any, Mapping, Optional, Tuple
16
+ from urllib.parse import quote as urlquote
17
+
18
+ from cs.lex import cutprefix, cutsuffix, indent as indent_text, r
19
+
20
+ __version__ = '20260531'
21
+
22
+ DISTINFO = {
23
+ 'keywords': ["python3"],
24
+ 'classifiers': [
25
+ "Programming Language :: Python",
26
+ "Programming Language :: Python :: 3",
27
+ ],
28
+ 'install_requires': [
29
+ 'cs.lex',
30
+ ],
31
+ }
32
+
33
+ DOT_KEYWORDS = ('strict', 'graph', 'digraph', 'subgraph', 'node', 'edge')
34
+
35
+ def quote(s):
36
+ ''' Quote a string for use in DOT syntax.
37
+ This implementation passes non-keyword identifiers and sequences
38
+ of decimal numerals through unchanged and double quotes other
39
+ strings.
40
+ '''
41
+ if isinstance(s, (int, float)):
42
+ return str(s)
43
+ if all((
44
+ not s[:1].isdigit(),
45
+ (s.isalnum() or s.replace('_', '').isalnum()),
46
+ s.lower() not in DOT_KEYWORDS,
47
+ )):
48
+ return s
49
+ return (
50
+ '"' + s.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n') +
51
+ '"'
52
+ )
53
+
54
+ # special value to capture the output of gvprint as binary data
55
+ GVCAPTURE = object()
56
+
57
+ # special value to capture the output of gvprint as a data: URL
58
+ GVDATAURL = object()
59
+
60
+ # pylint: disable=too-many-branches,too-many-statements,too-many-locals
61
+ def gvprint(
62
+ dot_s, file=None, fmt=None, layout=None, dataurl_encoding=None, **dot_kw
63
+ ):
64
+ ''' Print the graph specified by `dot_s`, a graph in graphViz DOT syntax,
65
+ to `file` (default `sys.stdout`)
66
+ in format `fmt` using the engine specified by `layout` (default `'dot'`).
67
+
68
+ If `fmt` is unspecified it defaults to `'png'` unless `file`
69
+ is a terminal in which case it defaults to `'sixel'`.
70
+
71
+ In addition to being a file or file descriptor,
72
+ `file` may also take the following special values:
73
+ * `GVCAPTURE`: causes `gvprint` to return the image data as `bytes`
74
+ * `GVDATAURL`: causes `gvprint` to return the image data as a `data:` URL
75
+
76
+ For `GVDATAURL`, the parameter `dataurl_encoding` may be used
77
+ to override the default encoding, which is `'utf8'` for `fmt`
78
+ values `'dot'` and `'svg'`, otherwise `'base64'`.
79
+
80
+ This uses the graphviz utility `dot` to draw graphs.
81
+ If printing in SIXEL format the `img2sixel` utility is required,
82
+ see [https://saitoha.github.io/libsixel/](libsixel).
83
+
84
+ Example:
85
+
86
+ data_url = gvprint('digraph FOO {A->B}', file=GVDATAURL, fmt='svg')
87
+ '''
88
+ if file is None:
89
+ file = sys.stdout
90
+ if isinstance(file, str):
91
+ with open(file, 'xb') as f:
92
+ return gvprint(dot_s, file=f, fmt=fmt, layout=layout, **dot_kw)
93
+ if file is GVDATAURL:
94
+ if dataurl_encoding is None:
95
+ dataurl_encoding = 'utf8' if fmt in (
96
+ 'dot',
97
+ 'svg',
98
+ ) else 'base64'
99
+ gvdata = gvprint(dot_s, file=GVCAPTURE, fmt=fmt, layout=layout, **dot_kw)
100
+ data_content_type = f'image/{"svg+xml" if fmt == "svg" else fmt}'
101
+ if dataurl_encoding == 'utf8':
102
+ gv_data_s = gvdata.decode('utf8')
103
+ data_part = urlquote(gv_data_s.replace('\n', ''), safe=':/<>{}')
104
+ elif dataurl_encoding == 'base64':
105
+ data_part = b64encode(gvdata).decode('ascii')
106
+ else:
107
+ raise ValueError(
108
+ "invalid data URL encoding %r; I accept 'utf8' or 'base64'" %
109
+ (dataurl_encoding,)
110
+ )
111
+ return f'data:{data_content_type};{dataurl_encoding},{data_part}'
112
+ if file is GVCAPTURE:
113
+ capture_mode = True
114
+ file = PIPE
115
+ else:
116
+ capture_mode = False
117
+ if layout is None:
118
+ layout = 'dot'
119
+ if fmt is None:
120
+ if file.isatty():
121
+ fmt = 'sixel'
122
+ else:
123
+ fmt = 'png'
124
+ graph_modes = dict(layout=layout, splines='true')
125
+ node_modes = {}
126
+ edge_modes = {}
127
+ for dot_mode, value in dot_kw.items():
128
+ try:
129
+ modetype, mode = dot_mode.split('_', 1)
130
+ except ValueError:
131
+ if dot_mode in ('fg',):
132
+ node_modes.update(color=value)
133
+ edge_modes.update(color=value)
134
+ elif dot_mode in ('fontcolor',):
135
+ node_modes.update(fontcolor=value)
136
+ else:
137
+ graph_modes[dot_mode] = value
138
+ else:
139
+ if modetype == 'graph':
140
+ graph_modes[mode] = value
141
+ elif modetype == 'node':
142
+ node_modes[mode] = value
143
+ elif modetype == 'edge':
144
+ edge_modes[mode] = value
145
+ else:
146
+ raise ValueError(
147
+ "%s=%r: unknown mode type %r,"
148
+ " expected one of graph, node, edge" % (dot_mode, value, modetype)
149
+ )
150
+ dot_fmt = 'png' if fmt == 'sixel' else fmt
151
+ dot_argv = ['dot', f'-T{dot_fmt}']
152
+ for gmode, gvalue in sorted(graph_modes.items()):
153
+ dot_argv.append(f'-G{gmode}={gvalue}')
154
+ for nmode, nvalue in sorted(node_modes.items()):
155
+ dot_argv.append(f'-N{nmode}={nvalue}')
156
+ for emode, evalue in sorted(edge_modes.items()):
157
+ dot_argv.append(f'-E{emode}={evalue}')
158
+ # make sure any preceeding output gets out first
159
+ if file is not PIPE:
160
+ file.flush()
161
+ # subprocesses to wait for in order
162
+ subprocs = []
163
+ output_popen = None
164
+ if fmt == 'sixel':
165
+ # pipeline to pipe "dot" through "img2sixel"
166
+ # pylint: disable=consider-using-with
167
+ img2sixel_popen = Popen(['img2sixel'], stdin=PIPE, stdout=file)
168
+ dot_output = img2sixel_popen.stdin
169
+ subprocs.append(img2sixel_popen)
170
+ output_popen = img2sixel_popen
171
+ else:
172
+ img2sixel_popen = None
173
+ dot_output = file
174
+ # pylint: disable=consider-using-with
175
+ dot_popen = Popen(dot_argv, stdin=PIPE, stdout=dot_output)
176
+ if output_popen is None:
177
+ output_popen = dot_popen
178
+ subprocs.insert(0, dot_popen)
179
+ if img2sixel_popen is not None:
180
+ # release our handle to img2sixel
181
+ img2sixel_popen.stdin.close()
182
+ if capture_mode:
183
+ captures = []
184
+ T = Thread(
185
+ target=lambda: captures.append(output_popen.stdout.read()),
186
+ daemon=True,
187
+ )
188
+ T.start()
189
+ dot_bs = dot_s.encode('ascii')
190
+ dot_popen.stdin.write(dot_bs)
191
+ dot_popen.stdin.close()
192
+ for subp in subprocs:
193
+ subp.wait()
194
+ if capture_mode:
195
+ # get the captured bytes
196
+ T.join()
197
+ bs, = captures
198
+ return bs
199
+ if img2sixel_popen is not None:
200
+ print(file=file)
201
+ return None
202
+
203
+ ## Nothing renders this :-(
204
+ ##
205
+ ##gvprint.__doc__ += (
206
+ ## '\n produces a `data:` URL rendering as:\n <img src="' + gvprint(
207
+ ## 'digraph FOO {A->B}',
208
+ ## file=GVDATAURL,
209
+ ## fmt='svg',
210
+ ## dataurl_encoding='base64',
211
+ ## ) + '">'
212
+ ##)
213
+
214
+ def gvdata(dot_s, **kw):
215
+ ''' Convenience wrapper for `gvprint` which returns the binary image data.
216
+ '''
217
+ return gvprint(dot_s, file=GVCAPTURE, **kw)
218
+
219
+ def gvdataurl(dot_s, **kw):
220
+ ''' Convenience wrapper for `gvprint` which returns the binary image data
221
+ as a `data:` URL.
222
+ '''
223
+ return gvprint(dot_s, file=GVDATAURL, **kw)
224
+
225
+ def gvsvg(dot_s, **gvdata_kw):
226
+ ''' Convenience wrapper for `gvprint` which returns an SVG string.
227
+ '''
228
+ svg = gvdata(dot_s, fmt='svg', **gvdata_kw).decode('utf-8')
229
+ svg = svg[svg.find('<svg'):].rstrip() # trim header and tail
230
+ return svg
231
+
232
+ class DOTNodeMixin:
233
+ ''' A mixin providing methods for things which can be drawn as
234
+ nodes in a DOT graph description.
235
+ '''
236
+
237
+ DOT_NODE_FONTCOLOR_PALETTE = {}
238
+ DOT_NODE_FILLCOLOR_PALETTE = {}
239
+
240
+ def __getattr__(self, attr: str):
241
+ ''' Recognise various `dot_node_*` attributes.
242
+
243
+ `dot_node_*color` is an attribute derives from `self.DOT_NODE_COLOR_*PALETTE`.
244
+ '''
245
+ dot_node_suffix = cutprefix(attr, 'dot_node_')
246
+ if dot_node_suffix is not attr:
247
+ # dot_node_*
248
+ colourname = cutsuffix(dot_node_suffix, 'color')
249
+ if colourname is not dot_node_suffix:
250
+ # dot_node_*color
251
+ palette_name = f'DOT_NODE_{colourname.upper()}COLOR_PALETTE'
252
+ try:
253
+ palette = getattr(self, palette_name)
254
+ except AttributeError:
255
+ # no colour palette
256
+ pass
257
+ else:
258
+ try:
259
+ colour = palette[self.dot_node_palette_key]
260
+ except KeyError:
261
+ colour = palette.get(None)
262
+ return colour
263
+ try:
264
+ sga = super().__getattr__
265
+ except AttributeError as e:
266
+ raise AttributeError(
267
+ "no %s.%s attribute" % (self.__class__.__name__, attr)
268
+ ) from e
269
+ return sga(attr)
270
+
271
+ @property
272
+ def dot_node_id(self):
273
+ ''' An id for this DOT node, also the default index into the palettes.
274
+ '''
275
+ return str(id(self))
276
+
277
+ @property
278
+ def dot_node_palette_key(self):
279
+ ''' The default palette index is `self.dot_node_id``.
280
+ '''
281
+ return self.dot_node_id
282
+
283
+ @staticmethod
284
+ def dot_node_attrs_str(attrs):
285
+ ''' An attributes mapping transcribed for DOT,
286
+ ready for insertion between `[]` in a node definition.
287
+ '''
288
+ strs = []
289
+ for attr, value in attrs.items():
290
+ if isinstance(value, (int, float)):
291
+ value_s = str(value)
292
+ elif isinstance(value, str):
293
+ value_s = quote(value)
294
+ else:
295
+ raise TypeError(
296
+ "attrs[%r]=%s: expected int,float,str" % (attr, r(value))
297
+ )
298
+ strs.append(quote(attr) + '=' + value_s)
299
+ attrs_s = ','.join(strs)
300
+ return attrs_s
301
+
302
+ def dot_node(self, label=None, **node_attrs) -> str:
303
+ ''' A DOT syntax node definition for `self`.
304
+ '''
305
+ if label is None:
306
+ label = self.dot_node_label()
307
+ attrs = dict(self.dot_node_attrs())
308
+ attrs.update(label=label)
309
+ attrs.update(node_attrs)
310
+ if not attrs:
311
+ return quote(label)
312
+ return f'{quote(self.dot_node_id)}[{self.dot_node_attrs_str(attrs)}]'
313
+
314
+ # pylint: disable=no-self-use
315
+ def dot_node_attrs(self) -> Mapping[str, str]:
316
+ ''' The default DOT node attributes.
317
+ '''
318
+ attrs = dict(style='solid')
319
+ fontcolor = self.dot_node_fontcolor
320
+ if fontcolor is not None:
321
+ attrs.update(fontcolor=fontcolor)
322
+ fillcolor = self.dot_node_fillcolor
323
+ if fillcolor is not None:
324
+ attrs.update(style='filled')
325
+ attrs.update(fillcolor=fillcolor)
326
+ return attrs
327
+
328
+ def dot_node_label(self) -> str:
329
+ ''' The default node label.
330
+ This implementation returns `str(self)`
331
+ and a common implementation might return `self.name` or similar.
332
+ '''
333
+ return str(self)
334
+
335
+ @dataclass
336
+ class Node(DOTNodeMixin):
337
+ id: str
338
+ rankdir: str = "LR"
339
+ shape: str = "rect"
340
+ attrs: dict = field(default_factory=dict)
341
+
342
+ def __str__(self):
343
+ return self.as_dot()
344
+
345
+ def __hash__(self):
346
+ return id(self)
347
+
348
+ def __eq__(self, other):
349
+ return self is other
350
+
351
+ @property
352
+ def dot_node_id(self) -> str:
353
+ return self.id
354
+
355
+ def as_dot(self, *, no_attrs=False):
356
+ dot = []
357
+ node_id = self.dot_node_id
358
+ if node_id:
359
+ dot.append(quote(node_id))
360
+ if not no_attrs:
361
+ for k, v in sorted(self.attrs.items()):
362
+ dot.append(f'{k}={quote(v)}')
363
+ return " ".join(dot)
364
+
365
+ @dataclass
366
+ class Graph:
367
+ ''' A representation of a graphviz graph suitable for transcribing as DOT.
368
+ '''
369
+ id: Optional[str] = None
370
+ digraph: bool = False
371
+ strict: bool = False
372
+ attrs: dict = field(default_factory=dict)
373
+ node_attrs: dict = field(default_factory=dict)
374
+ edge_attrs: dict = field(default_factory=dict)
375
+ nodes: Mapping[str, Node] = field(default_factory=dict)
376
+ edges: list[Tuple[list, dict]] = field(default_factory=list)
377
+ subgraphs: Mapping[str, "Graph"] = field(default_factory=dict)
378
+
379
+ def __str__(self):
380
+ return self.as_dot()
381
+
382
+ def __hash__(self):
383
+ return id(self)
384
+
385
+ def __eq__(self, other):
386
+ return self is other
387
+
388
+ def __getitem__(self, node_id: str):
389
+ return self.nodes[node_id]
390
+
391
+ def add(self, *items):
392
+ ''' Add a `Node` id or a `Node`s or `Graph`s to `self.nodes`.
393
+ '''
394
+ for item in items:
395
+ if isinstance(item, str):
396
+ if item not in self.nodes:
397
+ node = Node(item)
398
+ self.nodes[item] = node
399
+ elif isinstance(item, Node):
400
+ if (node := self.nodes.get(item.id)) is None:
401
+ self.nodes[item.id] = item
402
+ elif node is not item:
403
+ raise ValueError(f'self.nodes[{item.id!r}] already exists')
404
+ elif isinstance(item, Graph):
405
+ if (graph := self.subgraphs.get(item.id)) is None:
406
+ self.subgraphs[item.id] = item
407
+ elif graph is not item:
408
+ raise ValueError(f'self.subgraphs[{item.id!r}] already exists')
409
+ else:
410
+ raise TypeError(f'not a Node or Graph: {r(item)}')
411
+
412
+ def join(self, *items, **attrs):
413
+ ''' Join the specified `Node`s, `Node` ids or `Graph`s in an edge.
414
+ '''
415
+ if len(items) < 2:
416
+ raise ValueError(
417
+ f'at least 2 items required to join, received {len(items)}'
418
+ )
419
+ edge_nodes = []
420
+ for item in items:
421
+ if isinstance(item, str):
422
+ if item in self.nodes:
423
+ item = self.nodes[item]
424
+ else:
425
+ node = Node(item)
426
+ self.nodes[item] = node
427
+ item = node
428
+ elif not isinstance(item, (Node, Graph)):
429
+ raise TypeError(f'not a Node or Graph or Node id: {r(item)}')
430
+ edge_nodes.append(item)
431
+ self.edges.append((edge_nodes, attrs))
432
+
433
+ @staticmethod
434
+ def mapping_as_dot(kv: Mapping[str, Any]):
435
+ ''' Transcribe a mapping as DOT i.e. an `a_list`.
436
+ '''
437
+ return ", ".join(f'{k}={quote(v)}' for k, v in sorted(kv.items()))
438
+
439
+ def as_dot(
440
+ self,
441
+ *,
442
+ fold=False,
443
+ indent="",
444
+ subindent=" ",
445
+ graphtype=None,
446
+ ) -> str:
447
+ ''' Return a DOT representation of this `Graph`.
448
+
449
+ Parameters:
450
+ * `fold`: default `False`; if true then produce indented multiline text
451
+ * `indent`: the prevailing indent if `fold`, default `""`
452
+ * `subindent`: incremental indent of nested items if `fold`, default `" "`
453
+ '''
454
+ dot = [
455
+ " ".join(
456
+ filter(
457
+ None, (
458
+ ('strict' if self.strict else ''),
459
+ graphtype or ('digraph' if self.digraph else 'graph'),
460
+ self.id and quote(self.id),
461
+ '{',
462
+ )
463
+ )
464
+ )
465
+ ]
466
+ # graph wide object defaults
467
+ if self.attrs:
468
+ dot.append(f'graph [ {self.mapping_as_dot(self.attrs)} ]')
469
+ if self.node_attrs:
470
+ dot.append(f'node [ {self.mapping_as_dot(self.node_attrs)} ]')
471
+ if self.edge_attrs:
472
+ dot.append(f'edge [ {self.mapping_as_dot(self.edge_attrs)} ]')
473
+ # define nodes and their attributes
474
+ dot.extend(node.as_dot() for node in self.nodes.values())
475
+ # define subgraphs and their attributes
476
+ for graph in self.subgraphs.values():
477
+ dot.append(
478
+ graph.as_dot(fold=fold, subindent=subindent, graphtype='subgraph')
479
+ )
480
+ for edge_nodes, edge_attrs in self.edges:
481
+ edge_dot = []
482
+ first = True
483
+ for node in edge_nodes:
484
+ if first:
485
+ first = False
486
+ else:
487
+ edge_dot.append('->' if self.digraph else '--')
488
+ if isinstance(node, Node):
489
+ edge_dot.append(node.as_dot(no_attrs=True))
490
+ elif isinstance(node, Graph):
491
+ edge_dot.append(
492
+ node.as_dot(
493
+ fold=fold,
494
+ indent=indent + subindent,
495
+ subindent=subindent,
496
+ graphtype='subgraph'
497
+ )
498
+ )
499
+ else:
500
+ raise TypeError(f'unhandled egde node {r(node)}')
501
+ edge_dot.append(self.mapping_as_dot(edge_attrs))
502
+ dot.append(" ".join(edge_dot))
503
+ dot.append('}')
504
+ if not fold:
505
+ return " ".join(dot)
506
+ line0, *midlines, linez = dot
507
+ return indent + f'\n{indent}'.join(
508
+ (
509
+ line0,
510
+ *(indent_text(line, subindent) for line in midlines),
511
+ linez,
512
+ )
513
+ )
514
+
515
+ def print(self, **gvprint_kw):
516
+ dot_s = self.as_dot(fold=True)
517
+ return gvprint(dot_s, **gvprint_kw)
518
+
519
+ if __name__ == '__main__':
520
+ G = Graph(digraph=True)
521
+ G.attrs.update(rankdir="LR")
522
+ G.node_attrs.update(shape="rect")
523
+ G.node_attrs["b"] = "c"
524
+ g1 = Graph()
525
+ g1.add("ga", "gb")
526
+ g2 = Graph("cluster-g2")
527
+ g2.add("x", "y")
528
+ G.add("a", "b", g1, g2)
529
+ G["a"].attrs["x"] = 1
530
+ G.join("a", "b", "c")
531
+ G.join("b", g1)
532
+ G.join("gb", g2)
533
+ print(G.as_dot(fold=True))
534
+ for layout in 'neato', 'fdp', 'sfdp', 'circo', 'twopi', 'osage', 'patchwork', 'dot':
535
+ print("LAYOUT", layout)
536
+ G.print(layout=layout)