From 77b9ad3abb43dddbcee84fe27d61f73267571e23 Mon Sep 17 00:00:00 2001 From: Joost van Zwieten Date: Fri, 20 Nov 2020 16:18:21 +0100 Subject: [PATCH] add _graph module The evaluable module has two different implementations for producing graphs: asciitree and graphviz. While there is not much overlap between the two graphs at this point, that will change when we have loops embedded inside evaluables. This commit adds a `_graph` module, for internal use only, that provides a unified interface for drawing acyclic, directed graphs, with two backends: asciitree and graphviz. --- .github/workflows/test.yaml | 3 + nutils/_graph.py | 222 +++++++++++++++++++++++++ tests/test_graph.py | 312 ++++++++++++++++++++++++++++++++++++ 3 files changed, 537 insertions(+) create mode 100644 nutils/_graph.py create mode 100644 tests/test_graph.py diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 8a7e97bb5..f0ee708e8 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -73,6 +73,9 @@ jobs: with: name: python-package path: dist/ + - name: Install Graphviz + if: ${{ matrix.os == 'ubuntu-latest' }} + run: sudo apt install -y graphviz - name: Install Nutils and dependencies id: install env: diff --git a/nutils/_graph.py b/nutils/_graph.py new file mode 100644 index 000000000..71ab04a3c --- /dev/null +++ b/nutils/_graph.py @@ -0,0 +1,222 @@ +# Copyright (c) 2020 Evalf +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from typing import Mapping, MutableMapping, Optional, Iterator, Iterable, Generator, Sequence, List, MutableSet, Callable, Tuple, Generic, TypeVar, Dict +import typing, itertools, treelog, subprocess, abc, html + +Metadata = TypeVar('Metadata') +GraphvizColorCallback = Callable[['Node'], Optional[str]] + +class Subgraph: + + def __init__(self, label: str, parent: Optional['Subgraph'] = None) -> None: + self.label = label + self.parent = parent + +class Node(Generic[Metadata], metaclass=abc.ABCMeta): + + def __init__(self, metadata: Metadata, subgraph: Optional[Subgraph] = None) -> None: + self.metadata = metadata + self.subgraph = subgraph + + @abc.abstractmethod + def __bool__(self) -> bool: + raise NotImplementedError # pragma: no cover + + @abc.abstractmethod + def _generate_asciitree_nodes(self, cache: MutableMapping['Node[Metadata]', str], subgraph_ids: Mapping[Optional[Subgraph], str], id_gen: Iterator[str], select: str, bridge: str) -> Generator[str, None, None]: + raise NotImplementedError # pragma: no cover + + @abc.abstractmethod + def _collect_graphviz_nodes_edges(self, cache: MutableMapping['Node[Metadata]', str], id_gen: Iterator[str], nodes: MutableMapping[Optional[Subgraph], List[str]], edges: List[str], parent_subgraph: Optional[Subgraph], fill_color: Optional[GraphvizColorCallback] = None) -> Optional[str]: + raise NotImplementedError # pragma: no cover + + def walk(self, seen: MutableSet['Node[Metadata]']) -> Iterator['Node[Metadata]']: + raise NotImplementedError # pragma: no cover + + def generate_asciitree(self, richoutput: bool = False) -> str: + subgraph_children = _collect_subgraphs(self) + if len(subgraph_children) > 1: + subgraph_ids = {} # type: Dict[Optional[Subgraph], str] + parts = ['SUBGRAPHS\n'], _generate_asciitree_subgraphs(subgraph_children, subgraph_ids, None, '', ''), ['NODES\n'] # type: Sequence[Iterable[str]] + else: + subgraph_ids = {None: ''} + parts = [] + asciitree = ''.join(itertools.chain(*parts, self._generate_asciitree_nodes({}, subgraph_ids, map(str, itertools.count()), '', ''))) + if not richoutput: + asciitree = asciitree.replace('├', ':').replace('└', ':').replace('│', '|') + return asciitree + + def generate_graphviz_source(self, *, fill_color: Optional[GraphvizColorCallback] = None) -> str: + edges = [] # type: List[str] + nodes = {} # type: Dict[Optional[Subgraph], List[str]] + subgraph_children = _collect_subgraphs(self) + id_gen = map(str, itertools.count()) + self._collect_graphviz_nodes_edges({}, id_gen, nodes, edges, None, fill_color) + return ''.join(itertools.chain(['digraph {graph [dpi=72];'], _generate_graphviz_subgraphs(subgraph_children, nodes, None, id_gen), edges, ['}'])) + + def export_graphviz(self, *, fill_color: Optional[GraphvizColorCallback] = None, dot_path: str = 'dot', image_type: str = 'png') -> None: + src = self.generate_graphviz_source(fill_color=fill_color) + with treelog.infofile('dot.'+image_type, 'wb') as img: + src = src.replace(';', ';\n') + status = subprocess.run([dot_path,'-Gstart=1','-T'+image_type], input=src.encode(), stdout=subprocess.PIPE) + if status.returncode: + for i, line in enumerate(src.split('\n'), 1): + print('{:04d} {}'.format(i, line)) + treelog.warning('graphviz failed for error code', status.returncode) + img.write(status.stdout) + +class RegularNode(Node[Metadata]): + + def __init__(self, label: str, args: Sequence[Node[Metadata]], kwargs: Mapping[str, Node[Metadata]], metadata: Metadata, subgraph: Optional[Subgraph] = None) -> None: + self._label = label + self._args = tuple(args) + self._kwargs = dict(kwargs) + super().__init__(metadata, subgraph) + + def __bool__(self) -> bool: + return True + + def _generate_asciitree_nodes(self, cache: MutableMapping[Node[Metadata], str], subgraph_ids: Mapping[Optional[Subgraph], str], id_gen: Iterator[str], select: str, bridge: str) -> Generator[str, None, None]: + if self in cache: + yield '{}{}\n'.format(select, cache[self]) + else: + subgraph_id = subgraph_ids[self.subgraph] + cache[self] = id = '%{}{}'.format(subgraph_id, next(id_gen)) + yield '{}{} = {}\n'.format(select, id, self._label.replace('\n', '; ')) + args = tuple(('', arg) for arg in self._args if arg) + tuple(('{} = '.format(name), arg) for name, arg in self._kwargs.items()) + for i, (prefix, arg) in enumerate(args, 1-len(args)): + yield from arg._generate_asciitree_nodes(cache, subgraph_ids, id_gen, bridge+('├ ' if i else '└ ')+prefix, bridge+('│ ' if i else ' ')) + + def _collect_graphviz_nodes_edges(self, cache: MutableMapping[Node[Metadata], str], id_gen: Iterator[str], nodes: MutableMapping[Optional[Subgraph], List[str]], edges: List[str], parent_subgraph: Optional[Subgraph], fill_color: Optional[GraphvizColorCallback] = None) -> Optional[str]: + if self in cache: + return cache[self] + cache[self] = id = next(id_gen) + if self._kwargs: + table = [''] + table += ['', *(''.format(ikwarg, html.escape(name)) for ikwarg, name in enumerate(self._kwargs)), ''] + table += [''.format(len(self._kwargs), html.escape(line)) for line in self._label.split('\n')] + table += ['
{}
{}
'] + attributes = ['shape=plain', 'label=<{}>'.format(''.join(table))] + else: + attributes = ['shape=box', 'label="{}"'.format(self._label.replace('"', '\\"'))] + attributes.extend(_graphviz_fill_color_attributes(self, fill_color)) + nodes.setdefault(self.subgraph, []).append('{} [{}];'.format(id, ','.join(attributes))) + for arg in self._args: + arg_id = arg._collect_graphviz_nodes_edges(cache, id_gen, nodes, edges, self.subgraph, fill_color) + if arg_id: + edges.append('{} -> {};'.format(arg_id, id)) + for ikwarg, arg in enumerate(self._kwargs.values()): + arg_id = arg._collect_graphviz_nodes_edges(cache, id_gen, nodes, edges, self.subgraph, fill_color) + if arg_id: + edges.append('{} -> {}:kwarg{}:n;'.format(arg_id, id, ikwarg)) + return id + + def walk(self, seen: MutableSet[Node[Metadata]]) -> Iterator[Node[Metadata]]: + if self in seen: + return + seen.add(self) + yield self + for arg in self._args: + yield from arg.walk(seen) + for arg in self._kwargs.values(): + yield from arg.walk(seen) + +class DuplicatedLeafNode(Node[Metadata]): + + def __init__(self, label: str, metadata: Metadata) -> None: + self._label = label + super().__init__(metadata) + + def __bool__(self) -> bool: + return True + + def _generate_asciitree_nodes(self, cache: MutableMapping[Node[Metadata], str], subgraph_ids: Mapping[Optional[Subgraph], str], id_gen: Iterator[str], select: str, bridge: str) -> Generator[str, None, None]: + yield '{}{}\n'.format(select, self._label.replace('\n', '; ')) + + def _collect_graphviz_nodes_edges(self, cache: MutableMapping[Node[Metadata], str], id_gen: Iterator[str], nodes: MutableMapping[Optional[Subgraph], List[str]], edges: List[str], parent_subgraph: Optional[Subgraph], fill_color: Optional[GraphvizColorCallback] = None) -> Optional[str]: + id = next(id_gen) + attributes = ['shape=box', 'label="{}"'.format(self._label.replace('"', '\\"')), *_graphviz_fill_color_attributes(self, fill_color)] + nodes.setdefault(parent_subgraph, []).append('{} [{}];'.format(id, ','.join(attributes))) + return id + + def walk(self, seen: MutableSet[Node[Metadata]]) -> Iterator[Node[Metadata]]: + if self in seen: + return + seen.add(self) + yield self + +class InvisibleNode(Node[Metadata]): + + def __init__(self, metadata: Metadata) -> None: + super().__init__(metadata) + + def __bool__(self) -> bool: + return False + + def _generate_asciitree_nodes(self, cache: MutableMapping[Node[Metadata], str], subgraph_ids: Mapping[Optional[Subgraph], str], id_gen: Iterator[str], select: str, bridge: str) -> Generator[str, None, None]: + yield '{}\n'.format(select) + + def _collect_graphviz_nodes_edges(self, cache: MutableMapping[Node[Metadata], str], id_gen: Iterator[str], nodes: MutableMapping[Optional[Subgraph], List[str]], edges: List[str], parent_subgraph: Optional[Subgraph], fill_color: Optional[GraphvizColorCallback] = None) -> Optional[str]: + return None + + def walk(self, seen: MutableSet[Node[Metadata]]) -> Iterator[Node[Metadata]]: + if self in seen: + return + seen.add(self) + yield self + +def _graphviz_fill_color_attributes(node: Node[Metadata], fill_color: Optional[GraphvizColorCallback]) -> Sequence[str]: + if not fill_color: + return () + value = fill_color(node) + if value is None: + return () + return 'style=filled', 'fillcolor="{}"'.format(value) + +def _collect_subgraphs(node: Node[Metadata]) -> Dict[Optional[Subgraph], List[Subgraph]]: + children = {None: []} # type: Dict[Optional[Subgraph], List[Subgraph]] + for node in node.walk(set()): + subgraph = node.subgraph + if subgraph and subgraph not in children: + children[subgraph] = [] + while subgraph and subgraph.parent not in children: + children[subgraph.parent] = [subgraph] + subgraph = subgraph.parent + subgraph = typing.cast(Subgraph, subgraph) + children[subgraph.parent].append(subgraph) + return children + +def _generate_asciitree_subgraphs(children: Mapping[Optional[Subgraph], Sequence[Subgraph]], subgraph_ids: MutableMapping[Optional[Subgraph], str], subgraph: Optional[Subgraph], select: str, bridge: str) -> Iterator[str]: + assert subgraph not in subgraph_ids + subgraph_ids[subgraph] = id = chr(ord('A') + len(subgraph_ids)) + if subgraph: + yield '{}{} = {}\n'.format(select, id, subgraph.label.replace('\n', '; ')) + else: + yield '{}{}\n'.format(select, id) + for i, child in enumerate(children[subgraph], 1-len(children[subgraph])): + yield from _generate_asciitree_subgraphs(children, subgraph_ids, child, bridge+('├ ' if i else '└ '), bridge+('│ ' if i else ' ')) + +def _generate_graphviz_subgraphs(children: Mapping[Optional[Subgraph], Sequence[Subgraph]], nodes: Mapping[Optional[Subgraph], Sequence[str]], subgraph: Optional[Subgraph], id_gen: Iterator[str]) -> Iterator[str]: + for child in children[subgraph]: + yield 'subgraph cluster{} {{'.format(next(id_gen)) + yield from _generate_graphviz_subgraphs(children, nodes, child, id_gen) + yield '}' + yield from nodes.get(subgraph, ()) diff --git a/tests/test_graph.py b/tests/test_graph.py new file mode 100644 index 000000000..90dbb4991 --- /dev/null +++ b/tests/test_graph.py @@ -0,0 +1,312 @@ +from nutils.testing import TestCase +from nutils import _graph +import itertools, unittest, sys + +class DummyNode(_graph.Node): + + def __init__(self, label='', metadata=None): + assert '\n' not in label + self.label = label + super().__init__(metadata) + + def __bool__(self): + return bool(self.label) + + def _generate_asciitree_nodes(self, cache, graph_ids, id_gen, select, bridge): + yield '{}{}\n'.format(select, self.label) + + def _collect_graphviz_nodes_edges(self, cache, id_gen, nodes, edges, parent_graph, fill_color=None): + if self: + id = next(id_gen) + nodes.setdefault(parent_graph, []).append('{} [label="{}"];'.format(id, self.label)) + return id + + def walk(self, seen): + yield self + +class RegularNode(TestCase): + + def test_truthiness(self): + self.assertTrue(_graph.RegularNode('test', (), {}, 'meta')) + + @unittest.skipIf(sys.version_info < (3, 6), 'test requires dict with insertion order') + def test_generate_asciitree_nodes(self): + args = DummyNode('a'), DummyNode('b'), DummyNode() + kwargs = dict(spam=DummyNode('d'), eggs=DummyNode('e')) + node = _graph.RegularNode('test', args, kwargs, 'meta') + cache = {} + graph_ids = {None: 'X'} + cnt = map(str, itertools.count()) + with self.subTest('first'): + self.assertEqual(list(node._generate_asciitree_nodes(cache, graph_ids, cnt, 'S', 'B')), [ + 'S%X0 = test\n', + 'B├ a\n', + 'B├ b\n', + 'B├ spam = d\n', + 'B└ eggs = e\n']) + with self.subTest('second'): + self.assertEqual(list(node._generate_asciitree_nodes(cache, graph_ids, cnt, 'S', 'B')), [ + 'S%X0\n']) + + def test_collect_graphviz_nodes_edges_args(self): + args = DummyNode('a'), DummyNode('b'), DummyNode() + node = _graph.RegularNode('test', args, {}, 'meta') + cache = {} + nodes = {} + edges = [] + cnt = map(str, itertools.count()) + for sub in 'first', 'second': + with self.subTest(sub): + self.assertEqual(node._collect_graphviz_nodes_edges(cache, cnt, nodes, edges, _graph.Subgraph('sub'), None), '0') + self.assertEqual(edges, ['1 -> 0;', '2 -> 0;']) + self.assertEqual(nodes, {None: ['0 [shape=box,label="test"];', '1 [label="a"];', '2 [label="b"];']}) + + @unittest.skipIf(sys.version_info < (3, 6), 'requires dict with insertion order') + def test_collect_graphviz_nodes_edges_mixed(self): + args = DummyNode('a'), DummyNode('b'), DummyNode() + kwargs = dict(spam=DummyNode('d'), eggs=DummyNode('e')) + node = _graph.RegularNode('test', args, kwargs, 'meta') + label = ( + '' + '' + '' + '' + '' + '' + '
spameggs
test
') + cache = {} + nodes = {} + edges = [] + cnt = map(str, itertools.count()) + for sub in 'first', 'second': + with self.subTest(sub): + self.assertEqual(node._collect_graphviz_nodes_edges(cache, cnt, nodes, edges, _graph.Subgraph('sub'), None), '0') + self.assertEqual(edges, ['1 -> 0;', '2 -> 0;', '3 -> 0:kwarg0:n;', '4 -> 0:kwarg1:n;']) + self.assertEqual(nodes, {None: ['0 [shape=plain,label=<{}>];'.format(label), '1 [label="a"];', '2 [label="b"];', '3 [label="d"];', '4 [label="e"];']}) + + def test_graphviz_fill_color(self): + args = DummyNode('a'), DummyNode('b'), DummyNode() + node = _graph.RegularNode('test', args, {}, 'meta') + with self.subTest('some'): + nodes = {} + node._collect_graphviz_nodes_edges({}, map(str, itertools.count()), nodes, [], None, lambda n: 'red') + self.assertEqual(nodes[None][0], '0 [shape=box,label="test",style=filled,fillcolor="red"];') + with self.subTest('none'): + nodes = {} + node._collect_graphviz_nodes_edges({}, map(str, itertools.count()), nodes, [], None, lambda n: None) + self.assertEqual(nodes[None][0], '0 [shape=box,label="test"];') + + @unittest.skipIf(sys.version_info < (3, 6), 'test requires dict with insertion order') + def test_walk(self): + args = DummyNode('a'), DummyNode('b'), DummyNode() + kwargs = dict(spam=DummyNode('d'), eggs=DummyNode('e')) + node = _graph.RegularNode('test', args, kwargs, 'meta') + seen = set() + self.assertEqual(list(node.walk(seen)), [node, *args, *kwargs.values()]) + self.assertEqual(list(node.walk(seen)), []) + +class DuplicatedLeafNode(TestCase): + + def setUp(self): + super().setUp() + self.subgraph = _graph.Subgraph('sub') + self.node = _graph.DuplicatedLeafNode('test', 'meta') + + def test_truthiness(self): + self.assertTrue(self.node) + + def test_generate_asciitree_nodes(self): + cache = {} + graph_ids = {None: 'X', self.subgraph: 'Y'} + cnt = map(str, itertools.count()) + with self.subTest('first'): + self.assertEqual(list(self.node._generate_asciitree_nodes(cache, graph_ids, cnt, 'S', 'B')), [ + 'Stest\n']) + with self.subTest('second'): + self.assertEqual(list(self.node._generate_asciitree_nodes(cache, graph_ids, cnt, 'S', 'B')), [ + 'Stest\n']) + + def test_collect_graphviz_nodes_edges(self): + cache = {} + nodes = {} + edges = [] + cnt = map(str, itertools.count()) + with self.subTest('first-root'): + self.assertEqual(self.node._collect_graphviz_nodes_edges(cache, cnt, nodes, edges, None, None), '0') + self.assertEqual(edges, []) + self.assertEqual(nodes, {None: ['0 [shape=box,label="test"];']}) + with self.subTest('second-root'): + self.assertEqual(self.node._collect_graphviz_nodes_edges(cache, cnt, nodes, edges, None, None), '1') + self.assertEqual(edges, []) + self.assertEqual(nodes, {None: ['0 [shape=box,label="test"];', '1 [shape=box,label="test"];']}) + with self.subTest('sub'): + self.assertEqual(self.node._collect_graphviz_nodes_edges(cache, cnt, nodes, edges, self.subgraph, None), '2') + self.assertEqual(edges, []) + self.assertEqual(nodes, {None: ['0 [shape=box,label="test"];', '1 [shape=box,label="test"];'], self.subgraph: ['2 [shape=box,label="test"];']}) + + def test_graphviz_fill_color(self): + with self.subTest('some'): + nodes = {} + self.node._collect_graphviz_nodes_edges({}, map(str, itertools.count()), nodes, [], None, lambda n: 'red') + self.assertEqual(nodes[None][0], '0 [shape=box,label="test",style=filled,fillcolor="red"];') + with self.subTest('none'): + nodes = {} + self.node._collect_graphviz_nodes_edges({}, map(str, itertools.count()), nodes, [], None, lambda n: None) + self.assertEqual(nodes[None][0], '0 [shape=box,label="test"];') + + def test_walk(self): + seen = set() + self.assertEqual(list(self.node.walk(seen)), [self.node]) + self.assertEqual(list(self.node.walk(seen)), []) + +class InvisibleNode(TestCase): + + def setUp(self): + super().setUp() + self.node = _graph.InvisibleNode('meta') + + def test_truthiness(self): + self.assertFalse(self.node) + + def test_generate_asciitree_nodes(self): + cache = {} + self.assertEqual(list(self.node._generate_asciitree_nodes({}, {None: 'X'}, map(str, itertools.count()), 'S', 'B')), ['S\n']) + + def test_collect_graphviz_nodes_edges(self): + cache = {} + nodes = {} + edges = [] + cnt = map(str, itertools.count()) + self.assertEqual(self.node._collect_graphviz_nodes_edges({}, map(str, itertools.count()), nodes, edges, None, None), None) + self.assertEqual(edges, []) + self.assertEqual(nodes, {}) + + def test_walk(self): + seen = set() + self.assertEqual(list(self.node.walk(seen)), [self.node]) + self.assertEqual(list(self.node.walk(seen)), []) + +class generate(TestCase): + + def setUp(self): + super().setUp() + a = _graph.RegularNode('a', (), {}, None) + b = _graph.RegularNode('b', (a,), {}, None) + c = _graph.RegularNode('c', (a,b), {}, 'red') + d = _graph.RegularNode('d', (c,a), {}, None) + self.single = d + B = _graph.Subgraph('B', None) + C = _graph.Subgraph('C', B) + D = _graph.Subgraph('D', B) + E = _graph.Subgraph('E', C) + e = _graph.RegularNode('e', (a,), {}, None, E) + f = _graph.RegularNode('f', (b,e), {}, None, C) + g = _graph.RegularNode('g', (a,), {}, None, D) + h = _graph.RegularNode('h', (d,f), {}, None, B) + i = _graph.RegularNode('i', (e,f,g), {}, None, E) + j = _graph.RegularNode('j', (i,), {}, None) + self.multiple = j + + def test_single_asciitree_rich(self): + self.assertEqual(self.single.generate_asciitree(True), + '%0 = d\n' + '├ %1 = c\n' + '│ ├ %2 = a\n' + '│ └ %3 = b\n' + '│ └ %2\n' + '└ %2\n') + + def test_single_asciitree_unrich(self): + self.assertEqual(self.single.generate_asciitree(False), + '%0 = d\n' + ': %1 = c\n' + '| : %2 = a\n' + '| : %3 = b\n' + '| : %2\n' + ': %2\n') + + def test_single_graphviz_source(self): + self.assertEqual(self.single.generate_graphviz_source(), + 'digraph {' + 'graph [dpi=72];' + '0 [shape=box,label="d"];' + '1 [shape=box,label="c"];' + '2 [shape=box,label="a"];' + '3 [shape=box,label="b"];' + '2 -> 1;' + '2 -> 3;' + '3 -> 1;' + '1 -> 0;' + '2 -> 0;' + '}') + + def test_single_graphviz_source_fill_color(self): + self.assertEqual(self.single.generate_graphviz_source(fill_color=lambda node: node.metadata), + 'digraph {' + 'graph [dpi=72];' + '0 [shape=box,label="d"];' + '1 [shape=box,label="c",style=filled,fillcolor="red"];' + '2 [shape=box,label="a"];' + '3 [shape=box,label="b"];' + '2 -> 1;' + '2 -> 3;' + '3 -> 1;' + '1 -> 0;' + '2 -> 0;' + '}') + + def test_multiple_asciitree(self): + self.assertEqual(self.multiple.generate_asciitree(True), + 'SUBGRAPHS\n' + 'A\n' + '└ B = B\n' + ' ├ C = C\n' + ' │ └ D = E\n' + ' └ E = D\n' + 'NODES\n' + '%A0 = j\n' + '└ %D1 = i\n' + ' ├ %D2 = e\n' + ' │ └ %A3 = a\n' + ' ├ %C4 = f\n' + ' │ ├ %A5 = b\n' + ' │ │ └ %A3\n' + ' │ └ %D2\n' + ' └ %E6 = g\n' + ' └ %A3\n') + + def test_multiple_graphviz_source(self): + self.assertEqual(self.multiple.generate_graphviz_source(), + 'digraph {' + 'graph [dpi=72];' + 'subgraph cluster7 {' + 'subgraph cluster8 {' + 'subgraph cluster9 {' + '1 [shape=box,label="i"];' + '2 [shape=box,label="e"];' + '}' + '4 [shape=box,label="f"];' + '}' + 'subgraph cluster10 {' + '6 [shape=box,label="g"];' + '}' + '}' + '0 [shape=box,label="j"];' + '3 [shape=box,label="a"];' + '5 [shape=box,label="b"];' + '3 -> 2;' + '2 -> 1;' + '3 -> 5;' + '5 -> 4;' + '2 -> 4;' + '4 -> 1;' + '3 -> 6;' + '6 -> 1;' + '1 -> 0;' + '}') + + def test_export_graphviz(self): + try: + self.multiple.export_graphviz() + except FileNotFoundError: + self.skipTest('graphviz not available')