source: trunk/adm/website/sphinxext/inheritance_diagram.py @ 50

Last change on this file since 50 was 36, checked in by pinsard, 13 years ago

add end users website production

File size: 13.3 KB
Line 
1"""
2Defines a docutils directive for inserting inheritance diagrams.
3
4Provide the directive with one or more classes or modules (separated
5by whitespace).  For modules, all of the classes in that module will
6be used.
7
8Example::
9
10   Given the following classes:
11
12   class A: pass
13   class B(A): pass
14   class C(A): pass
15   class D(B, C): pass
16   class E(B): pass
17
18   .. inheritance-diagram: D E
19
20   Produces a graph like the following:
21
22               A
23              / \
24             B   C
25            / \ /
26           E   D
27
28The graph is inserted as a PNG+image map into HTML and a PDF in
29LaTeX.
30"""
31
32import inspect
33import os
34import re
35import subprocess
36try:
37    from hashlib import md5
38except ImportError:
39    from md5 import md5
40
41from docutils.nodes import Body, Element
42from docutils.parsers.rst import directives
43from sphinx.roles import xfileref_role
44
45def my_import(name):
46    """Module importer - taken from the python documentation.
47
48    This function allows importing names with dots in them."""
49   
50    mod = __import__(name)
51    components = name.split('.')
52    for comp in components[1:]:
53        mod = getattr(mod, comp)
54    return mod
55
56class DotException(Exception):
57    pass
58
59class InheritanceGraph(object):
60    """
61    Given a list of classes, determines the set of classes that
62    they inherit from all the way to the root "object", and then
63    is able to generate a graphviz dot graph from them.
64    """
65    def __init__(self, class_names, show_builtins=False):
66        """
67        *class_names* is a list of child classes to show bases from.
68
69        If *show_builtins* is True, then Python builtins will be shown
70        in the graph.
71        """
72        self.class_names = class_names
73        self.classes = self._import_classes(class_names)
74        self.all_classes = self._all_classes(self.classes)
75        if len(self.all_classes) == 0:
76            raise ValueError("No classes found for inheritance diagram")
77        self.show_builtins = show_builtins
78
79    py_sig_re = re.compile(r'''^([\w.]*\.)?    # class names
80                           (\w+)  \s* $        # optionally arguments
81                           ''', re.VERBOSE)
82
83    def _import_class_or_module(self, name):
84        """
85        Import a class using its fully-qualified *name*.
86        """
87        try:
88            path, base = self.py_sig_re.match(name).groups()
89        except:
90            raise ValueError(
91                "Invalid class or module '%s' specified for inheritance diagram" % name)
92        fullname = (path or '') + base
93        path = (path and path.rstrip('.'))
94        if not path:
95            path = base
96        try:
97            module = __import__(path, None, None, [])
98            # We must do an import of the fully qualified name.  Otherwise if a
99            # subpackage 'a.b' is requested where 'import a' does NOT provide
100            # 'a.b' automatically, then 'a.b' will not be found below.  This
101            # second call will force the equivalent of 'import a.b' to happen
102            # after the top-level import above.
103            my_import(fullname)
104           
105        except ImportError:
106            raise ValueError(
107                "Could not import class or module '%s' specified for inheritance diagram" % name)
108
109        try:
110            todoc = module
111            for comp in fullname.split('.')[1:]:
112                todoc = getattr(todoc, comp)
113        except AttributeError:
114            raise ValueError(
115                "Could not find class or module '%s' specified for inheritance diagram" % name)
116
117        # If a class, just return it
118        if inspect.isclass(todoc):
119            return [todoc]
120        elif inspect.ismodule(todoc):
121            classes = []
122            for cls in todoc.__dict__.values():
123                if inspect.isclass(cls) and cls.__module__ == todoc.__name__:
124                    classes.append(cls)
125            return classes
126        raise ValueError(
127            "'%s' does not resolve to a class or module" % name)
128
129    def _import_classes(self, class_names):
130        """
131        Import a list of classes.
132        """
133        classes = []
134        for name in class_names:
135            classes.extend(self._import_class_or_module(name))
136        return classes
137
138    def _all_classes(self, classes):
139        """
140        Return a list of all classes that are ancestors of *classes*.
141        """
142        all_classes = {}
143
144        def recurse(cls):
145            all_classes[cls] = None
146            for c in cls.__bases__:
147                if c not in all_classes:
148                    recurse(c)
149
150        for cls in classes:
151            recurse(cls)
152
153        return all_classes.keys()
154
155    def class_name(self, cls, parts=0):
156        """
157        Given a class object, return a fully-qualified name.  This
158        works for things I've tested in matplotlib so far, but may not
159        be completely general.
160        """
161        module = cls.__module__
162        if module == '__builtin__':
163            fullname = cls.__name__
164        else:
165            fullname = "%s.%s" % (module, cls.__name__)
166        if parts == 0:
167            return fullname
168        name_parts = fullname.split('.')
169        return '.'.join(name_parts[-parts:])
170
171    def get_all_class_names(self):
172        """
173        Get all of the class names involved in the graph.
174        """
175        return [self.class_name(x) for x in self.all_classes]
176
177    # These are the default options for graphviz
178    default_graph_options = {
179        "rankdir": "LR",
180        "size": '"8.0, 12.0"'
181        }
182    default_node_options = {
183        "shape": "box",
184        "fontsize": 10,
185        "height": 0.25,
186        "fontname": "Vera Sans, DejaVu Sans, Liberation Sans, Arial, Helvetica, sans",
187        "style": '"setlinewidth(0.5)"'
188        }
189    default_edge_options = {
190        "arrowsize": 0.5,
191        "style": '"setlinewidth(0.5)"'
192        }
193
194    def _format_node_options(self, options):
195        return ','.join(["%s=%s" % x for x in options.items()])
196    def _format_graph_options(self, options):
197        return ''.join(["%s=%s;\n" % x for x in options.items()])
198
199    def generate_dot(self, fd, name, parts=0, urls={},
200                     graph_options={}, node_options={},
201                     edge_options={}):
202        """
203        Generate a graphviz dot graph from the classes that
204        were passed in to __init__.
205
206        *fd* is a Python file-like object to write to.
207
208        *name* is the name of the graph
209
210        *urls* is a dictionary mapping class names to http urls
211
212        *graph_options*, *node_options*, *edge_options* are
213        dictionaries containing key/value pairs to pass on as graphviz
214        properties.
215        """
216        g_options = self.default_graph_options.copy()
217        g_options.update(graph_options)
218        n_options = self.default_node_options.copy()
219        n_options.update(node_options)
220        e_options = self.default_edge_options.copy()
221        e_options.update(edge_options)
222
223        fd.write('digraph %s {\n' % name)
224        fd.write(self._format_graph_options(g_options))
225
226        for cls in self.all_classes:
227            if not self.show_builtins and cls in __builtins__.values():
228                continue
229
230            name = self.class_name(cls, parts)
231
232            # Write the node
233            this_node_options = n_options.copy()
234            url = urls.get(self.class_name(cls))
235            if url is not None:
236                this_node_options['URL'] = '"%s"' % url
237            fd.write('  "%s" [%s];\n' %
238                     (name, self._format_node_options(this_node_options)))
239
240            # Write the edges
241            for base in cls.__bases__:
242                if not self.show_builtins and base in __builtins__.values():
243                    continue
244
245                base_name = self.class_name(base, parts)
246                fd.write('  "%s" -> "%s" [%s];\n' %
247                         (base_name, name,
248                          self._format_node_options(e_options)))
249        fd.write('}\n')
250
251    def run_dot(self, args, name, parts=0, urls={},
252                graph_options={}, node_options={}, edge_options={}):
253        """
254        Run graphviz 'dot' over this graph, returning whatever 'dot'
255        writes to stdout.
256
257        *args* will be passed along as commandline arguments.
258
259        *name* is the name of the graph
260
261        *urls* is a dictionary mapping class names to http urls
262
263        Raises DotException for any of the many os and
264        installation-related errors that may occur.
265        """
266        try:
267            dot = subprocess.Popen(['dot'] + list(args),
268                                   stdin=subprocess.PIPE, stdout=subprocess.PIPE,
269                                   close_fds=True)
270        except OSError:
271            raise DotException("Could not execute 'dot'.  Are you sure you have 'graphviz' installed?")
272        except ValueError:
273            raise DotException("'dot' called with invalid arguments")
274        except:
275            raise DotException("Unexpected error calling 'dot'")
276
277        self.generate_dot(dot.stdin, name, parts, urls, graph_options,
278                          node_options, edge_options)
279        dot.stdin.close()
280        result = dot.stdout.read()
281        returncode = dot.wait()
282        if returncode != 0:
283            raise DotException("'dot' returned the errorcode %d" % returncode)
284        return result
285
286class inheritance_diagram(Body, Element):
287    """
288    A docutils node to use as a placeholder for the inheritance
289    diagram.
290    """
291    pass
292
293def inheritance_diagram_directive(name, arguments, options, content, lineno,
294                                  content_offset, block_text, state,
295                                  state_machine):
296    """
297    Run when the inheritance_diagram directive is first encountered.
298    """
299    node = inheritance_diagram()
300
301    class_names = arguments
302
303    # Create a graph starting with the list of classes
304    graph = InheritanceGraph(class_names)
305
306    # Create xref nodes for each target of the graph's image map and
307    # add them to the doc tree so that Sphinx can resolve the
308    # references to real URLs later.  These nodes will eventually be
309    # removed from the doctree after we're done with them.
310    for name in graph.get_all_class_names():
311        refnodes, x = xfileref_role(
312            'class', ':class:`%s`' % name, name, 0, state)
313        node.extend(refnodes)
314    # Store the graph object so we can use it to generate the
315    # dot file later
316    node['graph'] = graph
317    # Store the original content for use as a hash
318    node['parts'] = options.get('parts', 0)
319    node['content'] = " ".join(class_names)
320    return [node]
321
322def get_graph_hash(node):
323    return md5(node['content'] + str(node['parts'])).hexdigest()[-10:]
324
325def html_output_graph(self, node):
326    """
327    Output the graph for HTML.  This will insert a PNG with clickable
328    image map.
329    """
330    graph = node['graph']
331    parts = node['parts']
332
333    graph_hash = get_graph_hash(node)
334    name = "inheritance%s" % graph_hash
335    path = '_images'
336    dest_path = os.path.join(setup.app.builder.outdir, path)
337    if not os.path.exists(dest_path):
338        os.makedirs(dest_path)
339    png_path = os.path.join(dest_path, name + ".png")
340    path = setup.app.builder.imgpath
341
342    # Create a mapping from fully-qualified class names to URLs.
343    urls = {}
344    for child in node:
345        if child.get('refuri') is not None:
346            urls[child['reftitle']] = child.get('refuri')
347        elif child.get('refid') is not None:
348            urls[child['reftitle']] = '#' + child.get('refid')
349
350    # These arguments to dot will save a PNG file to disk and write
351    # an HTML image map to stdout.
352    image_map = graph.run_dot(['-Tpng', '-o%s' % png_path, '-Tcmapx'],
353                              name, parts, urls)
354    return ('<img src="%s/%s.png" usemap="#%s" class="inheritance"/>%s' %
355            (path, name, name, image_map))
356
357def latex_output_graph(self, node):
358    """
359    Output the graph for LaTeX.  This will insert a PDF.
360    """
361    graph = node['graph']
362    parts = node['parts']
363
364    graph_hash = get_graph_hash(node)
365    name = "inheritance%s" % graph_hash
366    dest_path = os.path.abspath(os.path.join(setup.app.builder.outdir, '_images'))
367    if not os.path.exists(dest_path):
368        os.makedirs(dest_path)
369    pdf_path = os.path.abspath(os.path.join(dest_path, name + ".pdf"))
370
371    graph.run_dot(['-Tpdf', '-o%s' % pdf_path],
372                  name, parts, graph_options={'size': '"6.0,6.0"'})
373    return '\n\\includegraphics{%s}\n\n' % pdf_path
374
375def visit_inheritance_diagram(inner_func):
376    """
377    This is just a wrapper around html/latex_output_graph to make it
378    easier to handle errors and insert warnings.
379    """
380    def visitor(self, node):
381        try:
382            content = inner_func(self, node)
383        except DotException, e:
384            # Insert the exception as a warning in the document
385            warning = self.document.reporter.warning(str(e), line=node.line)
386            warning.parent = node
387            node.children = [warning]
388        else:
389            source = self.document.attributes['source']
390            self.body.append(content)
391            node.children = []
392    return visitor
393
394def do_nothing(self, node):
395    pass
396
397def setup(app):
398    setup.app = app
399    setup.confdir = app.confdir
400
401    app.add_node(
402        inheritance_diagram,
403        latex=(visit_inheritance_diagram(latex_output_graph), do_nothing),
404        html=(visit_inheritance_diagram(html_output_graph), do_nothing))
405    app.add_directive(
406        'inheritance-diagram', inheritance_diagram_directive,
407        False, (1, 100, 0), parts = directives.nonnegative_int)
Note: See TracBrowser for help on using the repository browser.