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.
- cs_gvutils-20260531/PKG-INFO +157 -0
- cs_gvutils-20260531/pyproject.toml +181 -0
- cs_gvutils-20260531/src/cs/gvutils.py +536 -0
|
@@ -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)
|