From 35f14d73eb5d49df30a46b807a804a3ed98ef424 Mon Sep 17 00:00:00 2001 From: chebee7i Date: Wed, 11 Sep 2013 18:52:13 -0500 Subject: [PATCH] Initial implementation. --- .gitignore | 1 + README.md | 51 +- images/demo.png | Bin 0 -> 73742 bytes nxpd/__init__.py | 8 + nxpd/nx_pydot.py | 472 +++++++++ nxpd/params.py | 133 +++ nxpd/pydot/__init__.py | 1969 ++++++++++++++++++++++++++++++++++++++ nxpd/pydot/_dotparser.py | 520 ++++++++++ nxpd/utils.py | 69 ++ setup.py | 58 ++ 10 files changed, 3280 insertions(+), 1 deletion(-) create mode 100644 images/demo.png create mode 100644 nxpd/__init__.py create mode 100644 nxpd/nx_pydot.py create mode 100644 nxpd/params.py create mode 100644 nxpd/pydot/__init__.py create mode 100644 nxpd/pydot/_dotparser.py create mode 100644 nxpd/utils.py create mode 100644 setup.py diff --git a/.gitignore b/.gitignore index d2d6f36..d51960e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.py[cod] +__pycache__ # C extensions *.so diff --git a/README.md b/README.md index 64d3e9b..59ebb97 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,53 @@ nxpd ==== -Pydot drawing of NetworkX graphs with support for IPython notebooks. +`nxpd` is a Python package for visualizing NetworkX graphs using `pydot` +and `graphviz`. Support is also provided for inline displays within IPython +notebooks. + +Installation +============ +Clone this repository: + + git clone https://github.com/chebee7i/nxpd + +Move into the directory and install the package. + + python setup.py install + +Usage +===== + +>>> import networkx as nx +>>> from nxpd import draw +>>> G = nx.cycle(4, create_using=nx.DiGraph()) +>>> draw(G) + +This will display a PNG (by default) using your operating system's default +PNG viewer. Alternatively, if you are in an IPython notebook, then you +might like the image displayed inline. This is achieved by setting the `show` +parameter of the `draw` function. + + >>> draw(G, show='ipynb') + +If you want all graphs to be drawn inline, then you can set a global parameter. + + >>> from nxpd import nxpdParams + >>> nxpdParams['show'] = 'ipynb' + >>> draw(G) + +Any graph/node/edge attribute that is supported by DOT is passed through to +graphviz (via pydot). All others are skipped. + + >>> G = nx.DiGraph() + >>> G.graph['rankdir'] = 'LR' + >>> G.graph['dpi'] = 120 + >>> G.add_cycle(range(4)) + >>> G.add_node(0, color='red', style='filled', fillcolor='pink') + >>> G.add_node(1, shape='square') + >>> G.add_node(3, style='filled', fillcolor='#00ffff') + >>> G.add_edge(0, 1, color='red', style='dashed') + >>> G.add_edge(3, 3, label='a') + >>> draw(G) + +![IPython Notebook Example](images/demo.png) diff --git a/images/demo.png b/images/demo.png new file mode 100644 index 0000000000000000000000000000000000000000..5a980859fcee0f0d0dea9beafca62bd58c60b861 GIT binary patch literal 73742 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 F=@A8@g1md-zZ`ci>U8~ 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 +# Dan Schult +# Pieter Swart +# All rights reserved. +# BSD license. + +from __future__ import absolute_import + +import os +import sys +import tempfile +import time + +from . import pydot +from .params import nxpdParams +from .utils import get_fobj, default_opener, make_str + +import networkx as nx + +__author__ = "\n".join(["Aric Hagberg (aric.hagberg@gmail.com)", + "chebee7i (chebee7i@gmail.com)"]) + +__all__ = ['write_dot', 'read_dot', 'graphviz_layout', 'pydot_layout', + 'to_pydot', 'from_pydot', 'draw_pydot'] + +def write_dot(G, path): + """Write NetworkX graph G to Graphviz dot format on path. + + Path can be a string or a file handle. + + """ + P = to_pydot(G) + + fobj, close = get_fobj(path, mode) + try: + path.write(P.to_string()) + finally: + if close: + fobj.close() + +def read_dot(path): + """Return a NetworkX MultiGraph or MultiDiGraph from a dot file on path. + + Parameters + ---------- + path : filename or file handle + + Returns + ------- + G : NetworkX multigraph + A MultiGraph or MultiDiGraph. + + Notes + ----- + Use G=nx.Graph(nx.read_dot(path)) to return a Graph instead of a MultiGraph. + + """ + fobj, close = get_fobj(path, mode) + + try: + data = path.read() + finally: + if close: + fobj.close() + + P = pydot.graph_from_dot_data(data) + G = from_pydot(P) + + return G + +def from_pydot(P): + """Return a NetworkX graph from a Pydot graph. + + Parameters + ---------- + P : Pydot graph + A graph created with Pydot + + Returns + ------- + G : NetworkX multigraph + A MultiGraph or MultiDiGraph. + + Examples + -------- + >>> K5 = nx.complete_graph(5) + >>> A = nx.to_pydot(K5) + >>> G = nx.from_pydot(A) # return MultiGraph + >>> G = nx.Graph(nx.from_pydot(A)) # make a Graph instead of MultiGraph + + """ + if P.get_strict(None): # pydot bug: get_strict() shouldn't take argument + multiedges = False + else: + multiedges = True + + if P.get_type() == 'graph': # undirected + if multiedges: + create_using = nx.MultiGraph() + else: + create_using = nx.Graph() + else: + if multiedges: + create_using = nx.MultiDiGraph() + else: + create_using = nx.DiGraph() + + # assign defaults + N = nx.empty_graph(0, create_using) + N.name = P.get_name() + + # add nodes, attributes to N.node_attr + for p in P.get_node_list(): + n = p.get_name().strip('"') + if n in ('node', 'graph', 'edge'): + continue + N.add_node(n, **p.get_attributes()) + + # add edges + for e in P.get_edge_list(): + u = e.get_source() + v = e.get_destination() + attr = e.get_attributes() + s = [] + d = [] + + if isinstance(u, basestring): + s.append(u.strip('"')) + else: + for unodes in u['nodes'].iterkeys(): + s.append(unodes.strip('"')) + + if isinstance(v, basestring): + d.append(v.strip('"')) + else: + for vnodes in v['nodes'].iterkeys(): + d.append(vnodes.strip('"')) + + for source_node in s: + for destination_node in d: + N.add_edge(source_node, destination_node, **attr) + + # add default attributes for graph, nodes, edges + N.graph['graph'] = P.get_attributes() + try: + N.graph['node'] = P.get_node_defaults()[0] + except:# IndexError,TypeError: + N.graph['node'] = {} + try: + N.graph['edge'] = P.get_edge_defaults()[0] + except:# IndexError,TypeError: + N.graph['edge'] = {} + return N + +def filter_attrs(attrs, attr_type): + """ + Helper function to keep only pydot supported attributes. + + All unsupported attributes are filtered out. + + Parameters + ---------- + attrs : dict + A dictionary of attributes. + attr_type : str + The type of attributes. Must be 'edge', 'graph', or 'node'. + + Returns + ------- + d : dict + The filtered attributes. + + """ + if attr_type == 'edge': + accepted = pydot.EDGE_ATTRIBUTES + elif attr_type == 'graph': + accepted = pydot.GRAPH_ATTRIBUTES + elif attr_type == 'node': + accepted = pydot.NODE_ATTRIBUTES + else: + raise Exception("Invalid attr_type.") + + d = dict( [(k,v) for (k,v) in attrs.items() if k in accepted] ) + return d + +def to_pydot(G, raise_exceptions=True): + """Return a pydot graph from a NetworkX graph G. + + All node names are converted to strings. However, no preprocessing is + performed on the edge/graph/node attribute values since some attributes + need to be strings while other need to be floats. If pydot does not handle + needed conversions, then your graph should be modified beforehand. + + Generally, the rule is: If the attribute is a supported Graphviz + attribute, then it will be added to the Pydot graph (and thus, assumed to + be in the proper format for Graphviz). + + Parameters + ---------- + G : NetworkX graph + A graph created with NetworkX. + raise_exceptions : bool + If `True`, raise any exceptions. Otherwise, the exception is ignored + and the procedure continues. + + Examples + -------- + >>> G = nx.complete_graph(5) + >>> G.add_edge(2, 10, color='red') + >>> P = nx.to_pydot(G) + + """ + # Set Graphviz graph type. + if G.is_directed(): + graph_type = 'digraph' + else: + graph_type = 'graph' + + strict = G.number_of_selfloops() == 0 and not G.is_multigraph() + + # Create the Pydot graph. + name = G.graph.get('name') + graph_defaults = filter_attrs(G.graph, 'graph') + if name is None: + P = pydot.Dot(graph_type=graph_type, strict=strict, **graph_defaults) + else: + P = pydot.Dot(name, graph_type=graph_type, strict=strict, + **graph_defaults) + + # Set default node attributes, if possible. + node_defaults = filter_attrs(G.graph.get('node', {}), 'node') + if node_defaults: + try: + P.set_node_defaults(**node_defaults) + except: + if raise_exceptions: + raise + + # Set default edge attributes, if possible. + edge_defaults = filter_attrs(G.graph.get('edge', {}), 'edge') + if edge_defaults: + # This adds a node called "edge" to the graph. + try: + P.set_edge_defaults(**edge_defaults) + except: + if raise_exceptions: + raise + + # Add the nodes. + for n,nodedata in G.nodes_iter(data=True): + attrs = filter_attrs(nodedata, 'node') + node = pydot.Node(make_str(n), **attrs) + P.add_node(node) + + # Add the edges. + if G.is_multigraph(): + for u,v,key,edgedata in G.edges_iter(data=True,keys=True): + attrs = filter_attrs(edgedata, 'edge') + uu, vv, kk = make_str(u), make_str(v), make_str(key) + edge = pydot.Edge(uu, vv, key=kk, **attrs) + P.add_edge(edge) + else: + for u,v,edgedata in G.edges_iter(data=True): + attrs = filter_attrs(edgedata, 'edge') + uu, vv = make_str(u), make_str(v) + edge = pydot.Edge(uu, vv, **attrs) + P.add_edge(edge) + return P + +def graphviz_layout(G, prog='neato', root=None, **kwds): + """Create node positions using Pydot and Graphviz. + + Returns a dictionary of positions keyed by node. + + Examples + -------- + >>> G=nx.complete_graph(4) + >>> pos=nx.graphviz_layout(G) + >>> pos=nx.graphviz_layout(G,prog='dot') + + Notes + ----- + This is a wrapper for pydot_layout. + """ + return pydot_layout(G=G, prog=prog, root=root, **kwds) + + +def pydot_layout(G, prog='neato', root=None, **kwds): + """Create node positions using Pydot and Graphviz. + + Returns a dictionary of positions keyed by node. + + Examples + -------- + >>> G = nx.complete_graph(4) + >>> pos = nx.pydot_layout(G) + >>> pos = nx.pydot_layout(G, prog='dot') + + """ + P = to_pydot(G) + if root is not None : + P.set("root",make_str(root)) + + D = P.create_dot(prog=prog) + + if D == "": # no data returned + raise Exception("Graphviz layout with {0} failed.".format(prog)) + + Q = pydot.graph_from_dot_data(D) + + node_pos = {} + for n in G.nodes(): + pydot_node = pydot.Node(make_str(n)).get_name().encode('utf-8') + node = Q.get_node(pydot_node) + + if isinstance(node,list): + node = node[0] + pos = node.get_pos()[1:-1] # strip leading and trailing double quotes + if pos != None: + xx,yy = pos.split(",") + node_pos[n] = (float(xx),float(yy)) + return node_pos + +def draw_pydot(G, filename=None, format=None, prefix=None, suffix=None, + layout='dot', args=None, show=None): + """Draws the graph G using pydot and graphviz. + + Parameters + ---------- + G : graph + A NetworkX graph object (e.g., Graph, DiGraph). + + filename : str, None, file object + The name of the file to save the image to. If None, save to a + temporary file with the name: + nx_PREFIX_RANDOMSTRING_SUFFIX.ext. + File formats are inferred from the extension of the filename, when + provided. If the `format` parameter is not `None`, it overwrites any + inferred value for the extension. + + format : str + An output format. Note that not all may be available on every system + depending on how Graphviz was built. If no filename is provided and + no format is specified, then a 'png' image is created. Other values + for `format` are: + + 'canon', 'cmap', 'cmapx', 'cmapx_np', 'dia', 'dot', + 'fig', 'gd', 'gd2', 'gif', 'hpgl', 'imap', 'imap_np', + 'ismap', 'jpe', 'jpeg', 'jpg', 'mif', 'mp', 'pcl', 'pdf', + 'pic', 'plain', 'plain-ext', 'png', 'ps', 'ps2', 'svg', + 'svgz', 'vml', 'vmlz', 'vrml', 'vtx', 'wbmp', 'xdot', 'xlib' + + prefix : str | None + If `filename` is None, we save to a temporary file. The value of + `prefix` will appear after 'nx_' but before random string + and file extension. If None, then the graph name will be used. + + suffix : str | None + If `filename` is None, we save to a temporary file. The value of + `suffix` will appear at after the prefix and random string but before + the file extension. If None, then no suffix is used. + + layout : str + The graphviz layout program. Pydot is responsible for locating the + binary. Common values for the layout program are: + 'neato','dot','twopi','circo','fdp','nop', 'wc','acyclic','gvpr', + 'gvcolor','ccomps','sccmap','tred' + + args : list + Additional arguments to pass to the Graphviz layout program. + This should be a list of strings. For example, ['-s10', '-maxiter=10']. + + show : bool + If `True`, then the image is displayed using the OS's default viewer + after drawing it. If show equals 'ipynb', then the image is displayed + inline for an IPython notebook. If `None`, then the value of + nxpdParams['show'] is used. By default, it is set to `True`. + + """ + # Determine the output format + if format is None: + # grab extension from filename + if filename is None: + # default to png + ext = 'png' + else: + ext = os.path.splitext(filename)[-1].lower()[1:] + else: + ext = format + + # Determine the "path" to be passed to pydot.Dot.write() + if filename is None: + if prefix is None: + prefix = G.graph.get("name", '') + + if prefix: + fn_prefix = "nx_{0}_".format(prefix) + else: + fn_prefix = "nx_" + + if suffix: + fn_suffix = '_{0}.{1}'.format(suffix, ext) + else: + fn_suffix = '.{0}'.format(ext) + + fobj = tempfile.NamedTemporaryFile(prefix=fn_prefix, + suffix=fn_suffix, + delete=False) + fname = fobj.name + close = True + else: + fobj, close = get_fobj(filename, 'w+b') + fname = fobj.name + + # Include additional command line arguments to the layout program. + if args is None: + args = [] + prog = layout + else: + args = list(args) + prog = [layout] + args + + # Draw the image. + G2 = to_pydot(G) + G2.write(fobj, prog=prog, format=ext) + if close: + fobj.close() + + if show is None: + show = nxpdParams['show'] + + if show: + if show == 'ipynb': + from IPython.core.display import Image + return Image(filename=fname, embed=True) + else: + default_opener(fname) + if sys.platform == 'linux2': + # necessary when opening many images in a row + time.sleep(.5) + + return fname diff --git a/nxpd/params.py b/nxpd/params.py new file mode 100644 index 0000000..cc3d086 --- /dev/null +++ b/nxpd/params.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +""" +Defines global configuration parameters. + +""" + +import warnings + +__all__ = ['nxpdParams', 'reset_params'] + +### Generic validations + +def validate_boolean(b): + """Convert b to a boolean or raise a ValueError.""" + try: + b = b.lower() + except AttributeError: + pass + if b in ('t', 'y', 'yes', 'on', 'true', '1', 1, True): return True + elif b in ('f', 'n', 'no', 'off', 'false', '0', 0, False): return False + else: + raise ValueError('Could not convert {0!r} to boolean'.format(b)) + +def validate_float(s): + """Convert s to float or raise a ValueError.""" + try: + return float(s) + except ValueError: + raise ValueError('Could not convert {0!r} to float'.format(s)) + +def validate_choice(s, choices): + try: + s = s.lower() + except AttributeError: + pass + if s not in choices: + raise ValueError("{0!r} is an invalid specification.".format(s)) + else: + return s + +### Specific validations + +def validate_show(s): + choices = ['ipynb', 'external', 'none'] + return validate_choice(s, choices) + + +### The main parameter class + +class Params(dict): + """ + A dictionary including validation, representing configuration parameters. + + """ + + def __init__(self): + """ + Initialize the Params instance. + + """ + defaults = [(key, tup[0]) for key, tup in defaultParams.items()] + converters = [(key, tup[1]) for key, tup in defaultParams.items()] + + # A dictionary relating params to validators. + self.validate = dict(converters) + dict.__init__(self, defaults) + + def _deprecation_check(self, param): + """ + Raise warning if param is deprecated. + + Return the param to use. This is the alternative parameter if available, + otherwise it is the original parameter. + + """ + if param in deprecatedParams: + alt = deprecatedParams[param] + if alt is None: + msg = "{0!r} is deprecated. There is no replacement." + msg = msg.format(key) + else: + msg = "{0!r} is deprecated. Use {1!r} instead." + msg = msg.format(key, alt) + param = alt + + warnings.warn(msg, DeprecationWarning, stacklevel=2) + + if param not in self.validate: + msg = '{0!r} is not a valid parameter. '.format(key) + msg += 'See nxParams.keys() for a list of valid parameters.' + raise KeyError(msg) + + return param + + def __setitem__(self, key, val): + key = self._deprecation_check(key) + cval = self.validate[key](val) + dict.__setitem__(self, key, cval) + + def __getitem__(self, key): + key = self._deprecation_check(key) + return dict.__getitem__(self, key) + +def reset_params(): + """ + Restore parameters to default values. + + """ + # This modifies the global parameters. + nxpdParams.update(nxpdParamsDefault) + + +### +### Globals +### + + +### TODO: key -> (value, validator, info_string) +defaultParams = { + # parameter : (default value, validator) + 'show': ('external', validate_show), +} + +### Dictionary relating deprecated parameter names to new names. +deprecatedParams = { + # old parameter : new parameter, (use None if no new parameter) +} + +### This is what will be used by nxpd. +nxpdParamsDefault = Params() +nxpdParams = Params() + + diff --git a/nxpd/pydot/__init__.py b/nxpd/pydot/__init__.py new file mode 100644 index 0000000..142b039 --- /dev/null +++ b/nxpd/pydot/__init__.py @@ -0,0 +1,1969 @@ +# -*- coding: Latin-1 -*- +"""Graphviz's dot language Python interface. + +This module provides with a full interface to create handle modify +and process graphs in Graphviz's dot language. + +References: + +pydot Homepage: http://code.google.com/p/pydot/ +Graphviz: http://www.graphviz.org/ +DOT Language: http://www.graphviz.org/doc/info/lang.html + +Programmed and tested with Graphviz 2.26.3 and Python 2.6 on OSX 10.6.4 + +Copyright (c) 2005-2011 Ero Carrera + +Distributed under MIT license [http://opensource.org/licenses/mit-license.html]. +""" + +from __future__ import division, print_function + +__author__ = 'Ero Carrera' +__version__ = '1.0.29' +__license__ = 'MIT' + +import os +import re +import subprocess +import sys +import tempfile +import copy + +from operator import itemgetter + +try: + from . import _dotparser as dot_parser +except Exception: + print("Couldn't import _dotparser, loading of dot files will not be possible.") + + +PY3 = not sys.version_info < (3, 0, 0) + +if PY3: + NULL_SEP = b'' + basestring = str + long = int + unicode = str +else: + NULL_SEP = '' + + +GRAPH_ATTRIBUTES = set([ + 'Damping', 'K', 'URL', 'aspect', 'bb', 'bgcolor', + 'center', 'charset', 'clusterrank', 'colorscheme', 'comment', 'compound', + 'concentrate', 'defaultdist', 'dim', 'dimen', 'diredgeconstraints', + 'dpi', 'epsilon', 'esep', 'fontcolor', 'fontname', 'fontnames', + 'fontpath', 'fontsize', 'id', 'label', 'labeljust', 'labelloc', + 'landscape', 'layers', 'layersep', 'layout', 'levels', 'levelsgap', + 'lheight', 'lp', 'lwidth', 'margin', 'maxiter', 'mclimit', 'mindist', + 'mode', 'model', 'mosek', 'nodesep', 'nojustify', 'normalize', 'nslimit', + 'nslimit1', 'ordering', 'orientation', 'outputorder', 'overlap', + 'overlap_scaling', 'pack', 'packmode', 'pad', 'page', 'pagedir', + 'quadtree', 'quantum', 'rankdir', 'ranksep', 'ratio', 'remincross', + 'repulsiveforce', 'resolution', 'root', 'rotate', 'searchsize', 'sep', + 'showboxes', 'size', 'smoothing', 'sortv', 'splines', 'start', + 'stylesheet', 'target', 'truecolor', 'viewport', 'voro_margin', + # for subgraphs + 'rank' + ]) + + +EDGE_ATTRIBUTES = set([ + 'URL', 'arrowhead', 'arrowsize', 'arrowtail', + 'color', 'colorscheme', 'comment', 'constraint', 'decorate', 'dir', + 'edgeURL', 'edgehref', 'edgetarget', 'edgetooltip', 'fontcolor', + 'fontname', 'fontsize', 'headURL', 'headclip', 'headhref', 'headlabel', + 'headport', 'headtarget', 'headtooltip', 'href', 'id', 'label', + 'labelURL', 'labelangle', 'labeldistance', 'labelfloat', 'labelfontcolor', + 'labelfontname', 'labelfontsize', 'labelhref', 'labeltarget', + 'labeltooltip', 'layer', 'len', 'lhead', 'lp', 'ltail', 'minlen', + 'nojustify', 'penwidth', 'pos', 'samehead', 'sametail', 'showboxes', + 'style', 'tailURL', 'tailclip', 'tailhref', 'taillabel', 'tailport', + 'tailtarget', 'tailtooltip', 'target', 'tooltip', 'weight', + 'rank' + ]) + + +NODE_ATTRIBUTES = set([ + 'URL', 'color', 'colorscheme', 'comment', + 'distortion', 'fillcolor', 'fixedsize', 'fontcolor', 'fontname', + 'fontsize', 'group', 'height', 'id', 'image', 'imagescale', 'label', + 'labelloc', 'layer', 'margin', 'nojustify', 'orientation', 'penwidth', + 'peripheries', 'pin', 'pos', 'rects', 'regular', 'root', 'samplepoints', + 'shape', 'shapefile', 'showboxes', 'sides', 'skew', 'sortv', 'style', + 'target', 'tooltip', 'vertices', 'width', 'z', + # The following are attributes dot2tex + 'texlbl', 'texmode' + ]) + + +CLUSTER_ATTRIBUTES = set([ + 'K', 'URL', 'bgcolor', 'color', 'colorscheme', + 'fillcolor', 'fontcolor', 'fontname', 'fontsize', 'label', 'labeljust', + 'labelloc', 'lheight', 'lp', 'lwidth', 'nojustify', 'pencolor', + 'penwidth', 'peripheries', 'sortv', 'style', 'target', 'tooltip' + ]) + + +def is_string_like(obj): # from John Hunter, types-free version + """Check if obj is string.""" + try: + obj + '' + except (TypeError, ValueError): + return False + return True + +def get_fobj(fname, mode='w+'): + """Obtain a proper file object. + + Parameters + ---------- + fname : string, file object, file descriptor + If a string or file descriptor, then we create a file object. If *fname* + is a file object, then we do nothing and ignore the specified *mode* + parameter. + mode : str + The mode of the file to be opened. + + Returns + ------- + fobj : file object + The file object. + close : bool + If *fname* was a string, then *close* will be *True* to signify that + the file object should be closed after writing to it. Otherwise, *close* + will be *False* signifying that the user, in essence, created the file + object already and that subsequent operations should not close it. + + """ + if is_string_like(fname): + fobj = open(fname, mode) + close = True + elif hasattr(fname, 'write'): + # fname is a file-like object, perhaps a StringIO (for example) + fobj = fname + close = False + else: + # assume it is a file descriptor + fobj = os.fdopen(fname, mode) + close = False + return fobj, close + + +# +# Extented version of ASPN's Python Cookbook Recipe: +# Frozen dictionaries. +# http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/414283 +# +# This version freezes dictionaries used as values within dictionaries. +# +class frozendict(dict): + def _blocked_attribute(obj): + raise AttributeError("A frozendict cannot be modified.") + _blocked_attribute = property(_blocked_attribute) + + __delitem__ = __setitem__ = clear = _blocked_attribute + pop = popitem = setdefault = update = _blocked_attribute + + def __new__(cls, *args, **kw): + new = dict.__new__(cls) + + args_ = [] + for arg in args: + if isinstance(arg, dict): + arg = copy.copy(arg) + for k, v in arg.items(): + if isinstance(v, frozendict): + arg[k] = v + elif isinstance(v, dict): + arg[k] = frozendict(v) + elif isinstance(v, list): + v_ = list() + for elm in v: + if isinstance(elm, dict): + v_.append(frozendict(elm)) + else: + v_.append(elm) + arg[k] = tuple(v_) + args_.append(arg) + else: + args_.append(arg) + + dict.__init__(new, *args_, **kw) + return new + + def __init__(self, *args, **kw): + pass + + def __hash__(self): + try: + return self._cached_hash + except AttributeError: + h = self._cached_hash = hash(tuple(sorted(self.items()))) + return h + + def __repr__(self): + return "frozendict(%s)" % dict.__repr__(self) + + +dot_keywords = ['graph', 'subgraph', 'digraph', 'node', 'edge', 'strict'] + +id_re_alpha_nums = re.compile('^[_a-zA-Z][a-zA-Z0-9_,]*$', re.UNICODE) +id_re_alpha_nums_with_ports = re.compile( + '^[_a-zA-Z][a-zA-Z0-9_,:\"]*[a-zA-Z0-9_,\"]+$', re.UNICODE + ) +id_re_num = re.compile('^[0-9,]+$', re.UNICODE) +id_re_with_port = re.compile('^([^:]*):([^:]*)$', re.UNICODE) +id_re_dbl_quoted = re.compile('^\".*\"$', re.S | re.UNICODE) +id_re_html = re.compile('^<.*>$', re.S | re.UNICODE) + + +def needs_quotes(s): + """Checks whether a string is a dot language ID. + + It will check whether the string is solely composed + by the characters allowed in an ID or not. + If the string is one of the reserved keywords it will + need quotes too but the user will need to add them + manually. + """ + + # If the name is a reserved keyword it will need quotes but pydot + # can't tell when it's being used as a keyword or when it's simply + # a name. Hence the user needs to supply the quotes when an element + # would use a reserved keyword as name. This function will return + # false indicating that a keyword string, if provided as-is, won't + # need quotes. + if s in dot_keywords: + return False + + chars = [ord(c) for c in s if ord(c) > 0x7f or ord(c) == 0] + if chars and not id_re_dbl_quoted.match(s) and not id_re_html.match(s): + return True + + for test_re in [ + id_re_alpha_nums, id_re_num, id_re_dbl_quoted, + id_re_html, id_re_alpha_nums_with_ports + ]: + if test_re.match(s): + return False + + m = id_re_with_port.match(s) + if m: + return needs_quotes(m.group(1)) or needs_quotes(m.group(2)) + + return True + + +def quote_if_necessary(s): + + if isinstance(s, bool): + if s is True: + return 'True' + return 'False' + + if not isinstance(s, basestring): + return s + + if not s: + return s + + if needs_quotes(s): + replace = {'"': r'\"', "\n": r'\n', "\r": r'\r'} + for (a, b) in replace.items(): + s = s.replace(a, b) + + return '"' + s + '"' + + return s + + +def graph_from_dot_data(data): + """Load graph as defined by data in DOT format. + + The data is assumed to be in DOT format. It will + be parsed and a Dot class will be returned, + representing the graph. + """ + + return dot_parser.parse_dot_data(data) + + +def graph_from_dot_file(path): + """Load graph as defined by a DOT file. + + The file is assumed to be in DOT format. It will + be loaded, parsed and a Dot class will be returned, + representing the graph. + """ + + fd = open(path, 'rb') + data = fd.read() + fd.close() + + return graph_from_dot_data(data) + + +def graph_from_edges(edge_list, node_prefix='', directed=False): + """Creates a basic graph out of an edge list. + + The edge list has to be a list of tuples representing + the nodes connected by the edge. + The values can be anything: bool, int, float, str. + + If the graph is undirected by default, it is only + calculated from one of the symmetric halves of the matrix. + """ + + if directed: + graph = Dot(graph_type='digraph') + + else: + graph = Dot(graph_type='graph') + + for edge in edge_list: + + if isinstance(edge[0], str): + src = node_prefix + edge[0] + else: + src = node_prefix + str(edge[0]) + + if isinstance(edge[1], str): + dst = node_prefix + edge[1] + else: + dst = node_prefix + str(edge[1]) + + e = Edge(src, dst) + graph.add_edge(e) + + return graph + + +def graph_from_adjacency_matrix(matrix, node_prefix='', directed=False): + """Creates a basic graph out of an adjacency matrix. + + The matrix has to be a list of rows of values + representing an adjacency matrix. + The values can be anything: bool, int, float, as long + as they can evaluate to True or False. + """ + + node_orig = 1 + + if directed: + graph = Dot(graph_type='digraph') + else: + graph = Dot(graph_type='graph') + + for row in matrix: + if not directed: + skip = matrix.index(row) + r = row[skip:] + else: + skip = 0 + r = row + node_dest = skip + 1 + + for e in r: + if e: + graph.add_edge( + Edge( + node_prefix + node_orig, + node_prefix + node_dest + ) + ) + node_dest += 1 + node_orig += 1 + + return graph + + +def graph_from_incidence_matrix(matrix, node_prefix='', directed=False): + """Creates a basic graph out of an incidence matrix. + + The matrix has to be a list of rows of values + representing an incidence matrix. + The values can be anything: bool, int, float, as long + as they can evaluate to True or False. + """ + + if directed: + graph = Dot(graph_type='digraph') + else: + graph = Dot(graph_type='graph') + + for row in matrix: + nodes = [] + c = 1 + + for node in row: + if node: + nodes.append(c * node) + c += 1 + + nodes.sort() + + if len(nodes) == 2: + graph.add_edge( + Edge( + node_prefix + abs(nodes[0]), + node_prefix + nodes[1] + ) + ) + + if not directed: + graph.set_simplify(True) + + return graph + + +def __find_executables(path): + """Used by find_graphviz + + path - single directory as a string + + If any of the executables are found, it will return a dictionary + containing the program names as keys and their paths as values. + + Otherwise returns None + """ + + success = False + progs = {'dot': '', 'twopi': '', 'neato': '', 'circo': '', 'fdp': '', 'sfdp': ''} + + was_quoted = False + path = path.strip() + if path.startswith('"') and path.endswith('"'): + path = path[1:-1] + was_quoted = True + + if os.path.isdir(path): + for prg in progs.keys(): + if progs[prg]: + continue + + if os.path.exists(os.path.join(path, prg)): + if was_quoted: + progs[prg] = '"' + os.path.join(path, prg) + '"' + else: + progs[prg] = os.path.join(path, prg) + + success = True + + elif os.path.exists(os.path.join(path, prg + '.exe')): + if was_quoted: + progs[prg] = '"' + os.path.join(path, prg + '.exe') + '"' + else: + progs[prg] = os.path.join(path, prg + '.exe') + + success = True + + if success: + return progs + else: + return None + + +# The multi-platform version of this 'find_graphviz' function was +# contributed by Peter Cock +def find_graphviz(): + """Locate Graphviz's executables in the system. + + Tries three methods: + + First: Windows Registry (Windows only) + This requires Mark Hammond's pywin32 is installed. + + Secondly: Search the path + It will look for 'dot', 'twopi' and 'neato' in all the directories + specified in the PATH environment variable. + + Thirdly: Default install location (Windows only) + It will look for 'dot', 'twopi' and 'neato' in the default install + location under the "Program Files" directory. + + It will return a dictionary containing the program names as keys + and their paths as values. + + If this fails, it returns None. + """ + + # Method 1 (Windows only) + if os.sys.platform == 'win32': + + HKEY_LOCAL_MACHINE = 0x80000002 + KEY_QUERY_VALUE = 0x0001 + + RegOpenKeyEx = None + RegQueryValueEx = None + RegCloseKey = None + + try: + import win32api + RegOpenKeyEx = win32api.RegOpenKeyEx + RegQueryValueEx = win32api.RegQueryValueEx + RegCloseKey = win32api.RegCloseKey + + except ImportError: + # Print a messaged suggesting they install these? + pass + + try: + import ctypes + + def RegOpenKeyEx(key, subkey, opt, sam): + result = ctypes.c_uint(0) + ctypes.windll.advapi32.RegOpenKeyExA(key, subkey, opt, sam, ctypes.byref(result)) + return result.value + + def RegQueryValueEx(hkey, valuename): + data_type = ctypes.c_uint(0) + data_len = ctypes.c_uint(1024) + data = ctypes.create_string_buffer(1024) + + # this has a return value, which we should probably check + ctypes.windll.advapi32.RegQueryValueExA( + hkey, valuename, 0, ctypes.byref(data_type), + data, ctypes.byref(data_len) + ) + + return data.value + + RegCloseKey = ctypes.windll.advapi32.RegCloseKey + + except ImportError: + # Print a messaged suggesting they install these? + pass + + if RegOpenKeyEx is not None: + # Get the GraphViz install path from the registry + hkey = None + potentialKeys = [ + "SOFTWARE\\ATT\\Graphviz", + "SOFTWARE\\AT&T Research Labs\\Graphviz" + ] + for potentialKey in potentialKeys: + + try: + hkey = RegOpenKeyEx( + HKEY_LOCAL_MACHINE, + potentialKey, 0, KEY_QUERY_VALUE + ) + + if hkey is not None: + path = RegQueryValueEx(hkey, "InstallPath") + RegCloseKey(hkey) + + # The regitry variable might exist, left by old installations + # but with no value, in those cases we keep searching... + if not path: + continue + + # Now append the "bin" subdirectory: + path = os.path.join(path, "bin") + progs = __find_executables(path) + if progs is not None: + #print("Used Windows registry") + return progs + + except Exception: + #raise + pass + else: + break + + # Method 2 (Linux, Windows etc) + if 'PATH' in os.environ: + for path in os.environ['PATH'].split(os.pathsep): + progs = __find_executables(path) + if progs is not None: + #print("Used path") + return progs + + # Method 3 (Windows only) + if os.sys.platform == 'win32': + + # Try and work out the equivalent of "C:\Program Files" on this + # machine (might be on drive D:, or in a different language) + if 'PROGRAMFILES' in os.environ: + # Note, we could also use the win32api to get this + # information, but win32api may not be installed. + path = os.path.join(os.environ['PROGRAMFILES'], 'ATT', 'GraphViz', 'bin') + else: + #Just in case, try the default... + path = r"C:\Program Files\att\Graphviz\bin" + + progs = __find_executables(path) + + if progs is not None: + + #print("Used default install location") + return progs + + for path in ( + '/usr/bin', '/usr/local/bin', + '/opt/local/bin', + '/opt/bin', '/sw/bin', '/usr/share', + '/Applications/Graphviz.app/Contents/MacOS/' + ): + + progs = __find_executables(path) + if progs is not None: + #print("Used path") + return progs + + # Failed to find GraphViz + return None + + +class Common(object): + """Common information to several classes. + + Should not be directly used, several classes are derived from + this one. + """ + + def __getstate__(self): + + dict = copy.copy(self.obj_dict) + + return dict + + def __setstate__(self, state): + + self.obj_dict = state + + def __get_attribute__(self, attr): + """Look for default attributes for this node""" + + attr_val = self.obj_dict['attributes'].get(attr, None) + + if attr_val is None: + # get the defaults for nodes/edges + + default_node_name = self.obj_dict['type'] + + # The defaults for graphs are set on a node named 'graph' + if default_node_name in ('subgraph', 'digraph', 'cluster'): + default_node_name = 'graph' + + g = self.get_parent_graph() + if g is not None: + defaults = g.get_node(default_node_name) + else: + return None + + # Multiple defaults could be set by having repeated 'graph [...]' + # 'node [...]', 'edge [...]' statements. In such case, if the + # same attribute is set in different statements, only the first + # will be returned. In order to get all, one would call the + # get_*_defaults() methods and handle those. Or go node by node + # (of the ones specifying defaults) and modify the attributes + # individually. + # + if not isinstance(defaults, (list, tuple)): + defaults = [defaults] + + for default in defaults: + attr_val = default.obj_dict['attributes'].get(attr, None) + if attr_val: + return attr_val + else: + return attr_val + + return None + + def set_parent_graph(self, parent_graph): + + self.obj_dict['parent_graph'] = parent_graph + + def get_parent_graph(self): + + return self.obj_dict.get('parent_graph', None) + + def set(self, name, value): + """Set an attribute value by name. + + Given an attribute 'name' it will set its value to 'value'. + There's always the possibility of using the methods: + + set_'name'(value) + + which are defined for all the existing attributes. + """ + + self.obj_dict['attributes'][name] = value + + def get(self, name): + """Get an attribute value by name. + + Given an attribute 'name' it will get its value. + There's always the possibility of using the methods: + + get_'name'() + + which are defined for all the existing attributes. + """ + + return self.obj_dict['attributes'].get(name, None) + + def get_attributes(self): + """""" + + return self.obj_dict['attributes'] + + def set_sequence(self, seq): + + self.obj_dict['sequence'] = seq + + def get_sequence(self): + + return self.obj_dict['sequence'] + + def create_attribute_methods(self, obj_attributes): + + #for attr in self.obj_dict['attributes']: + for attr in obj_attributes: + + # Generate all the Setter methods. + # + self.__setattr__( + 'set_' + attr, + lambda x, a=attr: self.obj_dict['attributes'].__setitem__(a, x) + ) + + # Generate all the Getter methods. + # + self.__setattr__('get_' + attr, lambda a=attr: self.__get_attribute__(a)) + + +class Error(Exception): + """General error handling class. + """ + def __init__(self, value): + self.value = value + + def __str__(self): + return self.value + + +class InvocationException(Exception): + """To indicate that a ploblem occurred while running any of the GraphViz executables. + """ + def __init__(self, value): + self.value = value + + def __str__(self): + return self.value + + +class Node(Common): + """A graph node. + + This class represents a graph's node with all its attributes. + + node(name, attribute=value, ...) + + name: node's name + + All the attributes defined in the Graphviz dot language should + be supported. + """ + + def __init__(self, name='', obj_dict=None, **attrs): + + # + # Nodes will take attributes of all other types because the defaults + # for any GraphViz object are dealt with as if they were Node definitions + # + + if obj_dict is not None: + self.obj_dict = obj_dict + else: + self.obj_dict = dict() + + # Copy the attributes + # + self.obj_dict['attributes'] = dict(attrs) + self.obj_dict['type'] = 'node' + self.obj_dict['parent_graph'] = None + self.obj_dict['parent_node_list'] = None + self.obj_dict['sequence'] = None + + # Remove the compass point + # + port = None + if isinstance(name, basestring) and not name.startswith('"'): + idx = name.find(':') + if idx > 0 and idx + 1 < len(name): + name, port = name[:idx], name[idx:] + + if isinstance(name, (long, int)): + name = str(name) + + self.obj_dict['name'] = quote_if_necessary(name) + self.obj_dict['port'] = port + + self.create_attribute_methods(NODE_ATTRIBUTES) + + def set_name(self, node_name): + """Set the node's name.""" + + self.obj_dict['name'] = node_name + + def get_name(self): + """Get the node's name.""" + + return self.obj_dict['name'] + + def get_port(self): + """Get the node's port.""" + + return self.obj_dict['port'] + + def add_style(self, style): + + styles = self.obj_dict['attributes'].get('style', None) + if not styles and style: + styles = [style] + else: + styles = styles.split(',') + styles.append(style) + + self.obj_dict['attributes']['style'] = ','.join(styles) + + def to_string(self): + """Returns a string representation of the node in dot language. + """ + + # RMF: special case defaults for node, edge and graph properties. + # + node = quote_if_necessary(self.obj_dict['name']) + + node_attr = list() + + for attr, value in sorted(self.obj_dict['attributes'].items(), key=itemgetter(0)): + if value is not None: + node_attr.append('%s=%s' % (attr, quote_if_necessary(value))) + else: + node_attr.append(attr) + + # No point in having nodes setting any defaults if the don't set + # any attributes... + # + if node in ('graph', 'node', 'edge') and len(node_attr) == 0: + return '' + + node_attr = ', '.join(node_attr) + + if node_attr: + node += ' [' + node_attr + ']' + + return node + ';' + + +class Edge(Common): + """A graph edge. + + This class represents a graph's edge with all its attributes. + + edge(src, dst, attribute=value, ...) + + src: source node's name + dst: destination node's name + + All the attributes defined in the Graphviz dot language should + be supported. + + Attributes can be set through the dynamically generated methods: + + set_[attribute name], i.e. set_label, set_fontname + + or directly by using the instance's special dictionary: + + Edge.obj_dict['attributes'][attribute name], i.e. + + edge_instance.obj_dict['attributes']['label'] + edge_instance.obj_dict['attributes']['fontname'] + + """ + + def __init__(self, src='', dst='', obj_dict=None, **attrs): + + if isinstance(src, (list, tuple)) and dst == '': + src, dst = src + + if obj_dict is not None: + + self.obj_dict = obj_dict + + else: + + self.obj_dict = dict() + + # Copy the attributes + # + self.obj_dict['attributes'] = dict(attrs) + self.obj_dict['type'] = 'edge' + self.obj_dict['parent_graph'] = None + self.obj_dict['parent_edge_list'] = None + self.obj_dict['sequence'] = None + + if isinstance(src, Node): + src = src.get_name() + + if isinstance(dst, Node): + dst = dst.get_name() + + points = (quote_if_necessary(src), quote_if_necessary(dst)) + + self.obj_dict['points'] = points + + self.create_attribute_methods(EDGE_ATTRIBUTES) + + def get_source(self): + """Get the edges source node name.""" + + return self.obj_dict['points'][0] + + def get_destination(self): + """Get the edge's destination node name.""" + + return self.obj_dict['points'][1] + + def __hash__(self): + return hash(hash(self.get_source()) + hash(self.get_destination())) + + def __eq__(self, edge): + """Compare two edges. + + If the parent graph is directed, arcs linking + node A to B are considered equal and A->B != B->A + + If the parent graph is undirected, any edge + connecting two nodes is equal to any other + edge connecting the same nodes, A->B == B->A + """ + + if not isinstance(edge, Edge): + raise Error("Can't compare and edge to a non-edge object.") + + if self.get_parent_graph().get_top_graph_type() == 'graph': + + # If the graph is undirected, the edge has neither + # source nor destination. + # + if ((self.get_source() == edge.get_source() and + self.get_destination() == edge.get_destination()) or + (edge.get_source() == self.get_destination() and + edge.get_destination() == self.get_source())): + return True + + else: + if (self.get_source() == edge.get_source() and + self.get_destination() == edge.get_destination()): + return True + + return False + + def parse_node_ref(self, node_str): + + if not isinstance(node_str, str): + return node_str + + if node_str.startswith('"') and node_str.endswith('"'): + return node_str + + node_port_idx = node_str.rfind(':') + + if (node_port_idx > 0 and node_str[0] == '"' and + node_str[node_port_idx - 1] == '"'): + return node_str + + if node_port_idx > 0: + a = node_str[:node_port_idx] + b = node_str[node_port_idx + 1:] + + node = quote_if_necessary(a) + + node += ':' + quote_if_necessary(b) + + return node + + return node_str + + def to_string(self): + """Returns a string representation of the edge in dot language. + """ + + src = self.parse_node_ref(self.get_source()) + dst = self.parse_node_ref(self.get_destination()) + + if isinstance(src, frozendict): + edge = [Subgraph(obj_dict=src).to_string()] + elif isinstance(src, (int, long)): + edge = [str(src)] + else: + edge = [src] + + if (self.get_parent_graph() and + self.get_parent_graph().get_top_graph_type() and + self.get_parent_graph().get_top_graph_type() == 'digraph'): + + edge.append('->') + + else: + edge.append('--') + + if isinstance(dst, frozendict): + edge.append(Subgraph(obj_dict=dst).to_string()) + elif isinstance(dst, (int, long)): + edge.append(str(dst)) + else: + edge.append(dst) + + edge_attr = list() + + for attr, value in sorted(self.obj_dict['attributes'].items(), key=itemgetter(0)): + if value is not None: + edge_attr.append('%s=%s' % (attr, quote_if_necessary(value))) + else: + edge_attr.append(attr) + + edge_attr = ', '.join(edge_attr) + + if edge_attr: + edge.append(' [' + edge_attr + ']') + + return ' '.join(edge) + ';' + + +class Graph(Common): + """Class representing a graph in Graphviz's dot language. + + This class implements the methods to work on a representation + of a graph in Graphviz's dot language. + + graph(graph_name='G', graph_type='digraph', + strict=False, suppress_disconnected=False, attribute=value, ...) + + graph_name: + the graph's name + graph_type: + can be 'graph' or 'digraph' + suppress_disconnected: + defaults to False, which will remove from the + graph any disconnected nodes. + simplify: + if True it will avoid displaying equal edges, i.e. + only one edge between two nodes. removing the + duplicated ones. + + All the attributes defined in the Graphviz dot language should + be supported. + + Attributes can be set through the dynamically generated methods: + + set_[attribute name], i.e. set_size, set_fontname + + or using the instance's attributes: + + Graph.obj_dict['attributes'][attribute name], i.e. + + graph_instance.obj_dict['attributes']['label'] + graph_instance.obj_dict['attributes']['fontname'] + """ + + def __init__( + self, graph_name='G', obj_dict=None, graph_type='digraph', strict=False, + suppress_disconnected=False, simplify=False, **attrs): + + if obj_dict is not None: + self.obj_dict = obj_dict + else: + self.obj_dict = dict() + + self.obj_dict['attributes'] = dict(attrs) + + if graph_type not in ['graph', 'digraph']: + raise Error(( + 'Invalid type "%s". Accepted graph types are: ' + 'graph, digraph, subgraph' % graph_type + )) + + self.obj_dict['name'] = quote_if_necessary(graph_name) + self.obj_dict['type'] = graph_type + + self.obj_dict['strict'] = strict + self.obj_dict['suppress_disconnected'] = suppress_disconnected + self.obj_dict['simplify'] = simplify + + self.obj_dict['current_child_sequence'] = 1 + self.obj_dict['nodes'] = dict() + self.obj_dict['edges'] = dict() + self.obj_dict['subgraphs'] = dict() + + self.set_parent_graph(self) + + self.create_attribute_methods(GRAPH_ATTRIBUTES) + + def get_graph_type(self): + return self.obj_dict['type'] + + def get_top_graph_type(self): + parent = self + while True: + parent_ = parent.get_parent_graph() + if parent_ == parent: + break + parent = parent_ + + return parent.obj_dict['type'] + + def set_graph_defaults(self, **attrs): + self.add_node(Node('graph', **attrs)) + + def get_graph_defaults(self, **attrs): + + graph_nodes = self.get_node('graph') + + if isinstance(graph_nodes, (list, tuple)): + return [node.get_attributes() for node in graph_nodes] + + return graph_nodes.get_attributes() + + def set_node_defaults(self, **attrs): + self.add_node(Node('node', **attrs)) + + def get_node_defaults(self, **attrs): + graph_nodes = self.get_node('node') + + if isinstance(graph_nodes, (list, tuple)): + return [node.get_attributes() for node in graph_nodes] + + return graph_nodes.get_attributes() + + def set_edge_defaults(self, **attrs): + self.add_node(Node('edge', **attrs)) + + def get_edge_defaults(self, **attrs): + graph_nodes = self.get_node('edge') + + if isinstance(graph_nodes, (list, tuple)): + return [node.get_attributes() for node in graph_nodes] + + return graph_nodes.get_attributes() + + def set_simplify(self, simplify): + """Set whether to simplify or not. + + If True it will avoid displaying equal edges, i.e. + only one edge between two nodes. removing the + duplicated ones. + """ + + self.obj_dict['simplify'] = simplify + + def get_simplify(self): + """Get whether to simplify or not. + + Refer to set_simplify for more information. + """ + + return self.obj_dict['simplify'] + + def set_type(self, graph_type): + """Set the graph's type, 'graph' or 'digraph'.""" + + self.obj_dict['type'] = graph_type + + def get_type(self): + """Get the graph's type, 'graph' or 'digraph'.""" + + return self.obj_dict['type'] + + def set_name(self, graph_name): + """Set the graph's name.""" + + self.obj_dict['name'] = graph_name + + def get_name(self): + """Get the graph's name.""" + + return self.obj_dict['name'] + + def set_strict(self, val): + """Set graph to 'strict' mode. + + This option is only valid for top level graphs. + """ + + self.obj_dict['strict'] = val + + def get_strict(self, val): + """Get graph's 'strict' mode (True, False). + + This option is only valid for top level graphs. + """ + + return self.obj_dict['strict'] + + def set_suppress_disconnected(self, val): + """Suppress disconnected nodes in the output graph. + + This option will skip nodes in the graph with no incoming or outgoing + edges. This option works also for subgraphs and has effect only in the + current graph/subgraph. + """ + + self.obj_dict['suppress_disconnected'] = val + + def get_suppress_disconnected(self, val): + """Get if suppress disconnected is set. + + Refer to set_suppress_disconnected for more information. + """ + + return self.obj_dict['suppress_disconnected'] + + def get_next_sequence_number(self): + seq = self.obj_dict['current_child_sequence'] + self.obj_dict['current_child_sequence'] += 1 + return seq + + def add_node(self, graph_node): + """Adds a node object to the graph. + + It takes a node object as its only argument and returns + None. + """ + + if not isinstance(graph_node, Node): + raise TypeError('add_node() received a non node class object: ' + str(graph_node)) + + node = self.get_node(graph_node.get_name()) + + if not node: + self.obj_dict['nodes'][graph_node.get_name()] = [graph_node.obj_dict] + + #self.node_dict[graph_node.get_name()] = graph_node.attributes + graph_node.set_parent_graph(self.get_parent_graph()) + else: + self.obj_dict['nodes'][graph_node.get_name()].append(graph_node.obj_dict) + + graph_node.set_sequence(self.get_next_sequence_number()) + + def del_node(self, name, index=None): + """Delete a node from the graph. + + Given a node's name all node(s) with that same name + will be deleted if 'index' is not specified or set + to None. + If there are several nodes with that same name and + 'index' is given, only the node in that position + will be deleted. + + 'index' should be an integer specifying the position + of the node to delete. If index is larger than the + number of nodes with that name, no action is taken. + + If nodes are deleted it returns True. If no action + is taken it returns False. + """ + + if isinstance(name, Node): + name = name.get_name() + + if name in self.obj_dict['nodes']: + if index is not None and index < len(self.obj_dict['nodes'][name]): + del self.obj_dict['nodes'][name][index] + return True + else: + del self.obj_dict['nodes'][name] + return True + + return False + + def get_node(self, name): + """Retrieve a node from the graph. + + Given a node's name the corresponding Node + instance will be returned. + + If one or more nodes exist with that name a list of + Node instances is returned. + An empty list is returned otherwise. + """ + + match = list() + + if name in self.obj_dict['nodes']: + match.extend([ + Node(obj_dict=obj_dict) + for obj_dict + in self.obj_dict['nodes'][name] + ]) + + return match + + def get_nodes(self): + """Get the list of Node instances.""" + + return self.get_node_list() + + def get_node_list(self): + """Get the list of Node instances. + + This method returns the list of Node instances + composing the graph. + """ + + node_objs = list() + + for node, obj_dict_list in self.obj_dict['nodes'].items(): + node_objs.extend([ + Node(obj_dict=obj_d) + for obj_d + in obj_dict_list + ]) + + return node_objs + + def add_edge(self, graph_edge): + """Adds an edge object to the graph. + + It takes a edge object as its only argument and returns + None. + """ + + if not isinstance(graph_edge, Edge): + raise TypeError('add_edge() received a non edge class object: ' + str(graph_edge)) + + edge_points = (graph_edge.get_source(), graph_edge.get_destination()) + + if edge_points in self.obj_dict['edges']: + + edge_list = self.obj_dict['edges'][edge_points] + edge_list.append(graph_edge.obj_dict) + else: + self.obj_dict['edges'][edge_points] = [graph_edge.obj_dict] + + graph_edge.set_sequence(self.get_next_sequence_number()) + graph_edge.set_parent_graph(self.get_parent_graph()) + + def del_edge(self, src_or_list, dst=None, index=None): + """Delete an edge from the graph. + + Given an edge's (source, destination) node names all + matching edges(s) will be deleted if 'index' is not + specified or set to None. + If there are several matching edges and 'index' is + given, only the edge in that position will be deleted. + + 'index' should be an integer specifying the position + of the edge to delete. If index is larger than the + number of matching edges, no action is taken. + + If edges are deleted it returns True. If no action + is taken it returns False. + """ + + if isinstance(src_or_list, (list, tuple)): + if dst is not None and isinstance(dst, (int, long)): + index = dst + src, dst = src_or_list + else: + src, dst = src_or_list, dst + + if isinstance(src, Node): + src = src.get_name() + + if isinstance(dst, Node): + dst = dst.get_name() + + if (src, dst) in self.obj_dict['edges']: + if index is not None and index < len(self.obj_dict['edges'][(src, dst)]): + del self.obj_dict['edges'][(src, dst)][index] + return True + else: + del self.obj_dict['edges'][(src, dst)] + return True + + return False + + def get_edge(self, src_or_list, dst=None): + """Retrieved an edge from the graph. + + Given an edge's source and destination the corresponding + Edge instance(s) will be returned. + + If one or more edges exist with that source and destination + a list of Edge instances is returned. + An empty list is returned otherwise. + """ + + if isinstance(src_or_list, (list, tuple)) and dst is None: + edge_points = tuple(src_or_list) + edge_points_reverse = (edge_points[1], edge_points[0]) + else: + edge_points = (src_or_list, dst) + edge_points_reverse = (dst, src_or_list) + + match = list() + + if edge_points in self.obj_dict['edges'] or ( + self.get_top_graph_type() == 'graph' and + edge_points_reverse in self.obj_dict['edges'] + ): + + edges_obj_dict = self.obj_dict['edges'].get( + edge_points, + self.obj_dict['edges'].get(edge_points_reverse, None)) + + for edge_obj_dict in edges_obj_dict: + match.append( + Edge(edge_points[0], edge_points[1], obj_dict=edge_obj_dict) + ) + + return match + + def get_edges(self): + return self.get_edge_list() + + def get_edge_list(self): + """Get the list of Edge instances. + + This method returns the list of Edge instances + composing the graph. + """ + + edge_objs = list() + + for edge, obj_dict_list in self.obj_dict['edges'].items(): + edge_objs.extend([ + Edge(obj_dict=obj_d) + for obj_d + in obj_dict_list + ]) + + return edge_objs + + def add_subgraph(self, sgraph): + """Adds an subgraph object to the graph. + + It takes a subgraph object as its only argument and returns + None. + """ + + if not isinstance(sgraph, Subgraph) and not isinstance(sgraph, Cluster): + raise TypeError('add_subgraph() received a non subgraph class object:' + str(sgraph)) + + if sgraph.get_name() in self.obj_dict['subgraphs']: + + sgraph_list = self.obj_dict['subgraphs'][sgraph.get_name()] + sgraph_list.append(sgraph.obj_dict) + + else: + self.obj_dict['subgraphs'][sgraph.get_name()] = [sgraph.obj_dict] + + sgraph.set_sequence(self.get_next_sequence_number()) + + sgraph.set_parent_graph(self.get_parent_graph()) + + def get_subgraph(self, name): + """Retrieved a subgraph from the graph. + + Given a subgraph's name the corresponding + Subgraph instance will be returned. + + If one or more subgraphs exist with the same name, a list of + Subgraph instances is returned. + An empty list is returned otherwise. + """ + + match = list() + + if name in self.obj_dict['subgraphs']: + sgraphs_obj_dict = self.obj_dict['subgraphs'].get(name) + + for obj_dict_list in sgraphs_obj_dict: + #match.extend(Subgraph(obj_dict = obj_d) for obj_d in obj_dict_list) + match.append(Subgraph(obj_dict=obj_dict_list)) + + return match + + def get_subgraphs(self): + return self.get_subgraph_list() + + def get_subgraph_list(self): + """Get the list of Subgraph instances. + + This method returns the list of Subgraph instances + in the graph. + """ + + sgraph_objs = list() + + for sgraph, obj_dict_list in self.obj_dict['subgraphs'].items(): + sgraph_objs.extend([ + Subgraph(obj_dict=obj_d) + for obj_d + in obj_dict_list + ]) + + return sgraph_objs + + def set_parent_graph(self, parent_graph): + + self.obj_dict['parent_graph'] = parent_graph + + for obj_list in self.obj_dict['nodes'].values(): + for obj in obj_list: + obj['parent_graph'] = parent_graph + + for obj_list in self.obj_dict['edges'].values(): + for obj in obj_list: + obj['parent_graph'] = parent_graph + + for obj_list in self.obj_dict['subgraphs'].values(): + for obj in obj_list: + Graph(obj_dict=obj).set_parent_graph(parent_graph) + + def to_string(self): + """Returns a string representation of the graph in dot language. + + It will return the graph and all its subelements in string from. + """ + + graph = list() + + if self.obj_dict.get('strict', None) is not None: + if self == self.get_parent_graph() and self.obj_dict['strict']: + graph.append('strict ') + + if self.obj_dict['name'] == '': + if 'show_keyword' in self.obj_dict and self.obj_dict['show_keyword']: + graph.append('subgraph {\n') + else: + graph.append('{\n') + else: + graph.append('%s %s {\n' % (self.obj_dict['type'], self.obj_dict['name'])) + + for attr, value in sorted(self.obj_dict['attributes'].items(), key=itemgetter(0)): + if value is not None: + graph.append('%s=%s' % (attr, quote_if_necessary(value))) + else: + graph.append(attr) + + graph.append(';\n') + + edges_done = set() + + edge_obj_dicts = list() + for e in self.obj_dict['edges'].values(): + edge_obj_dicts.extend(e) + + if edge_obj_dicts: + edge_src_set, edge_dst_set = list(zip(*[obj['points'] for obj in edge_obj_dicts])) + edge_src_set, edge_dst_set = set(edge_src_set), set(edge_dst_set) + else: + edge_src_set, edge_dst_set = set(), set() + + node_obj_dicts = list() + for e in self.obj_dict['nodes'].values(): + node_obj_dicts.extend(e) + + sgraph_obj_dicts = list() + for sg in self.obj_dict['subgraphs'].values(): + sgraph_obj_dicts.extend(sg) + + obj_list = sorted([ + (obj['sequence'], obj) + for obj + in (edge_obj_dicts + node_obj_dicts + sgraph_obj_dicts) + ]) + + for idx, obj in obj_list: + if obj['type'] == 'node': + node = Node(obj_dict=obj) + + if self.obj_dict.get('suppress_disconnected', False): + if (node.get_name() not in edge_src_set and + node.get_name() not in edge_dst_set): + continue + + graph.append(node.to_string() + '\n') + + elif obj['type'] == 'edge': + edge = Edge(obj_dict=obj) + + if self.obj_dict.get('simplify', False) and edge in edges_done: + continue + + graph.append(edge.to_string() + '\n') + edges_done.add(edge) + else: + sgraph = Subgraph(obj_dict=obj) + graph.append(sgraph.to_string() + '\n') + + graph.append('}\n') + + return ''.join(graph) + + +class Subgraph(Graph): + + """Class representing a subgraph in Graphviz's dot language. + + This class implements the methods to work on a representation + of a subgraph in Graphviz's dot language. + + subgraph(graph_name='subG', suppress_disconnected=False, attribute=value, ...) + + graph_name: + the subgraph's name + suppress_disconnected: + defaults to false, which will remove from the + subgraph any disconnected nodes. + All the attributes defined in the Graphviz dot language should + be supported. + + Attributes can be set through the dynamically generated methods: + + set_[attribute name], i.e. set_size, set_fontname + + or using the instance's attributes: + + Subgraph.obj_dict['attributes'][attribute name], i.e. + + subgraph_instance.obj_dict['attributes']['label'] + subgraph_instance.obj_dict['attributes']['fontname'] + """ + + # RMF: subgraph should have all the attributes of graph so it can be passed + # as a graph to all methods + # + def __init__( + self, graph_name='', obj_dict=None, suppress_disconnected=False, + simplify=False, **attrs): + + Graph.__init__( + self, graph_name=graph_name, obj_dict=obj_dict, + suppress_disconnected=suppress_disconnected, simplify=simplify, **attrs) + + if obj_dict is None: + self.obj_dict['type'] = 'subgraph' + + +class Cluster(Graph): + + """Class representing a cluster in Graphviz's dot language. + + This class implements the methods to work on a representation + of a cluster in Graphviz's dot language. + + cluster(graph_name='subG', suppress_disconnected=False, attribute=value, ...) + + graph_name: + the cluster's name (the string 'cluster' will be always prepended) + suppress_disconnected: + defaults to false, which will remove from the + cluster any disconnected nodes. + All the attributes defined in the Graphviz dot language should + be supported. + + Attributes can be set through the dynamically generated methods: + + set_[attribute name], i.e. set_color, set_fontname + + or using the instance's attributes: + + Cluster.obj_dict['attributes'][attribute name], i.e. + + cluster_instance.obj_dict['attributes']['label'] + cluster_instance.obj_dict['attributes']['fontname'] + """ + + def __init__( + self, graph_name='subG', obj_dict=None, suppress_disconnected=False, + simplify=False, **attrs): + + Graph.__init__( + self, graph_name=graph_name, obj_dict=obj_dict, + suppress_disconnected=suppress_disconnected, simplify=simplify, **attrs + ) + + if obj_dict is None: + self.obj_dict['type'] = 'subgraph' + self.obj_dict['name'] = 'cluster_' + graph_name + + self.create_attribute_methods(CLUSTER_ATTRIBUTES) + + +class Dot(Graph): + """A container for handling a dot language file. + + This class implements methods to write and process + a dot language file. It is a derived class of + the base class 'Graph'. + """ + + def __init__(self, *argsl, **argsd): + Graph.__init__(self, *argsl, **argsd) + + self.shape_files = list() + self.progs = None + self.formats = [ + 'canon', 'cmap', 'cmapx', 'cmapx_np', 'dia', 'dot', + 'fig', 'gd', 'gd2', 'gif', 'hpgl', 'imap', 'imap_np', 'ismap', + 'jpe', 'jpeg', 'jpg', 'mif', 'mp', 'pcl', 'pdf', 'pic', 'plain', + 'plain-ext', 'png', 'ps', 'ps2', 'svg', 'svgz', 'vml', 'vmlz', + 'vrml', 'vtx', 'wbmp', 'xdot', 'xlib' + ] + self.prog = 'dot' + + # Automatically creates all the methods enabling the creation + # of output in any of the supported formats. + for frmt in self.formats: + self.__setattr__( + 'create_' + frmt, + lambda f=frmt, prog=self.prog: self.create(format=f, prog=prog) + ) + f = self.__dict__['create_' + frmt] + f.__doc__ = ( + '''Refer to the docstring accompanying the''' + ''''create' method for more information.''' + ) + + for frmt in self.formats + ['raw']: + self.__setattr__( + 'write_' + frmt, + lambda path, f=frmt, prog=self.prog: self.write(path, format=f, prog=prog) + ) + + f = self.__dict__['write_' + frmt] + f.__doc__ = ( + '''Refer to the docstring accompanying the''' + ''''write' method for more information.''' + ) + + def __getstate__(self): + return copy.copy(self.obj_dict) + + def __setstate__(self, state): + self.obj_dict = state + + def set_shape_files(self, file_paths): + """Add the paths of the required image files. + + If the graph needs graphic objects to be used as shapes or otherwise + those need to be in the same folder as the graph is going to be rendered + from. Alternatively the absolute path to the files can be specified when + including the graphics in the graph. + + The files in the location pointed to by the path(s) specified as arguments + to this method will be copied to the same temporary location where the + graph is going to be rendered. + """ + + if isinstance(file_paths, basestring): + self.shape_files.append(file_paths) + + if isinstance(file_paths, (list, tuple)): + self.shape_files.extend(file_paths) + + def set_prog(self, prog): + """Sets the default program. + + Sets the default program in charge of processing + the dot file into a graph. + """ + self.prog = prog + + def set_graphviz_executables(self, paths): + """This method allows to manually specify the location of the GraphViz executables. + + The argument to this method should be a dictionary where the keys are as follows: + + {'dot': '', 'twopi': '', 'neato': '', 'circo': '', 'fdp': ''} + + and the values are the paths to the corresponding executable, including the name + of the executable itself. + """ + + self.progs = paths + + def write(self, path, prog=None, format='raw'): + """ + Given a filename 'path' it will open/create and truncate + such file and write on it a representation of the graph + defined by the dot object and in the format specified by + 'format'. 'path' can also be an open file-like object, such as + a StringIO instance. + + The format 'raw' is used to dump the string representation + of the Dot object, without further processing. + The output can be processed by any of graphviz tools, defined + in 'prog', which defaults to 'dot' + Returns True or False according to the success of the write + operation. + + There's also the preferred possibility of using: + + write_'format'(path, prog='program') + + which are automatically defined for all the supported formats. + [write_ps(), write_gif(), write_dia(), ...] + + """ + if prog is None: + prog = self.prog + + fobj, close = get_fobj(path, 'w+b') + try: + if format == 'raw': + data = self.to_string() + if isinstance(data, basestring): + if not isinstance(data, unicode): + try: + data = unicode(data, 'utf-8') + except: + pass + + try: + charset = self.get_charset() + if not PY3 or not charset: + charset = 'utf-8' + data = data.encode(charset) + except: + if PY3: + data = data.encode('utf-8') + pass + + fobj.write(data) + + else: + fobj.write(self.create(prog, format)) + finally: + if close: + fobj.close() + + return True + + def create(self, prog=None, format='ps'): + """Creates and returns a Postscript representation of the graph. + + create will write the graph to a temporary dot file and process + it with the program given by 'prog' (which defaults to 'twopi'), + reading the Postscript output and returning it as a string is the + operation is successful. + On failure None is returned. + + There's also the preferred possibility of using: + + create_'format'(prog='program') + + which are automatically defined for all the supported formats. + [create_ps(), create_gif(), create_dia(), ...] + + If 'prog' is a list instead of a string the fist item is expected + to be the program name, followed by any optional command-line + arguments for it: + + ['twopi', '-Tdot', '-s10'] + """ + + if prog is None: + prog = self.prog + + if isinstance(prog, (list, tuple)): + prog, args = prog[0], prog[1:] + else: + args = [] + + if self.progs is None: + self.progs = find_graphviz() + if self.progs is None: + raise InvocationException( + 'GraphViz\'s executables not found') + + if prog not in self.progs: + raise InvocationException( + 'GraphViz\'s executable "%s" not found' % prog) + + if not os.path.exists(self.progs[prog]) or not os.path.isfile(self.progs[prog]): + raise InvocationException( + 'GraphViz\'s executable "%s" is not a file or doesn\'t exist' % self.progs[prog]) + + tmp_fd, tmp_name = tempfile.mkstemp() + os.close(tmp_fd) + self.write(tmp_name) + tmp_dir = os.path.dirname(tmp_name) + + # For each of the image files... + for img in self.shape_files: + + # Get its data + f = open(img, 'rb') + f_data = f.read() + f.close() + + # And copy it under a file with the same name in the temporary directory + f = open(os.path.join(tmp_dir, os.path.basename(img)), 'wb') + f.write(f_data) + f.close() + + cmdline = [self.progs[prog], '-T' + format, tmp_name] + args + + p = subprocess.Popen( + cmdline, + cwd=tmp_dir, + stderr=subprocess.PIPE, stdout=subprocess.PIPE) + + stderr = p.stderr + stdout = p.stdout + + stdout_output = list() + while True: + data = stdout.read() + if not data: + break + stdout_output.append(data) + stdout.close() + + stdout_output = NULL_SEP.join(stdout_output) + + if not stderr.closed: + stderr_output = list() + while True: + data = stderr.read() + if not data: + break + stderr_output.append(data) + stderr.close() + + if stderr_output: + stderr_output = NULL_SEP.join(stderr_output) + if PY3: + stderr_output = stderr_output.decode(sys.stderr.encoding) + + #pid, status = os.waitpid(p.pid, 0) + status = p.wait() + + if status != 0: + raise InvocationException( + 'Program terminated with status: %d. stderr follows: %s' % ( + status, stderr_output)) + elif stderr_output: + print(stderr_output) + + # For each of the image files... + for img in self.shape_files: + + # remove it + os.unlink(os.path.join(tmp_dir, os.path.basename(img))) + + os.unlink(tmp_name) + + return stdout_output diff --git a/nxpd/pydot/_dotparser.py b/nxpd/pydot/_dotparser.py new file mode 100644 index 0000000..4cdd482 --- /dev/null +++ b/nxpd/pydot/_dotparser.py @@ -0,0 +1,520 @@ +"""Graphviz's dot language parser. + +The dotparser parses graphviz files in dot and dot files and transforms them +into a class representation defined by pydot. + +The module needs pyparsing (tested with version 1.2.2) and pydot + +Author: Michael Krause +Fixes by: Ero Carrera +""" + +from __future__ import division, print_function + +__author__ = ['Michael Krause', 'Ero Carrera'] +__license__ = 'MIT' + +import sys +import pydot +import codecs + +from pyparsing import __version__ as pyparsing_version + +from pyparsing import ( + nestedExpr, Literal, CaselessLiteral, Word, OneOrMore, + Forward, Group, Optional, Combine, nums, restOfLine, + cStyleComment, alphanums, printables, ParseException, + ParseResults, CharsNotIn, QuotedString + ) + + +PY3 = not sys.version_info < (3, 0, 0) + +if PY3: + basestring = str + + +class P_AttrList: + + def __init__(self, toks): + self.attrs = {} + i = 0 + + while i < len(toks): + attrname = toks[i] + if i + 2 < len(toks) and toks[i + 1] == '=': + attrvalue = toks[i + 2] + i += 3 + else: + attrvalue = None + i += 1 + + self.attrs[attrname] = attrvalue + + def __repr__(self): + return "%s(%r)" % (self.__class__.__name__, self.attrs) + + +class DefaultStatement(P_AttrList): + + def __init__(self, default_type, attrs): + self.default_type = default_type + self.attrs = attrs + + def __repr__(self): + return "%s(%s, %r)" % ( + self.__class__.__name__, + self.default_type, self.attrs + ) + + +top_graphs = list() + + +def push_top_graph_stmt(str, loc, toks): + attrs = {} + g = None + + for element in toks: + if (isinstance(element, (ParseResults, tuple, list)) and + len(element) == 1 and isinstance(element[0], basestring)): + element = element[0] + + if element == 'strict': + attrs['strict'] = True + + elif element in ['graph', 'digraph']: + attrs = {} + + g = pydot.Dot(graph_type=element, **attrs) + attrs['type'] = element + + top_graphs.append(g) + + elif isinstance(element, basestring): + g.set_name(element) + + elif isinstance(element, pydot.Subgraph): + g.obj_dict['attributes'].update(element.obj_dict['attributes']) + g.obj_dict['edges'].update(element.obj_dict['edges']) + g.obj_dict['nodes'].update(element.obj_dict['nodes']) + g.obj_dict['subgraphs'].update(element.obj_dict['subgraphs']) + g.set_parent_graph(g) + + elif isinstance(element, P_AttrList): + attrs.update(element.attrs) + + elif isinstance(element, (ParseResults, list)): + add_elements(g, element) + + else: + raise ValueError("Unknown element statement: %r " % element) + + for g in top_graphs: + update_parent_graph_hierarchy(g) + + if len(top_graphs) == 1: + return top_graphs[0] + + return top_graphs + + +def update_parent_graph_hierarchy(g, parent_graph=None, level=0): + if parent_graph is None: + parent_graph = g + + for key_name in ('edges',): + if isinstance(g, pydot.frozendict): + item_dict = g + else: + item_dict = g.obj_dict + + if key_name not in item_dict: + continue + + for key, objs in item_dict[key_name].items(): + for obj in objs: + if 'parent_graph' in obj and obj['parent_graph'].get_parent_graph() == g: + if obj['parent_graph'] is g: + pass + else: + obj['parent_graph'].set_parent_graph(parent_graph) + + if key_name == 'edges' and len(key) == 2: + for idx, vertex in enumerate(obj['points']): + if isinstance(vertex, (pydot.Graph, pydot.Subgraph, pydot.Cluster)): + vertex.set_parent_graph(parent_graph) + if isinstance(vertex, pydot.frozendict): + if vertex['parent_graph'] is g: + pass + else: + vertex['parent_graph'].set_parent_graph(parent_graph) + + +def add_defaults(element, defaults): + d = element.__dict__ + for key, value in defaults.items(): + if not d.get(key): + d[key] = value + + +def add_elements(g, toks, defaults_graph=None, defaults_node=None, defaults_edge=None): + if defaults_graph is None: + defaults_graph = {} + if defaults_node is None: + defaults_node = {} + if defaults_edge is None: + defaults_edge = {} + + for elm_idx, element in enumerate(toks): + if isinstance(element, (pydot.Subgraph, pydot.Cluster)): + add_defaults(element, defaults_graph) + g.add_subgraph(element) + + elif isinstance(element, pydot.Node): + add_defaults(element, defaults_node) + g.add_node(element) + + elif isinstance(element, pydot.Edge): + add_defaults(element, defaults_edge) + g.add_edge(element) + + elif isinstance(element, ParseResults): + for e in element: + add_elements(g, [e], defaults_graph, defaults_node, defaults_edge) + + elif isinstance(element, DefaultStatement): + if element.default_type == 'graph': + default_graph_attrs = pydot.Node('graph', **element.attrs) + g.add_node(default_graph_attrs) + + elif element.default_type == 'node': + default_node_attrs = pydot.Node('node', **element.attrs) + g.add_node(default_node_attrs) + + elif element.default_type == 'edge': + default_edge_attrs = pydot.Node('edge', **element.attrs) + g.add_node(default_edge_attrs) + defaults_edge.update(element.attrs) + + else: + raise ValueError("Unknown DefaultStatement: %s " % element.default_type) + + elif isinstance(element, P_AttrList): + g.obj_dict['attributes'].update(element.attrs) + + else: + raise ValueError("Unknown element statement: %r" % element) + + +def push_graph_stmt(str, loc, toks): + g = pydot.Subgraph('') + add_elements(g, toks) + return g + + +def push_subgraph_stmt(str, loc, toks): + g = pydot.Subgraph('') + + for e in toks: + if len(e) == 3: + e[2].set_name(e[1]) + if e[0] == 'subgraph': + e[2].obj_dict['show_keyword'] = True + return e[2] + else: + if e[0] == 'subgraph': + e[1].obj_dict['show_keyword'] = True + return e[1] + + return g + + +def push_default_stmt(str, loc, toks): + # The pydot class instances should be marked as + # default statements to be inherited by actual + # graphs, nodes and edges. + default_type = toks[0][0] + if len(toks) > 1: + attrs = toks[1].attrs + else: + attrs = {} + + if default_type in ['graph', 'node', 'edge']: + return DefaultStatement(default_type, attrs) + else: + raise ValueError("Unknown default statement: %r " % toks) + + +def push_attr_list(str, loc, toks): + p = P_AttrList(toks) + return p + + +def get_port(node): + if len(node) > 1: + if isinstance(node[1], ParseResults): + if len(node[1][0]) == 2: + if node[1][0][0] == ':': + return node[1][0][1] + return None + + +def do_node_ports(node): + node_port = '' + + if len(node) > 1: + node_port = ''.join([str(a) + str(b) for a, b in node[1]]) + + return node_port + + +def push_edge_stmt(str, loc, toks): + tok_attrs = [a for a in toks if isinstance(a, P_AttrList)] + attrs = {} + + for a in tok_attrs: + attrs.update(a.attrs) + + e = [] + + if isinstance(toks[0][0], pydot.Graph): + n_prev = pydot.frozendict(toks[0][0].obj_dict) + else: + n_prev = toks[0][0] + do_node_ports(toks[0]) + + if isinstance(toks[2][0], ParseResults): + n_next_list = [[n.get_name()] for n in toks[2][0]] + for n_next in [n for n in n_next_list]: + n_next_port = do_node_ports(n_next) + e.append(pydot.Edge(n_prev, n_next[0] + n_next_port, **attrs)) + + elif isinstance(toks[2][0], pydot.Graph): + e.append(pydot.Edge(n_prev, pydot.frozendict(toks[2][0].obj_dict), **attrs)) + + elif isinstance(toks[2][0], pydot.Node): + node = toks[2][0] + + if node.get_port() is not None: + name_port = node.get_name() + ":" + node.get_port() + else: + name_port = node.get_name() + + e.append(pydot.Edge(n_prev, name_port, **attrs)) + + elif isinstance(toks[2][0], type('')): + for n_next in [n for n in tuple(toks)[2::2]]: + if isinstance(n_next, P_AttrList) or not isinstance(n_next[0], type('')): + continue + + n_next_port = do_node_ports(n_next) + e.append(pydot.Edge(n_prev, n_next[0] + n_next_port, **attrs)) + + n_prev = n_next[0] + n_next_port + + else: + # UNEXPECTED EDGE TYPE + pass + + return e + + +def push_node_stmt(s, loc, toks): + + if len(toks) == 2: + attrs = toks[1].attrs + else: + attrs = {} + + node_name = toks[0] + if isinstance(node_name, list) or isinstance(node_name, tuple): + if len(node_name) > 0: + node_name = node_name[0] + + n = pydot.Node(str(node_name), **attrs) + return n + + +graphparser = None + + +def graph_definition(): + global graphparser + + if not graphparser: + # punctuation + colon = Literal(":") + lbrace = Literal("{") + rbrace = Literal("}") + lbrack = Literal("[") + rbrack = Literal("]") + lparen = Literal("(") + rparen = Literal(")") + equals = Literal("=") + comma = Literal(",") + # dot = Literal(".") + # slash = Literal("/") + # bslash = Literal("\\") + # star = Literal("*") + semi = Literal(";") + at = Literal("@") + minus = Literal("-") + + # keywords + strict_ = CaselessLiteral("strict") + graph_ = CaselessLiteral("graph") + digraph_ = CaselessLiteral("digraph") + subgraph_ = CaselessLiteral("subgraph") + node_ = CaselessLiteral("node") + edge_ = CaselessLiteral("edge") + + # token definitions + identifier = Word(alphanums + "_.").setName("identifier") + + # dblQuotedString + double_quoted_string = QuotedString('"', multiline=True, unquoteResults=False) + + noncomma_ = "".join([c for c in printables if c != ","]) + alphastring_ = OneOrMore(CharsNotIn(noncomma_ + ' ')) + + def parse_html(s, loc, toks): + return '<%s>' % ''.join(toks[0]) + + opener = '<' + closer = '>' + html_text = nestedExpr( + opener, closer, + (CharsNotIn(opener + closer)) + ).setParseAction(parse_html).leaveWhitespace() + + ID = ( + identifier | html_text | + double_quoted_string | # .setParseAction(strip_quotes) | + alphastring_ + ).setName("ID") + + float_number = Combine( + Optional(minus) + + OneOrMore(Word(nums + ".")) + ).setName("float_number") + + righthand_id = (float_number | ID).setName("righthand_id") + + port_angle = (at + ID).setName("port_angle") + + port_location = ( + OneOrMore(Group(colon + ID)) | + Group(colon + lparen + ID + comma + ID + rparen) + ).setName("port_location") + + port = ( + Group(port_location + Optional(port_angle)) | + Group(port_angle + Optional(port_location)) + ).setName("port") + + node_id = (ID + Optional(port)) + a_list = OneOrMore( + ID + Optional(equals + righthand_id) + Optional(comma.suppress()) + ).setName("a_list") + + attr_list = OneOrMore( + lbrack.suppress() + Optional(a_list) + rbrack.suppress() + ).setName("attr_list") + + attr_stmt = (Group(graph_ | node_ | edge_) + attr_list).setName("attr_stmt") + + edgeop = (Literal("--") | Literal("->")).setName("edgeop") + + stmt_list = Forward() + graph_stmt = Group( + lbrace.suppress() + Optional(stmt_list) + + rbrace.suppress() + Optional(semi.suppress()) + ).setName("graph_stmt") + + edge_point = Forward() + + edgeRHS = OneOrMore(edgeop + edge_point) + edge_stmt = edge_point + edgeRHS + Optional(attr_list) + + subgraph = Group(subgraph_ + Optional(ID) + graph_stmt).setName("subgraph") + + edge_point << Group(subgraph | graph_stmt | node_id).setName('edge_point') + + node_stmt = ( + node_id + Optional(attr_list) + Optional(semi.suppress()) + ).setName("node_stmt") + + assignment = (ID + equals + righthand_id).setName("assignment") + stmt = ( + assignment | edge_stmt | attr_stmt | + subgraph | graph_stmt | node_stmt + ).setName("stmt") + stmt_list << OneOrMore(stmt + Optional(semi.suppress())) + + graphparser = OneOrMore(( + Optional(strict_) + Group((graph_ | digraph_)) + + Optional(ID) + graph_stmt + ).setResultsName("graph")) + + singleLineComment = Group("//" + restOfLine) | Group("#" + restOfLine) + + # actions + graphparser.ignore(singleLineComment) + graphparser.ignore(cStyleComment) + + assignment.setParseAction(push_attr_list) + a_list.setParseAction(push_attr_list) + edge_stmt.setParseAction(push_edge_stmt) + node_stmt.setParseAction(push_node_stmt) + attr_stmt.setParseAction(push_default_stmt) + + subgraph.setParseAction(push_subgraph_stmt) + graph_stmt.setParseAction(push_graph_stmt) + graphparser.setParseAction(push_top_graph_stmt) + + return graphparser + + +def parse_dot_data(data): + global top_graphs + + top_graphs = list() + + if PY3: + if isinstance(data, bytes): + # this is extremely hackish + try: + idx = data.index(b'charset') + 7 + while data[idx] in b' \t\n\r=': + idx += 1 + fst = idx + while data[idx] not in b' \t\n\r];,': + idx += 1 + charset = data[fst:idx].strip(b'"\'').decode('ascii') + data = data.decode(charset) + except: + data = data.decode('utf-8') + else: + if data.startswith(codecs.BOM_UTF8): + data = data.decode('utf-8') + + try: + + graphparser = graph_definition() + + if pyparsing_version >= '1.2': + graphparser.parseWithTabs() + + tokens = graphparser.parseString(data) + + if len(tokens) == 1: + return tokens[0] + else: + return [g for g in tokens] + + except ParseException: + err = sys.exc_info()[1] + print(err.line) + print(" " * (err.column - 1) + "^") + print(err) + return None diff --git a/nxpd/utils.py b/nxpd/utils.py new file mode 100644 index 0000000..b21ec16 --- /dev/null +++ b/nxpd/utils.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- + +import os +import subprocess +import sys + +def default_opener(filename): + """Opens `filename` using system's default program. + + Parameters + ---------- + filename : str + The path of the file to be opened. + + """ + cmds = {'darwin': ['open'], + 'linux2': ['xdg-open'], + 'win32': ['cmd.exe', '/c', 'start', '']} + cmd = cmds[sys.platform] + [filename] + subprocess.call(cmd) + +def is_string_like(obj): # from John Hunter, types-free version + """Check if obj is string.""" + try: + obj + '' + except (TypeError, ValueError): + return False + return True + +def get_fobj(fname, mode='w+'): + """Obtain a proper file object. + + Parameters + ---------- + fname : string, file object, file descriptor + If a string or file descriptor, then we create a file object. If *fname* + is a file object, then we do nothing and ignore the specified *mode* + parameter. + mode : str + The mode of the file to be opened. + + Returns + ------- + fobj : file object + The file object. + close : bool + If *fname* was a string, then *close* will be *True* to signify that + the file object should be closed after writing to it. Otherwise, *close* + will be *False* signifying that the user, in essence, created the file + object already and that subsequent operations should not close it. + + """ + if is_string_like(fname): + fobj = open(fname, mode) + close = True + elif hasattr(fname, 'write'): + # fname is a file-like object, perhaps a StringIO (for example) + fobj = fname + close = False + else: + # assume it is a file descriptor + fobj = os.fdopen(fname, mode) + close = False + return fobj, close + +def make_str(t): + """Return the string representation of t.""" + if is_string_like(t): return t + return str(t) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..ef50db3 --- /dev/null +++ b/setup.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +""" +Setup script for `nxpd`. + +""" + +from __future__ import print_function + +import os +import sys + +from distutils.core import setup + +def main(): + + requires = [ + 'networkx(>=1.6)', + 'pyparsing(>=2.0.1)', + ] + + packages = [ + 'nxpd', + 'nxpd.pydot', + ] + + description = """ +`nxpd` is a Python package for visualizing NetworkX graphs using `pydot` +and `graphviz`. Support is also provided for inline displays within IPython +notebooks. +""" + setup( + name = "nxpd", + version = "0.1", + url = "https://github.com/chebee7i/nxpd", + + packages = packages, + provides = ['nxpd'], + requires = requires, + + author = "chebee7i", + author_email = "chebee7i@gmail.com", + description = "NetworkX Pydot Draw", + long_description = description, + license = "Unlicense", + ) + +if __name__ == '__main__': + + v = sys.version_info[:2] + if v < (2, 6): + msg = "nxpd requires Python 2.6 or newer.\n" + print(msg) + sys.exit(-1) + + if sys.argv[-1] == 'setup.py': + print("To install, run 'python setup.py install'.\n") + + main()