source: graphvizplugin/tags/0.11-0.7.5/graphviz/graphviz.py

Last change on this file was 5900, checked in by Christian Boos, 14 years ago

GraphvizPlugin: it was not possible to use the plugin as a renderer.

We were still calling render_macro, now replaced by expand_macro.
Closes #4746.

This makes it possible to visualize dot files store in the repository with the svn:mime-type set to application/graphviz and to visualize attached dot files (when a suitable suffix is used, e.g. .graphviz).

  • Property svn:keywords set to Id HeadURL LastChangedRevision
File size: 24.4 KB
Line 
1"""
2$Id: graphviz.py 5900 2009-05-30 11:05:08Z cboos $
3$HeadURL: //trac-hacks.org/svn/graphvizplugin/tags/0.11-0.7.5/graphviz/graphviz.py $
4
5Copyright (c) 2005, 2006, 2008 Peter Kropf. All rights reserved.
6
7Module documentation goes here.
8"""
9
10
11
12__revision__  = '$LastChangedRevision: 5900 $'
13__id__        = '$Id: graphviz.py 5900 2009-05-30 11:05:08Z cboos $'
14__headurl__   = '$HeadURL: //trac-hacks.org/svn/graphvizplugin/tags/0.11-0.7.5/graphviz/graphviz.py $'
15__docformat__ = 'restructuredtext'
16__version__   = '0.7.5dev'
17
18
19import inspect
20import locale
21import os
22import re
23import sha
24import subprocess
25import sys
26
27from genshi.builder import Element, tag
28from genshi.core import Markup
29
30from trac.config import BoolOption, IntOption, Option
31from trac.core import *
32from trac.mimeview.api import Context, IHTMLPreviewRenderer, MIME_MAP
33from trac.util import escape
34from trac.util.text import to_unicode
35from trac.util.translation import _
36from trac.web.api import IRequestHandler
37from trac.wiki.api import IWikiMacroProvider
38from trac.wiki.formatter import extract_link
39
40
41class Graphviz(Component):
42    """
43    Graphviz (http://trac-hacks.org/wiki/GraphvizPlugin) provides
44    a plugin for Trac to render graphviz (http://www.graphviz.org/)
45    drawings within a Trac wiki page.
46    """
47    implements(IWikiMacroProvider, IHTMLPreviewRenderer, IRequestHandler)
48
49    # Available formats and processors, default first (dot/png)
50    Processors = ['dot', 'neato', 'twopi', 'circo', 'fdp']
51    Bitmap_Formats = ['png', 'jpg', 'gif']
52    Vector_Formats = ['svg', 'svgz']
53    Formats = Bitmap_Formats + Vector_Formats
54    Cmd_Paths = {
55        'linux2':   ['/usr/bin',
56                     '/usr/local/bin',],
57       
58        'win32':    ['c:\\Program Files\\Graphviz\\bin',
59                     'c:\\Program Files\\ATT\\Graphviz\\bin',
60                     ],
61       
62        'freebsd6': ['/usr/local/bin',
63                     ],
64       
65        'freebsd5': ['/usr/local/bin',
66                     ],
67       
68        'darwin':   ['/opt/local/bin',
69                     '/sw/bin',],
70       
71        }
72
73    # Note: the following options named "..._option" are those which need
74    #       some additional processing, see `_load_config()` below.
75
76    DEFAULT_CACHE_DIR = 'gvcache'
77
78    cache_dir_option = Option("graphviz", "cache_dir", DEFAULT_CACHE_DIR,
79            """The directory that will be used to cache the generated images.
80            Note that if different than the default (`%s`), this directory must
81            exist.
82            If not given as an absolute path, the path will be relative to
83            the Trac environment's directory.
84            """ % DEFAULT_CACHE_DIR)
85
86    encoding = Option("graphviz", "encoding", 'utf-8',
87            """The encoding which should be used for communicating with
88            Graphviz (should match `-Gcharset` if given).
89            """)
90
91    cmd_path = Option("graphviz", "cmd_path", '',
92            r"""Full path to the directory where the graphviz
93            programs are located. If not specified, the
94            default is `/usr/bin` on Linux,
95            `C:\Program Files\ATT\Graphviz\bin` on Windows and
96            `/usr/local/bin` on FreeBSD 6.
97            """)
98
99    out_format = Option("graphviz", "out_format", Formats[0],
100            """Graph output format. Valid formats are: png, jpg,
101            svg, svgz, gif. If not specified, the default is
102            png. This setting can be overrided on a per-graph
103            basis.
104            """)
105
106    processor = Option("graphviz", "processor", Processors[0],
107            """Graphviz default processor. Valid processors
108            are: dot, neato, twopi, fdp, circo. If not
109            specified, the default is dot. This setting can
110            be overrided on a per-graph basis.
111
112            !GraphvizMacro will verify that the default
113            processor is installed and will not work if it
114            is missing. All other processors are optional.
115            If any of the other processors are missing, a
116            warning message will be sent to the trac log and
117            !GraphvizMacro will continue to work.
118            """)
119
120    png_anti_alias = BoolOption("graphviz", "png_antialias", False,
121            """If this entry exists in the configuration file,
122            then PNG outputs will be antialiased.
123            Note that this requires `rsvg` to be installed.
124            """)
125
126    rsvg_path_option = Option("graphviz", "rsvg_path", "",
127            """Full path to the rsvg program (including the filename).
128            The default is `<cmd_path>/rsvg`.
129            """)
130
131    cache_manager = BoolOption("graphviz", "cache_manager", False,
132            """If this entry exists and set to true in the configuration file,
133            then the cache management logic will be invoked
134            and the cache_max_size, cache_min_size,
135            cache_max_count and cache_min_count must be
136            defined.
137            """)
138
139    cache_max_size = IntOption("graphviz", "cache_max_size", 1024*1024*10,
140            """The maximum size in bytes that the cache should
141            consume. This is the high watermark for disk space
142            used.
143            """)
144
145    cache_min_size = IntOption("graphviz", "cache_min_size", 1024*1024*5,
146            """When cleaning out the cache, remove files until
147            this size in bytes is used by the cache. This is
148            the low watermark for disk space used.
149            """)
150
151    cache_max_count = IntOption("graphviz", "cache_max_count", 2000,
152            """The maximum number of files that the cache should
153            contain. This is the high watermark for the
154            directory entry count.
155            """)
156
157    cache_min_count = IntOption("graphviz", "cache_min_count", 1500,
158            """The minimum number of files that the cache should
159            contain. This is the low watermark for the
160            directory entry count.
161            """)
162
163    dpi = IntOption('graphviz', 'default_graph_dpi', 96,
164            """Default dpi setting for graphviz, used during SVG to PNG
165            rasterization.
166            """)
167
168
169    def __init__(self):
170        self.log.info('version: %s - id: %s' % (__version__, str(__id__)))
171        #self.log.info('processors: %s' % str(Graphviz.Processors))
172        #self.log.info('formats: %s' % str(Graphviz.Formats))
173
174
175    # IHTMLPreviewRenderer methods
176
177    MIME_TYPES = ('application/graphviz')
178
179    def get_quality_ratio(self, mimetype):
180        if mimetype in self.MIME_TYPES:
181            return 2
182        return 0
183
184    def render(self, context, mimetype, content, filename=None, url=None):
185        ext = filename.split('.')[1]
186        name = ext == 'graphviz' and 'graphviz' or 'graphviz.%s' % ext
187        text = hasattr(content, 'read') and content.read() or content
188        return self.expand_macro(context, name, text)
189
190
191    # IRequestHandler methods
192
193    def match_request(self, req):
194        return req.path_info.startswith('/graphviz')
195
196
197    def process_request(self, req):
198        # check and load the configuration
199        errmsg = self._load_config()
200        if errmsg:
201            return self._error_div(errmsg)
202
203        pieces = [item for item in req.path_info.split('/graphviz') if item]
204
205        if pieces:
206            pieces = [item for item in pieces[0].split('/') if item]
207
208            if pieces:
209                name = pieces[0]
210                img_path = os.path.join(self.cache_dir, name)
211                return req.send_file(img_path)
212
213    # IWikiMacroProvider methods
214
215    def get_macros(self):
216        """Return an iterable that provides the names of the provided macros."""
217        self._load_config()
218        for p in ['.' + p for p in Graphviz.Processors] + ['']: 
219            for f in ['/' + f for f in Graphviz.Formats] + ['']:
220                yield 'graphviz%s%s' % (p, f)
221
222
223    def get_macro_description(self, name):
224        """
225        Return a plain text description of the macro with the
226        specified name. Only return a description for the base
227        graphviz macro. All the other variants (graphviz/png,
228        graphviz/svg, etc.) will have no description. This will
229        cleanup the WikiMacros page a bit.
230        """
231        if name == 'graphviz':
232            return inspect.getdoc(Graphviz)
233        else:
234            return None
235
236
237    def expand_macro(self, formatter_or_context, name, content):
238        """Return the HTML output of the macro.
239
240        :param formatter_or_context: a Formatter when called as a macro,
241               a Context when called by `GraphvizPlugin.render`
242
243        :param name: Wiki macro command that resulted in this method being
244               called. In this case, it should be 'graphviz', followed
245               (or not) by the processor name, then by an output
246               format, as following: graphviz.<processor>/<format>
247
248               Valid processor names are: dot, neato, twopi, circo,
249               and fdp.  The default is dot.
250
251               Valid output formats are: jpg, png, gif, svg and svgz.
252               The default is the value specified in the out_format
253               configuration parameter. If out_format is not specified
254               in the configuration, then the default is png.
255
256               examples: graphviz.dot/png   -> dot    png
257                         graphviz.neato/jpg -> neato  jpg
258                         graphviz.circo     -> circo  png
259                         graphviz/svg       -> dot    svg
260
261        :param content: The text the user entered for the macro to process.
262        """
263        # check and load the configuration
264        errmsg = self._load_config()
265        if errmsg:
266            return self._error_div(errmsg)
267
268        ## Extract processor and format from name
269        processor = out_format = None
270
271        # first try with the RegExp engine
272        try: 
273            m = re.match('graphviz\.?([a-z]*)\/?([a-z]*)', name)
274            (processor, out_format) = m.group(1, 2)
275
276        # or use the string.split method
277        except:
278            (d_sp, s_sp) = (name.split('.'), name.split('/'))
279            if len(d_sp) > 1:
280                s_sp = d_sp[1].split('/')
281                if len(s_sp) > 1:
282                    out_format = s_sp[1]
283                processor = s_sp[0]
284            elif len(s_sp) > 1:
285                out_format = s_sp[1]
286           
287        # assign default values, if instance ones are empty
288        if not out_format:
289            out_format = self.out_format
290        if not processor:
291            processor = self.processor
292
293        if processor in Graphviz.Processors:
294            proc_cmd = self.cmds[processor]
295
296        else:
297            self.log.error('render_macro: requested processor (%s) not found.' %
298                           processor)
299            return self._error_div('requested processor (%s) not found.' % 
300                                  processor)
301           
302        if out_format not in Graphviz.Formats:
303            self.log.error('render_macro: requested format (%s) not found.' %
304                           out_format)
305            return self._error_div(
306                    tag.p(_("Graphviz macro processor error: "
307                            "requested format (%(fmt)s) not valid.",
308                            fmt=out_format)))
309
310        encoded_cmd = (processor + unicode(self.processor_options)) \
311                .encode(self.encoding)
312        encoded_content = content.encode(self.encoding)
313        sha_key  = sha.new(encoded_cmd + encoded_content).hexdigest()
314        img_name = '%s.%s.%s' % (sha_key, processor, out_format)
315        # cache: hash.<dot>.<png>
316        img_path = os.path.join(self.cache_dir, img_name)
317        map_name = '%s.%s.map' % (sha_key, processor)
318        # cache: hash.<dot>.map
319        map_path = os.path.join(self.cache_dir, map_name)
320
321        # Check for URL="" presence in graph code
322        URL_in_graph = 'URL=' in content
323
324        # Create image if not in cache
325        if not os.path.exists(img_path):
326            self._clean_cache()
327
328            if URL_in_graph: # translate wiki TracLinks in URL
329                if isinstance(formatter_or_context, Context):
330                    context = formatter_or_context
331                else:
332                    context = formatter_or_context.context
333                content = self._expand_wiki_links(context, out_format, 
334                                                  content)
335                encoded_content = content.encode(self.encoding)
336
337            # Antialias PNGs with rsvg, if requested
338            if out_format == 'png' and self.png_anti_alias == True:
339                # 1. SVG output
340                failure, errmsg = self._launch(
341                        encoded_content, proc_cmd, '-Tsvg', 
342                        '-o%s.svg' % img_path, *self.processor_options)
343                if failure:
344                    return self._error_div(errmsg)
345
346                # 2. SVG to PNG rasterization
347                failure, errmsg = self._launch(
348                        None, self.rsvg_path, '--dpi-x=%d' % self.dpi,
349                        '--dpi-y=%d' % self.dpi, '%s.svg' % img_path, img_path)
350                if failure:
351                    return self._error_div(errmsg)
352           
353            else: # Render other image formats
354                failure, errmsg = self._launch(
355                        encoded_content, proc_cmd, '-T%s' % out_format,
356                        '-o%s' % img_path, *self.processor_options)
357                if failure:
358                    return self._error_div(errmsg)
359
360            # Generate a map file for binary formats
361            if URL_in_graph and out_format in Graphviz.Bitmap_Formats:
362
363                # Create the map if not in cache
364                if not os.path.exists(map_path):
365                    failure, errmsg = self._launch(
366                            encoded_content, proc_cmd, '-Tcmap',
367                            '-o%s' % map_path, *self.processor_options)
368                    if failure:
369                        return self._error_div(errmsg)
370
371        if errmsg: 
372            # there was a warning. Ideally we should be able to use
373            # `add_warning` here, but that's not possible as the warnings
374            # are already emitted at this point in the template processing
375            return self._error_div(errmsg)
376
377        # Generate HTML output
378        img_url = formatter_or_context.href.graphviz(img_name)
379        # for SVG(z)
380        if out_format in Graphviz.Vector_Formats:
381            try: # try to get SVG dimensions
382                f = open(img_path, 'r')
383                svg = f.readlines(1024) # don't read all
384                f.close()
385                svg = "".join(svg).replace('\n', '')
386                w = re.search('width="([0-9]+)(.*?)" ', svg)
387                h = re.search('height="([0-9]+)(.*?)"', svg)
388                (w_val, w_unit) = w.group(1,2)
389                (h_val, h_unit) = h.group(1,2)
390                # Graphviz seems to underestimate height/width for SVG images,
391                # so we have to adjust them.
392                # The correction factor seems to be constant.
393                w_val, h_val = [1.35 * float(x) for x in (w_val, h_val)]
394                width = unicode(w_val) + w_unit
395                height = unicode(h_val) + h_unit
396            except ValueError:
397                width = height = '100%'
398
399            # insert SVG, IE compatibility
400            return tag.object(
401                    tag.embed(src=img_url, type="image/svg+xml", 
402                              width=width, height=height),
403                    data=img_url, type="image/svg+xml", 
404                    width=width, height=height)
405
406        # for binary formats, add map
407        elif URL_in_graph and os.path.exists(map_path):
408            f = open(map_path, 'r')
409            map = f.readlines()
410            f.close()
411            map = "".join(map).replace('\n', '')
412            return tag(tag.map(Markup(map), id='G'+sha_key, name='G'+sha_key),
413                       tag.img(src=img_url, usemap="#G"+sha_key, 
414                               alt=_("GraphViz image")))
415        else:
416            return tag.img(src=img_url, alt=_("GraphViz image"))
417
418
419    # Private methods
420
421    def _expand_wiki_links(self, context, out_format, content):
422        """Expand TracLinks that follow all URL= patterns."""
423        def expand(match):
424            wiki_text = match.groups()[0] # TracLink ([1], source:file/, ...)
425            link = extract_link(self.env, context, wiki_text)
426            if isinstance(link, Element):
427                href = link.attrib.get('href')
428                name = link.children
429                description = link.attrib.get('title', '')
430            else:
431                href = wiki_text
432                description = None
433            if out_format == 'svg':
434                format = 'URL="javascript:window.parent.location.href=\'%s\'"'
435            else:
436                format = 'URL="%s"'
437            url = format % href
438            if description:
439                url += '\ntooltip="%s"' % description \
440                        .replace('"', '').replace('\n', '')
441            return url
442        return re.sub(r'URL="(.*?)"', expand, content)
443
444    def _load_config(self):
445        """Preprocess the graphviz trac.ini configuration."""
446
447        # if 'graphviz' not in self.config.sections():
448        # ... so what? the defaults might be good enough
449
450        # check for the cache_dir entry
451        self.cache_dir = self.cache_dir_option
452        if not self.cache_dir:
453            return _("The [graphviz] section is missing the cache_dir field.")
454
455        if not os.path.isabs(self.cache_dir):
456            self.cache_dir = os.path.join(self.env.path, self.cache_dir)
457
458        if not os.path.exists(self.cache_dir):
459            if self.cache_dir_option == self.DEFAULT_CACHE_DIR:
460                os.mkdir(self.cache_dir)
461            else:
462                return _("The cache_dir '%(path)s' doesn't exist, "
463                         "please create it.", path=self.cache_dir)
464
465        # Get optional configuration parameters from trac.ini.
466
467        # check for the cmd_path entry and setup the various command paths
468        cmd_paths = Graphviz.Cmd_Paths.get(sys.platform, [])
469
470        if self.cmd_path:
471            if not os.path.exists(self.cmd_path):
472                return _("The '[graphviz] cmd_path' configuration entry "
473                         "is set to '%(path)s' but that path does not exist.", 
474                         path=self.cmd_path)
475            cmd_paths = [self.cmd_path]
476
477        if not cmd_paths:
478            return _("The '[graphviz] cmd_path' configuration entry "
479                     "is not set and there is no default for %(platform)s.",
480                     platform=sys.platform)
481
482        self.cmds = {}
483        pname = self._find_cmd(self.processor, cmd_paths)
484        if not pname:
485            return _("The default processor '%(proc)s' was not found "
486                     "in '%(paths)s'.", proc=self.processor, paths=cmd_paths)
487
488        for name in Graphviz.Processors:
489            pname = self._find_cmd(name, cmd_paths)
490
491            if not pname:
492                self.log.warn('The %s program was not found. '
493                              'The graphviz/%s macro will be disabled.' %
494                              (pname, name))
495                Graphviz.Processors.remove(name)
496
497            self.cmds[name] = pname
498
499        if self.png_anti_alias:
500            self.rsvg_path = (self.rsvg_path_option or 
501                              self._find_cmd('rsvg', cmd_paths))
502            if not (self.rsvg_path and os.path.exists(self.rsvg_path)):
503                return _("The rsvg program is set to '%(path)s' but that path "
504                         "does not exist.", path=self.rsvg_path)
505
506        # get default graph/node/edge attributes
507        self.processor_options = []
508        defaults = [opt for opt in self.config.options('graphviz') 
509                    if opt[0].startswith('default_')]
510        for name, value in defaults:
511            for prefix, optkey in [
512                    ('default_graph_', '-G'), 
513                    ('default_node_', '-N'),
514                    ('default_edge_', '-E')]:
515                if name.startswith(prefix):
516                    self.processor_options.append("%s%s=%s" % 
517                            (optkey, name.replace(prefix,''), value))
518
519        # setup mimetypes to support the IHTMLPreviewRenderer interface
520        if 'graphviz' not in MIME_MAP:
521            MIME_MAP['graphviz'] = 'application/graphviz'
522        for processor in Graphviz.Processors:
523            if processor not in MIME_MAP:
524                MIME_MAP[processor] = 'application/graphviz'
525
526    def _launch(self, encoded_input, *args):
527        """Launch a process (cmd), and returns exitcode, stdout + stderr"""
528        # Note: subprocess.Popen doesn't support unicode options arguments
529        # (http://bugs.python.org/issue1759845) so we have to encode them.
530        # Anyway, dot expects utf-8 or the encoding specified with -Gcharset.
531        encoded_cmd = []
532        for arg in args:
533            if isinstance(arg, unicode):
534                arg = arg.encode(self.encoding, 'replace')
535            encoded_cmd.append(arg)
536        p = subprocess.Popen(encoded_cmd, stdin=subprocess.PIPE,
537                             stdout=subprocess.PIPE, stderr=subprocess.PIPE)
538        if encoded_input:
539            p.stdin.write(encoded_input)
540        p.stdin.close()
541        out = p.stdout.read()
542        err = p.stderr.read()
543        failure = p.wait() != 0
544        if failure or err or out:
545            return (failure, tag.p(tag.br(), _("The command:"), 
546                         tag.pre(repr(' '.join(encoded_cmd))), 
547                         (_("succeeded but emitted the following output:"),
548                          _("failed with the following output:"))[failure],
549                         out and tag.pre(repr(out)), 
550                         err and tag.pre(repr(err))))
551        else:
552            return (False, None)
553
554    def _error_div(self, msg):
555        """Display msg in an error box, using Trac style."""
556        if isinstance(msg, str):
557            msg = to_unicode(msg)
558        self.log.error(msg)
559        if isinstance(msg, unicode):
560            msg = tag.pre(escape(msg))
561        return tag.div(
562                tag.strong(_("Graphviz macro processor has detected an error. "
563                             "Please fix the problem before continuing.")),
564                msg, class_="system-message")
565
566    def _clean_cache(self):
567        """
568        The cache manager (clean_cache) is an attempt at keeping the
569        cache directory under control. When the cache manager
570        determines that it should clean up the cache, it will delete
571        files based on the file access time. The files that were least
572        accessed will be deleted first.
573
574        The graphviz section of the trac configuration file should
575        have an entry called cache_manager to enable the cache
576        cleaning code. If it does, then the cache_max_size,
577        cache_min_size, cache_max_count and cache_min_count entries
578        must also be there.
579        """
580
581        if self.cache_manager:
582
583            # os.stat gives back a tuple with: st_mode(0), st_ino(1),
584            # st_dev(2), st_nlink(3), st_uid(4), st_gid(5),
585            # st_size(6), st_atime(7), st_mtime(8), st_ctime(9)
586
587            entry_list = {}
588            atime_list = {}
589            size_list = {}
590            count = 0
591            size = 0
592
593            for name in os.listdir(self.cache_dir):
594                #self.log.debug('clean_cache.entry: %s' % name)
595                entry_list[name] = os.stat(os.path.join(self.cache_dir, name))
596
597                atime_list.setdefault(entry_list[name][7], []).append(name)
598                count = count + 1
599
600                size_list.setdefault(entry_list[name][6], []).append(name)
601                size = size + entry_list[name][6]
602
603            atime_keys = atime_list.keys()
604            atime_keys.sort()
605
606            #self.log.debug('clean_cache.atime_keys: %s' % atime_keys)
607            #self.log.debug('clean_cache.count: %d' % count)
608            #self.log.debug('clean_cache.size: %d' % size)
609       
610            # In the spirit of keeping the code fairly simple, the
611            # clearing out of files from the cache directory may
612            # result in the count dropping below cache_min_count if
613            # multiple entries are have the same last access
614            # time. Same for cache_min_size.
615            if count > self.cache_max_count or size > self.cache_max_size:
616                while atime_keys and (self.cache_min_count < count or 
617                                      self.cache_min_size < size):
618                    key = atime_keys.pop(0)
619                    for file in atime_list[key]:
620                        os.unlink(os.path.join(self.cache_dir, file))
621                        count = count - 1
622                        size = size - entry_list[file][6]
623
624    def _find_cmd(self, cmd, paths):
625        exe_suffix = ''
626        if sys.platform == 'win32':
627            exe_suffix = '.exe'
628
629        for path in paths:
630            p = os.path.join(path, cmd) + exe_suffix
631            if os.path.exists(p):
632                return p
Note: See TracBrowser for help on using the repository browser.