From 5cbc378c8e76ade11d003d2732f7387060853721 Mon Sep 17 00:00:00 2001 From: Matthias Diener <mdiener@illinois.edu> Date: Tue, 27 Dec 2022 02:01:32 +0100 Subject: [PATCH] add get_graph_dot_code and show_dot (#151) * add visualize_graph * add package * replace graphviz * lint fixes * small style fixes * fix exception text * restructure * add a small test * skip test if dot not available * further restructure, rename to get_graph_dot_code * remove useless assert * small doc fix * change Iterable to Collection * flake8 * fix log message * explicit return in non-svg case * make sure that nodes not in graph.keys() appear in output * rename dot.py -> graphviz.py * add foldmethod * add edge_labels * fix doc * trigger GitHub actions * bump CI again * rename to as_graphviz_dot and add noed_labels * remove explicit edges storage * test node_labels explicitly * flake8 --- doc/reference.rst | 2 + pytools/graph.py | 58 +++++++++++++++++ pytools/graphviz.py | 133 +++++++++++++++++++++++++++++++++++++++ test/test_graph_tools.py | 33 ++++++++++ 4 files changed, 226 insertions(+) create mode 100644 pytools/graphviz.py diff --git a/doc/reference.rst b/doc/reference.rst index 8c7ccc2..d18c2c5 100644 --- a/doc/reference.rst +++ b/doc/reference.rst @@ -1,2 +1,4 @@ .. automodule:: pytools .. automodule:: pytools.datatable + +.. automodule:: pytools.graphviz diff --git a/pytools/graph.py b/pytools/graph.py index 69421d3..00bac77 100644 --- a/pytools/graph.py +++ b/pytools/graph.py @@ -43,6 +43,7 @@ Graph Algorithms .. autofunction:: compute_transitive_closure .. autofunction:: contains_cycle .. autofunction:: compute_induced_subgraph +.. autofunction:: as_graphviz_dot .. autofunction:: validate_graph .. autofunction:: is_connected @@ -410,6 +411,63 @@ def compute_induced_subgraph(graph: Mapping[NodeT, Set[NodeT]], # }}} +# {{{ as_graphviz_dot + +def as_graphviz_dot(graph: GraphT[NodeT], + node_labels: Optional[Callable[[NodeT], str]] = None, + edge_labels: Optional[Callable[[NodeT, NodeT], str]] = None) \ + -> str: + """ + Create a visualization of the graph *graph* in the + `dot <http://graphviz.org/>`__ language. + + :arg node_labels: An optional function that returns node labels + for each node. + + :arg edge_labels: An optional function that returns edge labels + for each pair of nodes. + + :returns: A string in the `dot <http://graphviz.org/>`__ language. + """ + from pytools import UniqueNameGenerator + id_gen = UniqueNameGenerator(forced_prefix="mynode") + + from pytools.graphviz import dot_escape + + if node_labels is None: + node_labels = lambda x: str(x) + + if edge_labels is None: + edge_labels = lambda x, y: "" + + node_to_id = {} + + for node, targets in graph.items(): + if node not in node_to_id: + node_to_id[node] = id_gen() + for t in targets: + if t not in node_to_id: + node_to_id[t] = id_gen() + + # Add nodes + content = "\n".join( + [f'{node_to_id[node]} [label="{dot_escape(node_labels(node))}"];' + for node in node_to_id.keys()]) + + content += "\n" + + # Add edges + content += "\n".join( + [f"{node_to_id[node]} -> {node_to_id[t]} " + f'[label="{dot_escape(edge_labels(node, t))}"];' + for (node, targets) in graph.items() + for t in targets]) + + return f"digraph mygraph {{\n{ content }\n}}\n" + +# }}} + + # {{{ validate graph def validate_graph(graph: GraphT[NodeT]) -> None: diff --git a/pytools/graphviz.py b/pytools/graphviz.py new file mode 100644 index 0000000..3340b7b --- /dev/null +++ b/pytools/graphviz.py @@ -0,0 +1,133 @@ +__copyright__ = """ +Copyright (C) 2013 Andreas Kloeckner +Copyright (C) 2014 Matt Wala +""" + +__license__ = """ +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. +""" + +__doc__ = """ +Dot helper functions +==================== + +.. autofunction:: dot_escape +.. autofunction:: show_dot +""" + +from typing import Optional + +import html +import logging +logger = logging.getLogger(__name__) + + +# {{{ graphviz / dot interactive show + +def dot_escape(s: str) -> str: + """ + Escape the string *s* for compatibility with the + `dot <http://graphviz.org/>`__ language, particularly + backslashes and HTML tags. + + :arg s: The input string to escape. + + :returns: *s* with special characters escaped. + """ + # "\" and HTML are significant in graphviz. + return html.escape(s.replace("\\", "\\\\")) + + +def show_dot(dot_code: str, output_to: Optional[str] = None) -> Optional[str]: + """ + Visualize the graph represented by *dot_code*. + + :arg dot_code: An instance of :class:`str` in the `dot <http://graphviz.org/>`__ + language to visualize. + :arg output_to: An instance of :class:`str` that can be one of: + + - ``"xwindow"`` to visualize the graph as an + `X window <https://en.wikipedia.org/wiki/X_Window_System>`_. + - ``"browser"`` to visualize the graph as an SVG file in the + system's default web-browser. + - ``"svg"`` to store the dot code as an SVG file on the file system. + Returns the path to the generated SVG file. + + Defaults to ``"xwindow"`` if X11 support is present, otherwise defaults + to ``"browser"``. + + :returns: Depends on *output_to*. If ``"svg"``, returns the path to the + generated SVG file, otherwise returns ``None``. + """ + + from tempfile import mkdtemp + import subprocess + temp_dir = mkdtemp(prefix="tmp_pytools_dot") + + dot_file_name = "code.dot" + + from os.path import join + with open(join(temp_dir, dot_file_name), "w") as dotf: + dotf.write(dot_code) + + # {{{ preprocess 'output_to' + + if output_to is None: + with subprocess.Popen(["dot", "-T?"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) as proc: + assert proc.stderr, ("Could not execute the 'dot' program. " + "Please install the 'graphviz' package and " + "make sure it is in your $PATH.") + supported_formats = proc.stderr.read().decode() + + if " x11 " in supported_formats: + output_to = "xwindow" + else: + output_to = "browser" + + # }}} + + if output_to == "xwindow": + subprocess.check_call(["dot", "-Tx11", dot_file_name], cwd=temp_dir) + elif output_to in ["browser", "svg"]: + svg_file_name = "code.svg" + subprocess.check_call(["dot", "-Tsvg", "-o", svg_file_name, dot_file_name], + cwd=temp_dir) + + full_svg_file_name = join(temp_dir, svg_file_name) + logger.info("show_dot: svg written to '%s'", full_svg_file_name) + + if output_to == "svg": + return full_svg_file_name + else: + assert output_to == "browser" + + from webbrowser import open as browser_open + browser_open("file://" + full_svg_file_name) + else: + raise ValueError("`output_to` can be one of 'xwindow', 'browser', or 'svg'," + f" got '{output_to}'") + + return None +# }}} + + +# vim: foldmethod=marker diff --git a/test/test_graph_tools.py b/test/test_graph_tools.py index 2f14f1a..f9814ea 100644 --- a/test/test_graph_tools.py +++ b/test/test_graph_tools.py @@ -294,6 +294,39 @@ def test_prioritized_topological_sort(): assert len(dep_graph) == 0 +def test_as_graphviz_dot(): + graph = {"A": ["B", "C"], + "B": [], + "C": ["A"]} + + from pytools.graph import as_graphviz_dot, NodeT + + def edge_labels(n1: NodeT, n2: NodeT) -> str: + if n1 == "A" and n2 == "B": + return "foo" + + return "" + + def node_labels(node: NodeT) -> str: + if node == "A": + return "foonode" + + return str(node) + + res = as_graphviz_dot(graph, node_labels=node_labels, edge_labels=edge_labels) + + assert res == \ +"""digraph mygraph { +mynodeid [label="foonode"]; +mynodeid_0 [label="B"]; +mynodeid_1 [label="C"]; +mynodeid -> mynodeid_0 [label="foo"]; +mynodeid -> mynodeid_1 [label=""]; +mynodeid_1 -> mynodeid [label=""]; +} +""" + + def test_reverse_graph(): graph = { "a": frozenset(("b", "c")), -- GitLab