1# backend.py - execute rendering, open files in viewer
2
3import os
4import re
5import errno
6import platform
7import subprocess
8
9from ._compat import CalledProcessError, stderr_write_bytes
10
11from . import tools
12
13__all__ = [
14    'render', 'pipe', 'version', 'view',
15    'ENGINES', 'FORMATS', 'RENDERERS', 'FORMATTERS',
16    'ExecutableNotFound', 'RequiredArgumentError',
17]
18
19ENGINES = {  # http://www.graphviz.org/pdf/dot.1.pdf
20    'dot', 'neato', 'twopi', 'circo', 'fdp', 'sfdp', 'patchwork', 'osage',
21}
22
23FORMATS = {  # http://www.graphviz.org/doc/info/output.html
24    'bmp',
25    'canon', 'dot', 'gv', 'xdot', 'xdot1.2', 'xdot1.4',
26    'cgimage',
27    'cmap',
28    'eps',
29    'exr',
30    'fig',
31    'gd', 'gd2',
32    'gif',
33    'gtk',
34    'ico',
35    'imap', 'cmapx',
36    'imap_np', 'cmapx_np',
37    'ismap',
38    'jp2',
39    'jpg', 'jpeg', 'jpe',
40    'json', 'json0', 'dot_json', 'xdot_json',  # Graphviz 2.40
41    'pct', 'pict',
42    'pdf',
43    'pic',
44    'plain', 'plain-ext',
45    'png',
46    'pov',
47    'ps',
48    'ps2',
49    'psd',
50    'sgi',
51    'svg', 'svgz',
52    'tga',
53    'tif', 'tiff',
54    'tk',
55    'vml', 'vmlz',
56    'vrml',
57    'wbmp',
58    'webp',
59    'xlib',
60    'x11',
61}
62
63RENDERERS = {  # $ dot -T:
64    'cairo',
65    'dot',
66    'fig',
67    'gd',
68    'gdiplus',
69    'map',
70    'pic',
71    'pov',
72    'ps',
73    'svg',
74    'tk',
75    'vml',
76    'vrml',
77    'xdot',
78}
79
80FORMATTERS = {'cairo', 'core', 'gd', 'gdiplus', 'gdwbmp', 'xlib'}
81
82PLATFORM = platform.system().lower()
83
84
85class ExecutableNotFound(RuntimeError):
86    """Exception raised if the Graphviz executable is not found."""
87
88    _msg = ('failed to execute %r, '
89            'make sure the Graphviz executables are on your systems\' PATH')
90
91    def __init__(self, args):
92        super(ExecutableNotFound, self).__init__(self._msg % args)
93
94
95class RequiredArgumentError(Exception):
96    """Exception raised if a required argument is missing."""
97
98
99def command(engine, format, filepath=None, renderer=None, formatter=None):
100    """Return args list for ``subprocess.Popen`` and name of the rendered file."""
101    if formatter is not None and renderer is None:
102        raise RequiredArgumentError('formatter given without renderer')
103
104    if engine not in ENGINES:
105        raise ValueError('unknown engine: %r' % engine)
106    if format not in FORMATS:
107        raise ValueError('unknown format: %r' % format)
108    if renderer is not None and renderer not in RENDERERS:
109        raise ValueError('unknown renderer: %r' % renderer)
110    if formatter is not None and formatter not in FORMATTERS:
111        raise ValueError('unknown formatter: %r' % formatter)
112
113    format_arg = [s for s in (format, renderer, formatter) if s is not None]
114    suffix = '.'.join(reversed(format_arg))
115    format_arg = ':'.join(format_arg)
116
117    cmd = [engine, '-T%s' % format_arg]
118    rendered = None
119    if filepath is not None:
120        cmd.extend(['-O', filepath])
121        rendered = '%s.%s' % (filepath, suffix)
122
123    return cmd, rendered
124
125
126if PLATFORM == 'windows':  # pragma: no cover
127    def get_startupinfo():
128        """Return subprocess.STARTUPINFO instance hiding the console window."""
129        startupinfo = subprocess.STARTUPINFO()
130        startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
131        startupinfo.wShowWindow = subprocess.SW_HIDE
132        return startupinfo
133else:
134    def get_startupinfo():
135        """Return None for startupinfo argument of ``subprocess.Popen``."""
136        return None
137
138
139def run(cmd, input=None, capture_output=False, check=False, quiet=False, **kwargs):
140    """Run the command described by cmd and return its (stdout, stderr) tuple."""
141    if input is not None:
142        kwargs['stdin'] = subprocess.PIPE
143    if capture_output:
144        kwargs['stdout'] = kwargs['stderr'] = subprocess.PIPE
145
146    try:
147        proc = subprocess.Popen(cmd, startupinfo=get_startupinfo(), **kwargs)
148    except OSError as e:
149        if e.errno == errno.ENOENT:
150            raise ExecutableNotFound(cmd)
151        else:  # pragma: no cover
152            raise
153
154    out, err = proc.communicate(input)
155
156    if not quiet and err:
157        stderr_write_bytes(err, flush=True)
158    if check and proc.returncode:
159        raise CalledProcessError(proc.returncode, cmd, output=out, stderr=err)
160
161    return out, err
162
163
164def render(engine, format, filepath, renderer=None, formatter=None, quiet=False):
165    """Render file with Graphviz ``engine`` into ``format``,  return result filename.
166
167    Args:
168        engine: The layout commmand used for rendering (``'dot'``, ``'neato'``, ...).
169        format: The output format used for rendering (``'pdf'``, ``'png'``, ...).
170        filepath: Path to the DOT source file to render.
171        renderer: The output renderer used for rendering (``'cairo'``, ``'gd'``, ...).
172        formatter: The output formatter used for rendering (``'cairo'``, ``'gd'``, ...).
173        quiet (bool): Suppress ``stderr`` output.
174    Returns:
175        The (possibly relative) path of the rendered file.
176    Raises:
177        ValueError: If ``engine``, ``format``, ``renderer``, or ``formatter`` are not known.
178        graphviz.RequiredArgumentError: If ``formatter`` is given but ``renderer`` is None.
179        graphviz.ExecutableNotFound: If the Graphviz executable is not found.
180        subprocess.CalledProcessError: If the exit status is non-zero.
181    """
182    cmd, rendered = command(engine, format, filepath, renderer, formatter)
183    run(cmd, capture_output=True, check=True, quiet=quiet)
184    return rendered
185
186
187def pipe(engine, format, data, renderer=None, formatter=None, quiet=False):
188    """Return ``data`` piped through Graphviz ``engine`` into ``format``.
189
190    Args:
191        engine: The layout commmand used for rendering (``'dot'``, ``'neato'``, ...).
192        format: The output format used for rendering (``'pdf'``, ``'png'``, ...).
193        data: The binary (encoded) DOT source string to render.
194        renderer: The output renderer used for rendering (``'cairo'``, ``'gd'``, ...).
195        formatter: The output formatter used for rendering (``'cairo'``, ``'gd'``, ...).
196        quiet (bool): Suppress ``stderr`` output.
197    Returns:
198        Binary (encoded) stdout of the layout command.
199    Raises:
200        ValueError: If ``engine``, ``format``, ``renderer``, or ``formatter`` are not known.
201        graphviz.RequiredArgumentError: If ``formatter`` is given but ``renderer`` is None.
202        graphviz.ExecutableNotFound: If the Graphviz executable is not found.
203        subprocess.CalledProcessError: If the exit status is non-zero.
204    """
205    cmd, _ = command(engine, format, None, renderer, formatter)
206    out, _ = run(cmd, input=data, capture_output=True, check=True, quiet=quiet)
207    return out
208
209
210def version():
211    """Return the version number tuple from the ``stderr`` output of ``dot -V``.
212
213    Returns:
214        Two or three ``int`` version ``tuple``.
215    Raises:
216        graphviz.ExecutableNotFound: If the Graphviz executable is not found.
217        subprocess.CalledProcessError: If the exit status is non-zero.
218        RuntimmeError: If the output cannot be parsed into a version number.
219    """
220    cmd = ['dot', '-V']
221    out, _ = run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
222
223    info = out.decode('ascii')
224    ma = re.search(r'graphviz version (\d+\.\d+(?:\.\d+)?) ', info)
225    if ma is None:
226        raise RuntimeError
227    return tuple(int(d) for d in ma.group(1).split('.'))
228
229
230def view(filepath):
231    """Open filepath with its default viewing application (platform-specific).
232
233    Args:
234        filepath: Path to the file to open in viewer.
235    Raises:
236        RuntimeError: If the current platform is not supported.
237    """
238    try:
239        view_func = getattr(view, PLATFORM)
240    except AttributeError:
241        raise RuntimeError('platform %r not supported' % PLATFORM)
242    view_func(filepath)
243
244
245@tools.attach(view, 'darwin')
246def view_darwin(filepath):
247    """Open filepath with its default application (mac)."""
248    subprocess.Popen(['open', filepath])
249
250
251@tools.attach(view, 'linux')
252@tools.attach(view, 'freebsd')
253def view_unixoid(filepath):
254    """Open filepath in the user's preferred application (linux, freebsd)."""
255    subprocess.Popen(['xdg-open', filepath])
256
257
258@tools.attach(view, 'windows')
259def view_windows(filepath):
260    """Start filepath with its associated application (windows)."""
261    os.startfile(os.path.normpath(filepath))
262