source: graphvizplugin/branches/1.0/graphviz/graphviz.py

Last change on this file was 17908, checked in by Ryan J Ollos, 3 years ago

Fix metadata and version numbers

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