MoinLight

Annotated moinformat/utils/graphviz.py

203:6808e7c37cd3
2019-02-28 Paul Boddie Test the graphviz exit code to detect errors before trying to parse any output.
paul@101 1
#!/usr/bin/env python
paul@101 2
paul@101 3
"""
paul@101 4
Graphviz utilities.
paul@101 5
paul@101 6
Copyright (C) 2018 Paul Boddie <paul@boddie.org.uk>
paul@101 7
paul@101 8
This program is free software; you can redistribute it and/or modify it under
paul@101 9
the terms of the GNU General Public License as published by the Free Software
paul@101 10
Foundation; either version 3 of the License, or (at your option) any later
paul@101 11
version.
paul@101 12
paul@101 13
This program is distributed in the hope that it will be useful, but WITHOUT
paul@101 14
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
paul@101 15
FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
paul@101 16
details.
paul@101 17
paul@101 18
You should have received a copy of the GNU General Public License along with
paul@101 19
this program.  If not, see <http://www.gnu.org/licenses/>.
paul@101 20
"""
paul@101 21
paul@101 22
from os.path import exists, join
paul@101 23
from StringIO import StringIO
paul@101 24
from subprocess import Popen, PIPE
paul@189 25
from xml.sax.saxutils import XMLGenerator
paul@101 26
import gzip
paul@101 27
import sha
paul@101 28
import xml.sax
paul@101 29
paul@101 30
# Configurable paths and locations.
paul@101 31
paul@101 32
DIAGRAM_TOOLS_PATH  = "/home/paulb/Software/Graphical/diagram-tools"
paul@101 33
GRAPHVIZ_PATH       = "/usr/bin"
paul@101 34
XSLT_PROCESSOR      = "/usr/bin/xsltproc"
paul@101 35
paul@101 36
# Graphviz "filter" programs performing layout.
paul@101 37
paul@101 38
FILTERS = ['circo', 'dot', 'fdp', 'neato', 'twopi']
paul@101 39
paul@101 40
# Supported output formats.
paul@101 41
paul@101 42
IMAGE_FORMATS = ['png', 'gif']
paul@101 43
SVG_FORMATS = ['svg', 'svgz']
paul@101 44
paul@101 45
OUTPUT_FORMATS = IMAGE_FORMATS + SVG_FORMATS + \
paul@101 46
                 ['dia', 'fig', 'hpgl', 'imap', 'mif', 'pcl', 'ps']
paul@101 47
paul@101 48
# XSL transformations for SVG output.
paul@101 49
paul@101 50
TRANSFORMS = {
paul@101 51
    "notugly" : join(DIAGRAM_TOOLS_PATH, "notugly.xsl"),
paul@101 52
    }
paul@101 53
paul@101 54
paul@101 55
paul@101 56
# Utility functions.
paul@101 57
paul@101 58
def encode(s, encoding):
paul@101 59
paul@101 60
    "Encode 's' using 'encoding' if Unicode."
paul@101 61
paul@101 62
    if isinstance(s, unicode):
paul@101 63
        return s.encode(encoding)
paul@101 64
    else:
paul@101 65
        return s
paul@101 66
paul@189 67
class Parser(xml.sax.handler.ContentHandler):
paul@101 68
paul@189 69
    "Common XML parsing functionality."
paul@101 70
paul@101 71
    def parse(self, f):
paul@101 72
paul@101 73
        "Parse content from the file object 'f' using reasonable defaults."
paul@101 74
paul@101 75
        try:
paul@101 76
            parser = xml.sax.make_parser()
paul@101 77
            parser.setContentHandler(self)
paul@101 78
            parser.setErrorHandler(xml.sax.handler.ErrorHandler())
paul@101 79
            parser.setFeature(xml.sax.handler.feature_external_ges, 0)
paul@101 80
            parser.parse(f)
paul@101 81
        finally:
paul@101 82
            f.close()
paul@101 83
paul@189 84
    def parse_data(self, data):
paul@101 85
paul@189 86
        "Parse the given 'data'."
paul@101 87
paul@101 88
        f = StringIO(data)
