Add Python opcode generation
[ganeti-local] / lib / build / sphinx_ext.py
1 #
2 #
3
4 # Copyright (C) 2011, 2012, 2013 Google Inc.
5 #
6 # This program is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 2 of the License, or
9 # (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful, but
12 # WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14 # General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
19 # 02110-1301, USA.
20
21
22 """Sphinx extension for building opcode documentation.
23
24 """
25
26 import re
27 from cStringIO import StringIO
28
29 import docutils.statemachine
30 import docutils.nodes
31 import docutils.utils
32 import docutils.parsers.rst
33
34 import sphinx.errors
35 import sphinx.util.compat
36 import sphinx.roles
37 import sphinx.addnodes
38
39 s_compat = sphinx.util.compat
40
41 try:
42   # Access to a protected member of a client class
43   # pylint: disable=W0212
44   orig_manpage_role = docutils.parsers.rst.roles._roles["manpage"]
45 except (AttributeError, ValueError, KeyError), err:
46   # Normally the "manpage" role is registered by sphinx/roles.py
47   raise Exception("Can't find reST role named 'manpage': %s" % err)
48
49 from ganeti import constants
50 from ganeti import compat
51 from ganeti import errors
52 from ganeti import utils
53 from ganeti import opcodes
54 from ganeti import ht
55 from ganeti import rapi
56 from ganeti import luxi
57 from ganeti import objects
58 from ganeti import http
59 from ganeti import _autoconf
60
61 import ganeti.rapi.rlib2 # pylint: disable=W0611
62 import ganeti.rapi.connector # pylint: disable=W0611
63
64
65 #: Regular expression for man page names
66 _MAN_RE = re.compile(r"^(?P<name>[-\w_]+)\((?P<section>\d+)\)$")
67
68 _TAB_WIDTH = 2
69
70 RAPI_URI_ENCODE_RE = re.compile("[^_a-z0-9]+", re.I)
71
72
73 class ReSTError(Exception):
74   """Custom class for generating errors in Sphinx.
75
76   """
77
78
79 def _GetCommonParamNames():
80   """Builds a list of parameters common to all opcodes.
81
82   """
83   names = set(map(compat.fst, opcodes.OpCode.OP_PARAMS))
84
85   # The "depends" attribute should be listed
86   names.remove(opcodes.DEPEND_ATTR)
87
88   return names
89
90
91 COMMON_PARAM_NAMES = _GetCommonParamNames()
92
93 #: Namespace for evaluating expressions
94 EVAL_NS = dict(compat=compat, constants=constants, utils=utils, errors=errors,
95                rlib2=rapi.rlib2, luxi=luxi, rapi=rapi, objects=objects,
96                http=http)
97
98 # Constants documentation for man pages
99 CV_ECODES_DOC = "ecodes"
100 # We don't care about the leak of variables _, name and doc here.
101 # pylint: disable=W0621
102 CV_ECODES_DOC_LIST = [(name, doc) for (_, name, doc) in constants.CV_ALL_ECODES]
103 DOCUMENTED_CONSTANTS = {
104   CV_ECODES_DOC: CV_ECODES_DOC_LIST,
105   }
106
107
108 class OpcodeError(sphinx.errors.SphinxError):
109   category = "Opcode error"
110
111
112 def _SplitOption(text):
113   """Split simple option list.
114
115   @type text: string
116   @param text: Options, e.g. "foo, bar, baz"
117
118   """
119   return [i.strip(",").strip() for i in text.split()]
120
121
122 def _ParseAlias(text):
123   """Parse simple assignment option.
124
125   @type text: string
126   @param text: Assignments, e.g. "foo=bar, hello=world"
127   @rtype: dict
128
129   """
130   result = {}
131
132   for part in _SplitOption(text):
133     if "=" not in part:
134       raise OpcodeError("Invalid option format, missing equal sign")
135
136     (name, value) = part.split("=", 1)
137
138     result[name.strip()] = value.strip()
139
140   return result
141
142
143 def _BuildOpcodeParams(op_id, include, exclude, alias):
144   """Build opcode parameter documentation.
145
146   @type op_id: string
147   @param op_id: Opcode ID
148
149   """
150   op_cls = opcodes.OP_MAPPING[op_id]
151
152   params_with_alias = \
153     utils.NiceSort([(alias.get(name, name), name, default, test, doc)
154                     for (name, default, test, doc) in op_cls.GetAllParams()],
155                    key=compat.fst)
156
157   for (rapi_name, name, default, test, doc) in params_with_alias:
158     # Hide common parameters if not explicitly included
159     if (name in COMMON_PARAM_NAMES and
160         (not include or name not in include)):
161       continue
162     if exclude is not None and name in exclude:
163       continue
164     if include is not None and name not in include:
165       continue
166
167     has_default = default is not None or default is not ht.NoDefault
168     has_test = test is not None or test is not ht.NoType
169
170     buf = StringIO()
171     buf.write("``%s``" % (rapi_name,))
172     if has_default or has_test:
173       buf.write(" (")
174       if has_default:
175         buf.write("defaults to ``%s``" % (default,))
176         if has_test:
177           buf.write(", ")
178       if has_test:
179         buf.write("must be ``%s``" % (test,))
180       buf.write(")")
181     yield buf.getvalue()
182
183     # Add text
184     for line in doc.splitlines():
185       yield "  %s" % line
186
187
188 def _BuildOpcodeResult(op_id):
189   """Build opcode result documentation.
190
191   @type op_id: string
192   @param op_id: Opcode ID
193
194   """
195   op_cls = opcodes.OP_MAPPING[op_id]
196
197   result_fn = getattr(op_cls, "OP_RESULT", None)
198
199   if not result_fn:
200     raise OpcodeError("Opcode '%s' has no result description" % op_id)
201
202   return "``%s``" % result_fn
203
204
205 class OpcodeParams(s_compat.Directive):
206   """Custom directive for opcode parameters.
207
208   See also <http://docutils.sourceforge.net/docs/howto/rst-directives.html>.
209
210   """
211   has_content = False
212   required_arguments = 1
213   optional_arguments = 0
214   final_argument_whitespace = False
215   option_spec = dict(include=_SplitOption, exclude=_SplitOption,
216                      alias=_ParseAlias)
217
218   def run(self):
219     op_id = self.arguments[0]
220     include = self.options.get("include", None)
221     exclude = self.options.get("exclude", None)
222     alias = self.options.get("alias", {})
223
224     path = op_id
225     include_text = "\n\n".join(_BuildOpcodeParams(op_id,
226                                                   include,
227                                                   exclude,
228                                                   alias))
229
230     # Inject into state machine
231     include_lines = docutils.statemachine.string2lines(include_text, _TAB_WIDTH,
232                                                        convert_whitespace=1)
233     self.state_machine.insert_input(include_lines, path)
234
235     return []
236
237
238 class OpcodeResult(s_compat.Directive):
239   """Custom directive for opcode result.
240
241   See also <http://docutils.sourceforge.net/docs/howto/rst-directives.html>.
242
243   """
244   has_content = False
245   required_arguments = 1
246   optional_arguments = 0
247   final_argument_whitespace = False
248
249   def run(self):
250     op_id = self.arguments[0]
251
252     path = op_id
253     include_text = _BuildOpcodeResult(op_id)
254
255     # Inject into state machine
256     include_lines = docutils.statemachine.string2lines(include_text, _TAB_WIDTH,
257                                                        convert_whitespace=1)
258     self.state_machine.insert_input(include_lines, path)
259
260     return []
261
262
263 def PythonEvalRole(role, rawtext, text, lineno, inliner,
264                    options={}, content=[]):
265   """Custom role to evaluate Python expressions.
266
267   The expression's result is included as a literal.
268
269   """
270   # pylint: disable=W0102,W0613,W0142
271   # W0102: Dangerous default value as argument
272   # W0142: Used * or ** magic
273   # W0613: Unused argument
274
275   code = docutils.utils.unescape(text, restore_backslashes=True)
276
277   try:
278     result = eval(code, EVAL_NS)
279   except Exception, err: # pylint: disable=W0703
280     msg = inliner.reporter.error("Failed to evaluate %r: %s" % (code, err),
281                                  line=lineno)
282     return ([inliner.problematic(rawtext, rawtext, msg)], [msg])
283
284   node = docutils.nodes.literal("", unicode(result), **options)
285
286   return ([node], [])
287
288
289 class PythonAssert(s_compat.Directive):
290   """Custom directive for writing assertions.
291
292   The content must be a valid Python expression. If its result does not
293   evaluate to C{True}, the assertion fails.
294
295   """
296   has_content = True
297   required_arguments = 0
298   optional_arguments = 0
299   final_argument_whitespace = False
300
301   def run(self):
302     # Handle combinations of Sphinx and docutils not providing the wanted method
303     if hasattr(self, "assert_has_content"):
304       self.assert_has_content()
305     else:
306       assert self.content
307
308     code = "\n".join(self.content)
309
310     try:
311       result = eval(code, EVAL_NS)
312     except Exception, err:
313       raise self.error("Failed to evaluate %r: %s" % (code, err))
314
315     if not result:
316       raise self.error("Assertion failed: %s" % (code, ))
317
318     return []
319
320
321 def BuildQueryFields(fields):
322   """Build query fields documentation.
323
324   @type fields: dict (field name as key, field details as value)
325
326   """
327   defs = [(fdef.name, fdef.doc)
328            for (_, (fdef, _, _, _)) in utils.NiceSort(fields.items(),
329                                                       key=compat.fst)]
330   return BuildValuesDoc(defs)
331
332
333 def BuildValuesDoc(values):
334   """Builds documentation for a list of values
335
336   @type values: list of tuples in the form (value, documentation)
337
338   """
339   for name, doc in values:
340     assert len(doc.splitlines()) == 1
341     yield "``%s``" % (name,)
342     yield "  %s" % (doc,)
343
344
345 def _ManPageNodeClass(*args, **kwargs):
346   """Generates a pending XRef like a ":doc:`...`" reference.
347
348   """
349   # Type for sphinx/environment.py:BuildEnvironment.resolve_references
350   kwargs["reftype"] = "doc"
351
352   # Force custom title
353   kwargs["refexplicit"] = True
354
355   return sphinx.addnodes.pending_xref(*args, **kwargs)
356
357
358 class _ManPageXRefRole(sphinx.roles.XRefRole):
359   def __init__(self):
360     """Initializes this class.
361
362     """
363     sphinx.roles.XRefRole.__init__(self, nodeclass=_ManPageNodeClass,
364                                    warn_dangling=True)
365
366     assert not hasattr(self, "converted"), \
367       "Sphinx base class gained an attribute named 'converted'"
368
369     self.converted = None
370
371   def process_link(self, env, refnode, has_explicit_title, title, target):
372     """Specialization for man page links.
373
374     """
375     if has_explicit_title:
376       raise ReSTError("Setting explicit title is not allowed for man pages")
377
378     # Check format and extract name and section
379     m = _MAN_RE.match(title)
380     if not m:
381       raise ReSTError("Man page reference '%s' does not match regular"
382                       " expression '%s'" % (title, _MAN_RE.pattern))
383
384     name = m.group("name")
385     section = int(m.group("section"))
386
387     wanted_section = _autoconf.MAN_PAGES.get(name, None)
388
389     if not (wanted_section is None or wanted_section == section):
390       raise ReSTError("Referenced man page '%s' has section number %s, but the"
391                       " reference uses section %s" %
392                       (name, wanted_section, section))
393
394     self.converted = bool(wanted_section is not None and
395                           env.app.config.enable_manpages)
396
397     if self.converted:
398       # Create link to known man page
399       return (title, "man-%s" % name)
400     else:
401       # No changes
402       return (title, target)
403
404
405 def _ManPageRole(typ, rawtext, text, lineno, inliner, # pylint: disable=W0102
406                  options={}, content=[]):
407   """Custom role for man page references.
408
409   Converts man pages to links if enabled during the build.
410
411   """
412   xref = _ManPageXRefRole()
413
414   assert ht.TNone(xref.converted)
415
416   # Check if it's a known man page
417   try:
418     result = xref(typ, rawtext, text, lineno, inliner,
419                   options=options, content=content)
420   except ReSTError, err:
421     msg = inliner.reporter.error(str(err), line=lineno)
422     return ([inliner.problematic(rawtext, rawtext, msg)], [msg])
423
424   assert ht.TBool(xref.converted)
425
426   # Return if the conversion was successful (i.e. the man page was known and
427   # conversion was enabled)
428   if xref.converted:
429     return result
430
431   # Fallback if man page links are disabled or an unknown page is referenced
432   return orig_manpage_role(typ, rawtext, text, lineno, inliner,
433                            options=options, content=content)
434
435
436 def _EncodeRapiResourceLink(method, uri):
437   """Encodes a RAPI resource URI for use as a link target.
438
439   """
440   parts = [RAPI_URI_ENCODE_RE.sub("-", uri.lower()).strip("-")]
441
442   if method is not None:
443     parts.append(method.lower())
444
445   return "rapi-res-%s" % "+".join(filter(None, parts))
446
447
448 def _MakeRapiResourceLink(method, uri):
449   """Generates link target name for RAPI resource.
450
451   """
452   if uri in ["/", "/2"]:
453     # Don't link these
454     return None
455
456   elif uri == "/version":
457     return _EncodeRapiResourceLink(method, uri)
458
459   elif uri.startswith("/2/"):
460     return _EncodeRapiResourceLink(method, uri[len("/2/"):])
461
462   else:
463     raise ReSTError("Unhandled URI '%s'" % uri)
464
465
466 def _GetHandlerMethods(handler):
467   """Returns list of HTTP methods supported by handler class.
468
469   @type handler: L{rapi.baserlib.ResourceBase}
470   @param handler: Handler class
471   @rtype: list of strings
472
473   """
474   return sorted(method
475                 for (method, op_attr, _, _) in rapi.baserlib.OPCODE_ATTRS
476                 # Only if handler supports method
477                 if hasattr(handler, method) or hasattr(handler, op_attr))
478
479
480 def _DescribeHandlerAccess(handler, method):
481   """Returns textual description of required RAPI permissions.
482
483   @type handler: L{rapi.baserlib.ResourceBase}
484   @param handler: Handler class
485   @type method: string
486   @param method: HTTP method (e.g. L{http.HTTP_GET})
487   @rtype: string
488
489   """
490   access = rapi.baserlib.GetHandlerAccess(handler, method)
491
492   if access:
493     return utils.CommaJoin(sorted(access))
494   else:
495     return "*(none)*"
496
497
498 class _RapiHandlersForDocsHelper(object):
499   @classmethod
500   def Build(cls):
501     """Returns dictionary of resource handlers.
502
503     """
504     resources = \
505       rapi.connector.GetHandlers("[node_name]", "[instance_name]",
506                                  "[group_name]", "[network_name]", "[job_id]",
507                                  "[disk_index]", "[resource]",
508                                  translate=cls._TranslateResourceUri)
509
510     return resources
511
512   @classmethod
513   def _TranslateResourceUri(cls, *args):
514     """Translates a resource URI for use in documentation.
515
516     @see: L{rapi.connector.GetHandlers}
517
518     """
519     return "".join(map(cls._UriPatternToString, args))
520
521   @staticmethod
522   def _UriPatternToString(value):
523     """Converts L{rapi.connector.UriPattern} to strings.
524
525     """
526     if isinstance(value, rapi.connector.UriPattern):
527       return value.content
528     else:
529       return value
530
531
532 _RAPI_RESOURCES_FOR_DOCS = _RapiHandlersForDocsHelper.Build()
533
534
535 def _BuildRapiAccessTable(res):
536   """Build a table with access permissions needed for all RAPI resources.
537
538   """
539   for (uri, handler) in utils.NiceSort(res.items(), key=compat.fst):
540     reslink = _MakeRapiResourceLink(None, uri)
541     if not reslink:
542       # No link was generated
543       continue
544
545     yield ":ref:`%s <%s>`" % (uri, reslink)
546
547     for method in _GetHandlerMethods(handler):
548       yield ("  | :ref:`%s <%s>`: %s" %
549              (method, _MakeRapiResourceLink(method, uri),
550               _DescribeHandlerAccess(handler, method)))
551
552
553 class RapiAccessTable(s_compat.Directive):
554   """Custom directive to generate table of all RAPI resources.
555
556   See also <http://docutils.sourceforge.net/docs/howto/rst-directives.html>.
557
558   """
559   has_content = False
560   required_arguments = 0
561   optional_arguments = 0
562   final_argument_whitespace = False
563   option_spec = {}
564
565   def run(self):
566     include_text = "\n".join(_BuildRapiAccessTable(_RAPI_RESOURCES_FOR_DOCS))
567
568     # Inject into state machine
569     include_lines = docutils.statemachine.string2lines(include_text, _TAB_WIDTH,
570                                                        convert_whitespace=1)
571     self.state_machine.insert_input(include_lines, self.__class__.__name__)
572
573     return []
574
575
576 class RapiResourceDetails(s_compat.Directive):
577   """Custom directive for RAPI resource details.
578
579   See also <http://docutils.sourceforge.net/docs/howto/rst-directives.html>.
580
581   """
582   has_content = False
583   required_arguments = 1
584   optional_arguments = 0
585   final_argument_whitespace = False
586
587   def run(self):
588     uri = self.arguments[0]
589
590     try:
591       handler = _RAPI_RESOURCES_FOR_DOCS[uri]
592     except KeyError:
593       raise self.error("Unknown resource URI '%s'" % uri)
594
595     lines = [
596       ".. list-table::",
597       "   :widths: 1 4",
598       "   :header-rows: 1",
599       "",
600       "   * - Method",
601       "     - :ref:`Required permissions <rapi-users>`",
602       ]
603
604     for method in _GetHandlerMethods(handler):
605       lines.extend([
606         "   * - :ref:`%s <%s>`" % (method, _MakeRapiResourceLink(method, uri)),
607         "     - %s" % _DescribeHandlerAccess(handler, method),
608         ])
609
610     # Inject into state machine
611     include_lines = \
612       docutils.statemachine.string2lines("\n".join(lines), _TAB_WIDTH,
613                                          convert_whitespace=1)
614     self.state_machine.insert_input(include_lines, self.__class__.__name__)
615
616     return []
617
618
619 def setup(app):
620   """Sphinx extension callback.
621
622   """
623   # TODO: Implement Sphinx directive for query fields
624   app.add_directive("opcode_params", OpcodeParams)
625   app.add_directive("opcode_result", OpcodeResult)
626   app.add_directive("pyassert", PythonAssert)
627   app.add_role("pyeval", PythonEvalRole)
628   app.add_directive("rapi_access_table", RapiAccessTable)
629   app.add_directive("rapi_resource_details", RapiResourceDetails)
630
631   app.add_config_value("enable_manpages", False, True)
632   app.add_role("manpage", _ManPageRole)