New RPC to get size and spindles of disks
[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 ht.NoDefault
168     has_test = not (test is None or test is 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".join(_BuildOpcodeParams(op_id, include, exclude, alias))
226
227     # Inject into state machine
228     include_lines = docutils.statemachine.string2lines(include_text, _TAB_WIDTH,
229                                                        convert_whitespace=1)
230     self.state_machine.insert_input(include_lines, path)
231
232     return []
233
234
235 class OpcodeResult(s_compat.Directive):
236   """Custom directive for opcode result.
237
238   See also <http://docutils.sourceforge.net/docs/howto/rst-directives.html>.
239
240   """
241   has_content = False
242   required_arguments = 1
243   optional_arguments = 0
244   final_argument_whitespace = False
245
246   def run(self):
247     op_id = self.arguments[0]
248
249     path = op_id
250     include_text = _BuildOpcodeResult(op_id)
251
252     # Inject into state machine
253     include_lines = docutils.statemachine.string2lines(include_text, _TAB_WIDTH,
254                                                        convert_whitespace=1)
255     self.state_machine.insert_input(include_lines, path)
256
257     return []
258
259
260 def PythonEvalRole(role, rawtext, text, lineno, inliner,
261                    options={}, content=[]):
262   """Custom role to evaluate Python expressions.
263
264   The expression's result is included as a literal.
265
266   """
267   # pylint: disable=W0102,W0613,W0142
268   # W0102: Dangerous default value as argument
269   # W0142: Used * or ** magic
270   # W0613: Unused argument
271
272   code = docutils.utils.unescape(text, restore_backslashes=True)
273
274   try:
275     result = eval(code, EVAL_NS)
276   except Exception, err: # pylint: disable=W0703
277     msg = inliner.reporter.error("Failed to evaluate %r: %s" % (code, err),
278                                  line=lineno)
279     return ([inliner.problematic(rawtext, rawtext, msg)], [msg])
280
281   node = docutils.nodes.literal("", unicode(result), **options)
282
283   return ([node], [])
284
285
286 class PythonAssert(s_compat.Directive):
287   """Custom directive for writing assertions.
288
289   The content must be a valid Python expression. If its result does not
290   evaluate to C{True}, the assertion fails.
291
292   """
293   has_content = True
294   required_arguments = 0
295   optional_arguments = 0
296   final_argument_whitespace = False
297
298   def run(self):
299     # Handle combinations of Sphinx and docutils not providing the wanted method
300     if hasattr(self, "assert_has_content"):
301       self.assert_has_content()
302     else:
303       assert self.content
304
305     code = "\n".join(self.content)
306
307     try:
308       result = eval(code, EVAL_NS)
309     except Exception, err:
310       raise self.error("Failed to evaluate %r: %s" % (code, err))
311
312     if not result:
313       raise self.error("Assertion failed: %s" % (code, ))
314
315     return []
316
317
318 def BuildQueryFields(fields):
319   """Build query fields documentation.
320
321   @type fields: dict (field name as key, field details as value)
322
323   """
324   defs = [(fdef.name, fdef.doc)
325            for (_, (fdef, _, _, _)) in utils.NiceSort(fields.items(),
326                                                       key=compat.fst)]
327   return BuildValuesDoc(defs)
328
329
330 def BuildValuesDoc(values):
331   """Builds documentation for a list of values
332
333   @type values: list of tuples in the form (value, documentation)
334
335   """
336   for name, doc in values:
337     assert len(doc.splitlines()) == 1
338     yield "``%s``" % (name,)
339     yield "  %s" % (doc,)
340
341
342 def _ManPageNodeClass(*args, **kwargs):
343   """Generates a pending XRef like a ":doc:`...`" reference.
344
345   """
346   # Type for sphinx/environment.py:BuildEnvironment.resolve_references
347   kwargs["reftype"] = "doc"
348
349   # Force custom title
350   kwargs["refexplicit"] = True
351
352   return sphinx.addnodes.pending_xref(*args, **kwargs)
353
354
355 class _ManPageXRefRole(sphinx.roles.XRefRole):
356   def __init__(self):
357     """Initializes this class.
358
359     """
360     sphinx.roles.XRefRole.__init__(self, nodeclass=_ManPageNodeClass,
361                                    warn_dangling=True)
362
363     assert not hasattr(self, "converted"), \
364       "Sphinx base class gained an attribute named 'converted'"
365
366     self.converted = None
367
368   def process_link(self, env, refnode, has_explicit_title, title, target):
369     """Specialization for man page links.
370
371     """
372     if has_explicit_title:
373       raise ReSTError("Setting explicit title is not allowed for man pages")
374
375     # Check format and extract name and section
376     m = _MAN_RE.match(title)
377     if not m:
378       raise ReSTError("Man page reference '%s' does not match regular"
379                       " expression '%s'" % (title, _MAN_RE.pattern))
380
381     name = m.group("name")
382     section = int(m.group("section"))
383
384     wanted_section = _autoconf.MAN_PAGES.get(name, None)
385
386     if not (wanted_section is None or wanted_section == section):
387       raise ReSTError("Referenced man page '%s' has section number %s, but the"
388                       " reference uses section %s" %
389                       (name, wanted_section, section))
390
391     self.converted = bool(wanted_section is not None and
392                           env.app.config.enable_manpages)
393
394     if self.converted:
395       # Create link to known man page
396       return (title, "man-%s" % name)
397     else:
398       # No changes
399       return (title, target)
400
401
402 def _ManPageRole(typ, rawtext, text, lineno, inliner, # pylint: disable=W0102
403                  options={}, content=[]):
404   """Custom role for man page references.
405
406   Converts man pages to links if enabled during the build.
407
408   """
409   xref = _ManPageXRefRole()
410
411   assert ht.TNone(xref.converted)
412
413   # Check if it's a known man page
414   try:
415     result = xref(typ, rawtext, text, lineno, inliner,
416                   options=options, content=content)
417   except ReSTError, err:
418     msg = inliner.reporter.error(str(err), line=lineno)
419     return ([inliner.problematic(rawtext, rawtext, msg)], [msg])
420
421   assert ht.TBool(xref.converted)
422
423   # Return if the conversion was successful (i.e. the man page was known and
424   # conversion was enabled)
425   if xref.converted:
426     return result
427
428   # Fallback if man page links are disabled or an unknown page is referenced
429   return orig_manpage_role(typ, rawtext, text, lineno, inliner,
430                            options=options, content=content)
431
432
433 def _EncodeRapiResourceLink(method, uri):
434   """Encodes a RAPI resource URI for use as a link target.
435
436   """
437   parts = [RAPI_URI_ENCODE_RE.sub("-", uri.lower()).strip("-")]
438
439   if method is not None:
440     parts.append(method.lower())
441
442   return "rapi-res-%s" % "+".join(filter(None, parts))
443
444
445 def _MakeRapiResourceLink(method, uri):
446   """Generates link target name for RAPI resource.
447
448   """
449   if uri in ["/", "/2"]:
450     # Don't link these
451     return None
452
453   elif uri == "/version":
454     return _EncodeRapiResourceLink(method, uri)
455
456   elif uri.startswith("/2/"):
457     return _EncodeRapiResourceLink(method, uri[len("/2/"):])
458
459   else:
460     raise ReSTError("Unhandled URI '%s'" % uri)
461
462
463 def _GetHandlerMethods(handler):
464   """Returns list of HTTP methods supported by handler class.
465
466   @type handler: L{rapi.baserlib.ResourceBase}
467   @param handler: Handler class
468   @rtype: list of strings
469
470   """
471   return sorted(method
472                 for (method, op_attr, _, _) in rapi.baserlib.OPCODE_ATTRS
473                 # Only if handler supports method
474                 if hasattr(handler, method) or hasattr(handler, op_attr))
475
476
477 def _DescribeHandlerAccess(handler, method):
478   """Returns textual description of required RAPI permissions.
479
480   @type handler: L{rapi.baserlib.ResourceBase}
481   @param handler: Handler class
482   @type method: string
483   @param method: HTTP method (e.g. L{http.HTTP_GET})
484   @rtype: string
485
486   """
487   access = rapi.baserlib.GetHandlerAccess(handler, method)
488
489   if access:
490     return utils.CommaJoin(sorted(access))
491   else:
492     return "*(none)*"
493
494
495 class _RapiHandlersForDocsHelper(object):
496   @classmethod
497   def Build(cls):
498     """Returns dictionary of resource handlers.
499
500     """
501     resources = \
502       rapi.connector.GetHandlers("[node_name]", "[instance_name]",
503                                  "[group_name]", "[network_name]", "[job_id]",
504                                  "[disk_index]", "[resource]",
505                                  translate=cls._TranslateResourceUri)
506
507     return resources
508
509   @classmethod
510   def _TranslateResourceUri(cls, *args):
511     """Translates a resource URI for use in documentation.
512
513     @see: L{rapi.connector.GetHandlers}
514
515     """
516     return "".join(map(cls._UriPatternToString, args))
517
518   @staticmethod
519   def _UriPatternToString(value):
520     """Converts L{rapi.connector.UriPattern} to strings.
521
522     """
523     if isinstance(value, rapi.connector.UriPattern):
524       return value.content
525     else:
526       return value
527
528
529 _RAPI_RESOURCES_FOR_DOCS = _RapiHandlersForDocsHelper.Build()
530
531
532 def _BuildRapiAccessTable(res):
533   """Build a table with access permissions needed for all RAPI resources.
534
535   """
536   for (uri, handler) in utils.NiceSort(res.items(), key=compat.fst):
537     reslink = _MakeRapiResourceLink(None, uri)
538     if not reslink:
539       # No link was generated
540       continue
541
542     yield ":ref:`%s <%s>`" % (uri, reslink)
543
544     for method in _GetHandlerMethods(handler):
545       yield ("  | :ref:`%s <%s>`: %s" %
546              (method, _MakeRapiResourceLink(method, uri),
547               _DescribeHandlerAccess(handler, method)))
548
549
550 class RapiAccessTable(s_compat.Directive):
551   """Custom directive to generate table of all RAPI resources.
552
553   See also <http://docutils.sourceforge.net/docs/howto/rst-directives.html>.
554
555   """
556   has_content = False
557   required_arguments = 0
558   optional_arguments = 0
559   final_argument_whitespace = False
560   option_spec = {}
561
562   def run(self):
563     include_text = "\n".join(_BuildRapiAccessTable(_RAPI_RESOURCES_FOR_DOCS))
564
565     # Inject into state machine
566     include_lines = docutils.statemachine.string2lines(include_text, _TAB_WIDTH,
567                                                        convert_whitespace=1)
568     self.state_machine.insert_input(include_lines, self.__class__.__name__)
569
570     return []
571
572
573 class RapiResourceDetails(s_compat.Directive):
574   """Custom directive for RAPI resource details.
575
576   See also <http://docutils.sourceforge.net/docs/howto/rst-directives.html>.
577
578   """
579   has_content = False
580   required_arguments = 1
581   optional_arguments = 0
582   final_argument_whitespace = False
583
584   def run(self):
585     uri = self.arguments[0]
586
587     try:
588       handler = _RAPI_RESOURCES_FOR_DOCS[uri]
589     except KeyError:
590       raise self.error("Unknown resource URI '%s'" % uri)
591
592     lines = [
593       ".. list-table::",
594       "   :widths: 1 4",
595       "   :header-rows: 1",
596       "",
597       "   * - Method",
598       "     - :ref:`Required permissions <rapi-users>`",
599       ]
600
601     for method in _GetHandlerMethods(handler):
602       lines.extend([
603         "   * - :ref:`%s <%s>`" % (method, _MakeRapiResourceLink(method, uri)),
604         "     - %s" % _DescribeHandlerAccess(handler, method),
605         ])
606
607     # Inject into state machine
608     include_lines = \
609       docutils.statemachine.string2lines("\n".join(lines), _TAB_WIDTH,
610                                          convert_whitespace=1)
611     self.state_machine.insert_input(include_lines, self.__class__.__name__)
612
613     return []
614
615
616 def setup(app):
617   """Sphinx extension callback.
618
619   """
620   # TODO: Implement Sphinx directive for query fields
621   app.add_directive("opcode_params", OpcodeParams)
622   app.add_directive("opcode_result", OpcodeResult)
623   app.add_directive("pyassert", PythonAssert)
624   app.add_role("pyeval", PythonEvalRole)
625   app.add_directive("rapi_access_table", RapiAccessTable)
626   app.add_directive("rapi_resource_details", RapiResourceDetails)
627
628   app.add_config_value("enable_manpages", False, True)
629   app.add_role("manpage", _ManPageRole)