1 | """ |
---|
2 | Defines a docutils directive for inserting inheritance diagrams. |
---|
3 | |
---|
4 | Provide the directive with one or more classes or modules (separated |
---|
5 | by whitespace). For modules, all of the classes in that module will |
---|
6 | be used. |
---|
7 | |
---|
8 | Example:: |
---|
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 | |
---|
28 | The graph is inserted as a PNG+image map into HTML and a PDF in |
---|
29 | LaTeX. |
---|
30 | """ |
---|
31 | |
---|
32 | import inspect |
---|
33 | import os |
---|
34 | import re |
---|
35 | import subprocess |
---|
36 | try: |
---|
37 | from hashlib import md5 |
---|
38 | except ImportError: |
---|
39 | from md5 import md5 |
---|
40 | |
---|
41 | from docutils.nodes import Body, Element |
---|
42 | from docutils.parsers.rst import directives |
---|
43 | from sphinx.roles import xfileref_role |
---|
44 | |
---|
45 | def 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 | |
---|
56 | class DotException(Exception): |
---|
57 | pass |
---|
58 | |
---|
59 | class 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 | |
---|
286 | class inheritance_diagram(Body, Element): |
---|
287 | """ |
---|
288 | A docutils node to use as a placeholder for the inheritance |
---|
289 | diagram. |
---|
290 | """ |
---|
291 | pass |
---|
292 | |
---|
293 | def 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 | |
---|
322 | def get_graph_hash(node): |
---|
323 | return md5(node['content'] + str(node['parts'])).hexdigest()[-10:] |
---|
324 | |
---|
325 | def 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 | |
---|
357 | def 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 | |
---|
375 | def 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 | |
---|
394 | def do_nothing(self, node): |
---|
395 | pass |
---|
396 | |
---|
397 | def 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) |
---|