paul@101 89
        try:
paul@101 90
            self.parse(f)
paul@101 91
        finally:
paul@101 92
            f.close()
paul@101 93
paul@189 94
class MetadataParser(Parser):
paul@189 95
paul@189 96
    "Parse metadata from the svg element."
paul@189 97
paul@189 98
    def __init__(self):
paul@189 99
        self.attrs = {}
paul@189 100
paul@189 101
    def startElement(self, name, attrs):
paul@189 102
        if name == self.tagname:
paul@189 103
            self.attrs = dict(attrs)
paul@189 104
paul@189 105
    def get_metadata(self, data, tagname):
paul@189 106
paul@189 107
        "Process 'data', returning attributes from 'tagname'."
paul@189 108
paul@189 109
        self.tagname = tagname
paul@189 110
        self.parse_data(data)
paul@101 111
        return self.attrs
paul@101 112
paul@189 113
class DocumentSelector(XMLGenerator, Parser):
paul@189 114
paul@189 115
    "Parse a document and obtain the serialisation of the document node."
paul@189 116
paul@189 117
    def startDocument(self):
paul@189 118
        pass
paul@189 119
paul@101 120
def get_output_identifier(text):
paul@101 121
paul@101 122
    "Return an output identifier for the given 'text'."
paul@101 123
paul@101 124
    return sha.new(encode(text, 'utf-8')).hexdigest()
paul@101 125
paul@101 126
def get_program(filter):
paul@101 127
paul@101 128
    "Return the program for the given 'filter'."
paul@101 129
paul@101 130
    if not filter in FILTERS:
paul@101 131
        return None
paul@101 132
    else:
paul@101 133
        return join(GRAPHVIZ_PATH, filter)
paul@101 134
paul@101 135
def transform_output(process, format, transforms):
paul@101 136
paul@101 137
    "Transform the output from 'process' as 'format' using 'transforms'."
paul@101 138
paul@101 139
    # No transformation can occur if the processor is missing.
paul@101 140
paul@101 141
    if not exists(XSLT_PROCESSOR):
paul@101 142
        return process
paul@101 143
paul@101 144
    # Chain transformation processors, each accepting the output of the
paul@101 145
    # preceding one, with the first accepting the initial Graphviz output.
paul@101 146
paul@101 147
    for transform in transforms:
paul@101 148
        stylesheet = TRANSFORMS.get(transform)
paul@101 149
paul@101 150
        # Ignore unrecognised or missing stylesheets.
paul@101 151
paul@101 152
        if not stylesheet or not exists(stylesheet):
paul@101 153
            continue
paul@101 154
paul@101 155
        # Invoke the processor, indicating standard input as the source
paul@101 156
        # document.
paul@101 157
        # Example: /usr/bin/dot /usr/local/share/diagram-tools/notugly.xsl -
paul@101 158
paul@101 159
        process = Popen(
paul@101 160
            [XSLT_PROCESSOR, stylesheet, "-"],
paul@101 161
            shell=False,
paul@101 162
            stdin=process.stdout,
paul@101 163
            stdout=PIPE,
paul@101 164
            stderr=PIPE,
paul@101 165
            close_fds=True)
paul@101 166
paul@101 167
    return process
paul@101 168
paul@101 169
def writefile(s, filename, compressed=False):
paul@101 170
paul@101 171
    "Write 's' to the file having 'filename'."
paul@101 172
paul@101 173
    if compressed:
paul@101 174
        f = gzip.open(filename, "w")
paul@101 175
    else:
paul@101 176
        f = open(filename, "w")
paul@101 177
paul@101 178
    try:
paul@101 179
        f.write(s)
paul@101 180
    finally:
paul@101 181
        f.close()
paul@101 182
paul@101 183
paul@101 184
paul@101 185
# Classes for interacting with Graphviz.
paul@101 186
paul@101 187
class GraphvizError(Exception):
paul@101 188
paul@101 189
    "An error produced when using Graphviz."
paul@101 190
paul@101 191
    def __init__(self, errors):
paul@101 192
        self.errors = errors
paul@101 193
paul@101 194
class Graphviz:
paul@101 195
paul@101 196
    "A Graphviz configuration for single or repeated invocation."
paul@101 197
paul@189 198
    def __init__(self, filter, text):
paul@101 199
paul@101 200
        """
paul@189 201
        Employ the given 'filter' to produce a graph from the given 'text'.
paul@101 202
        """
paul@101 203
paul@101 204
        self.filter = filter
paul@101 205
        self.text = text
paul@101 206
paul@101 207
    def call(self, format, transforms=None, filename=None):
paul@101 208
paul@101 209
        """
paul@101 210
        Invoke Graphviz to produce output in the given 'format'. Any
paul@101 211
        'transforms' are used to transform the output, if appropriate. Any
paul@101 212
        given 'filename' is used to write to a file.
paul@101 213
        """
paul@101 214
paul@101 215
        program = get_program(self.filter)
paul@101 216
paul@101 217
        # Generate uncompressed SVG for later compression.
paul@101 218
paul@101 219
        graphviz_format = format == "svgz" and "svg" or format
paul@101 220
paul@101 221
        # Indicate a filename for direct output for non-SVG formats.
paul@101 222
paul@101 223
        svg = format in SVG_FORMATS
paul@101 224
        options = filename and not svg and ["-o", filename] or []
paul@101 225
paul@101 226
        # Invoke the layout program, with the text to be provided on its
paul@101 227
        # standard input.
paul@101 228
        # Example: /usr/bin/dot -Tsvg -o filename
paul@101 229
paul@101 230
        start = end = Popen(
paul@101 231
            [program, '-T%s' % graphviz_format] + options,
paul@101 232
            shell=False,
paul@101 233
            stdin=PIPE,
paul@101 234
            stdout=PIPE,
paul@101 235
            stderr=PIPE)
paul@101 236
paul@101 237
        # Chain the invocation to transformations, if appropriate.
paul@101 238
paul@101 239
        if svg and transforms:
paul@101 240
            end = transform_output(start, format, transforms)
paul@101 241
paul@101 242
        # Send the graph to the filter.
paul@101 243
paul@101 244
        start.stdin.write(encode(self.text, 'utf-8'))
paul@101 245
paul@101 246
        if end is not start:
paul@101 247
            start.stdin.close()
paul@101 248
paul@101 249
        # Obtain the eventual output.
paul@101 250
paul@101 251
        (self.output, errors) = end.communicate()
paul@101 252
paul@203 253
        # Test for errors.
paul@203 254
paul@203 255
        if end.wait() != 0:
paul@203 256
            raise GraphvizError, errors
paul@203 257
paul@101 258
        # Obtain any metadata.
paul@101 259
paul@101 260
        if svg:
paul@101 261
            parser = MetadataParser()
paul@101 262
            self.metadata = parser.get_metadata(self.output, "svg")
paul@101 263
        elif format == "cmapx":
paul@101 264
            parser = MetadataParser()
paul@101 265
            self.metadata = parser.get_metadata(self.output, "map")
paul@101 266
        else:
paul@101 267
            self.metadata = {}
paul@101 268
paul@101 269
        # Write the file separately, if requested.
paul@101 270
paul@101 271
        if svg and filename:
paul@101 272
            writefile(self.get_output(), filename, format == "svgz")
paul@101 273
paul@101 274
    def get_metadata(self):
paul@101 275
        return self.metadata
paul@101 276
paul@101 277
    def get_output(self):
paul@101 278
        return self.output
paul@101 279
paul@189 280
    def get_inline_output(self):
paul@189 281
paul@189 282
        """
paul@189 283
        Return a string containing the document element, excluding XML
paul@189 284
        boilerplate.
paul@189 285
        """
paul@189 286
paul@189 287
        f = StringIO()
paul@189 288
        parser = DocumentSelector(f, "utf-8")
paul@189 289
paul@189 290
        try:
paul@189 291
            parser.parse_data(self.output)
paul@189 292
            return f.getvalue()
paul@189 293
        finally:
paul@189 294
            f.close()
paul@189 295
paul@101 296
# vim: tabstop=4 expandtab shiftwidth=4