Statistics
| Branch: | Tag: | Revision:

root / lib / build / sphinx_ext.py @ 8e4968ca

History | View | Annotate | Download (17 kB)

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 opcodes_base
55
from ganeti import ht
56
from ganeti import rapi
57
from ganeti import luxi
58
from ganeti import objects
59
from ganeti import http
60
from ganeti import _autoconf
61

    
62
import ganeti.rapi.rlib2 # pylint: disable=W0611
63
import ganeti.rapi.connector # pylint: disable=W0611
64

    
65

    
66
#: Regular expression for man page names
67
_MAN_RE = re.compile(r"^(?P<name>[-\w_]+)\((?P<section>\d+)\)$")
68

    
69
_TAB_WIDTH = 2
70

    
71
RAPI_URI_ENCODE_RE = re.compile("[^_a-z0-9]+", re.I)
72

    
73

    
74
class ReSTError(Exception):
75
  """Custom class for generating errors in Sphinx.
76

77
  """
78

    
79

    
80
def _GetCommonParamNames():
81
  """Builds a list of parameters common to all opcodes.
82

83
  """
84
  names = set(map(compat.fst, opcodes.OpCode.OP_PARAMS))
85

    
86
  # The "depends" attribute should be listed
87
  names.remove(opcodes_base.DEPEND_ATTR)
88

    
89
  return names
90

    
91

    
92
COMMON_PARAM_NAMES = _GetCommonParamNames()
93

    
94
#: Namespace for evaluating expressions
95
EVAL_NS = dict(compat=compat, constants=constants, utils=utils, errors=errors,
96
               rlib2=rapi.rlib2, luxi=luxi, rapi=rapi, objects=objects,
97
               http=http)
98

    
99
# Constants documentation for man pages
100
CV_ECODES_DOC = "ecodes"
101
# We don't care about the leak of variables _, name and doc here.
102
# pylint: disable=W0621
103
CV_ECODES_DOC_LIST = [(name, doc) for (_, name, doc) in constants.CV_ALL_ECODES]
104
DOCUMENTED_CONSTANTS = {
105
  CV_ECODES_DOC: CV_ECODES_DOC_LIST,
106
  }
107

    
108

    
109
class OpcodeError(sphinx.errors.SphinxError):
110
  category = "Opcode error"
111

    
112

    
113
def _SplitOption(text):
114
  """Split simple option list.
115

116
  @type text: string
117
  @param text: Options, e.g. "foo, bar, baz"
118

119
  """
120
  return [i.strip(",").strip() for i in text.split()]
121

    
122

    
123
def _ParseAlias(text):
124
  """Parse simple assignment option.
125

126
  @type text: string
127
  @param text: Assignments, e.g. "foo=bar, hello=world"
128
  @rtype: dict
129

130
  """
131
  result = {}
132

    
133
  for part in _SplitOption(text):
134
    if "=" not in part:
135
      raise OpcodeError("Invalid option format, missing equal sign")
136

    
137
    (name, value) = part.split("=", 1)
138

    
139
    result[name.strip()] = value.strip()
140

    
141
  return result
142

    
143

    
144
def _BuildOpcodeParams(op_id, include, exclude, alias):
145
  """Build opcode parameter documentation.
146

147
  @type op_id: string
148
  @param op_id: Opcode ID
149

150
  """
151
  op_cls = opcodes.OP_MAPPING[op_id]
152

    
153
  params_with_alias = \
154
    utils.NiceSort([(alias.get(name, name), name, default, test, doc)
155
                    for (name, default, test, doc) in op_cls.GetAllParams()],
156
                   key=compat.fst)
157

    
158
  for (rapi_name, name, default, test, doc) in params_with_alias:
159
    # Hide common parameters if not explicitly included
160
    if (name in COMMON_PARAM_NAMES and
161
        (not include or name not in include)):
162
      continue
163
    if exclude is not None and name in exclude:
164
      continue
165
    if include is not None and name not in include:
166
      continue
167

    
168
    has_default = default is not None or default is not ht.NoDefault
169
    has_test = test is not None
170

    
171
    buf = StringIO()
172
    buf.write("``%s``" % (rapi_name,))
173
    if has_default or has_test:
174
      buf.write(" (")
175
      if has_default:
176
        buf.write("defaults to ``%s``" % (default,))
177
        if has_test:
178
          buf.write(", ")
179
      if has_test:
180
        buf.write("must be ``%s``" % (test,))
181
      buf.write(")")
182
    yield buf.getvalue()
183

    
184
    # Add text
185
    for line in doc.splitlines():
186
      yield "  %s" % line
187

    
188

    
189
def _BuildOpcodeResult(op_id):
190
  """Build opcode result documentation.
191

192
  @type op_id: string
193
  @param op_id: Opcode ID
194

195
  """
196
  op_cls = opcodes.OP_MAPPING[op_id]
197

    
198
  result_fn = getattr(op_cls, "OP_RESULT", None)
199

    
200
  if not result_fn:
201
    raise OpcodeError("Opcode '%s' has no result description" % op_id)
202

    
203
  return "``%s``" % result_fn
204

    
205

    
206
class OpcodeParams(s_compat.Directive):
207
  """Custom directive for opcode parameters.
208

209
  See also <http://docutils.sourceforge.net/docs/howto/rst-directives.html>.
210

211
  """
212
  has_content = False
213
  required_arguments = 1
214
  optional_arguments = 0
215
  final_argument_whitespace = False
216
  option_spec = dict(include=_SplitOption, exclude=_SplitOption,
217
                     alias=_ParseAlias)
218

    
219
  def run(self):
220
    op_id = self.arguments[0]
221
    include = self.options.get("include", None)
222
    exclude = self.options.get("exclude", None)
223
    alias = self.options.get("alias", {})
224

    
225
    path = op_id
226
    include_text = "\n\n".join(_BuildOpcodeParams(op_id,
227
                                                  include,
228
                                                  exclude,
229
                                                  alias))
230

    
231
    # Inject into state machine
232
    include_lines = docutils.statemachine.string2lines(include_text, _TAB_WIDTH,
233
                                                       convert_whitespace=1)
234
    self.state_machine.insert_input(include_lines, path)
235

    
236
    return []
237

    
238

    
239
class OpcodeResult(s_compat.Directive):
240
  """Custom directive for opcode result.
241

242
  See also <http://docutils.sourceforge.net/docs/howto/rst-directives.html>.
243

244
  """
245
  has_content = False
246
  required_arguments = 1
247
  optional_arguments = 0
248
  final_argument_whitespace = False
249

    
250
  def run(self):
251
    op_id = self.arguments[0]
252

    
253
    path = op_id
254
    include_text = _BuildOpcodeResult(op_id)
255

    
256
    # Inject into state machine
257
    include_lines = docutils.statemachine.string2lines(include_text, _TAB_WIDTH,
258
                                                       convert_whitespace=1)
259
    self.state_machine.insert_input(include_lines, path)
260

    
261
    return []
262

    
263

    
264
def PythonEvalRole(role, rawtext, text, lineno, inliner,
265
                   options={}, content=[]):
266
  """Custom role to evaluate Python expressions.
267

268
  The expression's result is included as a literal.
269

270
  """
271
  # pylint: disable=W0102,W0613,W0142
272
  # W0102: Dangerous default value as argument
273
  # W0142: Used * or ** magic
274
  # W0613: Unused argument
275

    
276
  code = docutils.utils.unescape(text, restore_backslashes=True)
277

    
278
  try:
279
    result = eval(code, EVAL_NS)
280
  except Exception, err: # pylint: disable=W0703
281
    msg = inliner.reporter.error("Failed to evaluate %r: %s" % (code, err),
282
                                 line=lineno)
283
    return ([inliner.problematic(rawtext, rawtext, msg)], [msg])
284

    
285
  node = docutils.nodes.literal("", unicode(result), **options)
286

    
287
  return ([node], [])
288

    
289

    
290
class PythonAssert(s_compat.Directive):
291
  """Custom directive for writing assertions.
292

293
  The content must be a valid Python expression. If its result does not
294
  evaluate to C{True}, the assertion fails.
295

296
  """
297
  has_content = True
298
  required_arguments = 0
299
  optional_arguments = 0
300
  final_argument_whitespace = False
301

    
302
  def run(self):
303
    # Handle combinations of Sphinx and docutils not providing the wanted method
304
    if hasattr(self, "assert_has_content"):
305
      self.assert_has_content()
306
    else:
307
      assert self.content
308

    
309
    code = "\n".join(self.content)
310

    
311
    try:
312
      result = eval(code, EVAL_NS)
313
    except Exception, err:
314
      raise self.error("Failed to evaluate %r: %s" % (code, err))
315

    
316
    if not result:
317
      raise self.error("Assertion failed: %s" % (code, ))
318

    
319
    return []
320

    
321

    
322
def BuildQueryFields(fields):
323
  """Build query fields documentation.
324

325
  @type fields: dict (field name as key, field details as value)
326

327
  """
328
  defs = [(fdef.name, fdef.doc)
329
           for (_, (fdef, _, _, _)) in utils.NiceSort(fields.items(),
330
                                                      key=compat.fst)]
331
  return BuildValuesDoc(defs)
332

    
333

    
334
def BuildValuesDoc(values):
335
  """Builds documentation for a list of values
336

337
  @type values: list of tuples in the form (value, documentation)
338

339
  """
340
  for name, doc in values:
341
    assert len(doc.splitlines()) == 1
342
    yield "``%s``" % (name,)
343
    yield "  %s" % (doc,)
344

    
345

    
346
def _ManPageNodeClass(*args, **kwargs):
347
  """Generates a pending XRef like a ":doc:`...`" reference.
348

349
  """
350
  # Type for sphinx/environment.py:BuildEnvironment.resolve_references
351
  kwargs["reftype"] = "doc"
352

    
353
  # Force custom title
354
  kwargs["refexplicit"] = True
355

    
356
  return sphinx.addnodes.pending_xref(*args, **kwargs)
357

    
358

    
359
class _ManPageXRefRole(sphinx.roles.XRefRole):
360
  def __init__(self):
361
    """Initializes this class.
362

363
    """
364
    sphinx.roles.XRefRole.__init__(self, nodeclass=_ManPageNodeClass,
365
                                   warn_dangling=True)
366

    
367
    assert not hasattr(self, "converted"), \
368
      "Sphinx base class gained an attribute named 'converted'"
369

    
370
    self.converted = None
371

    
372
  def process_link(self, env, refnode, has_explicit_title, title, target):
373
    """Specialization for man page links.
374

375
    """
376
    if has_explicit_title:
377
      raise ReSTError("Setting explicit title is not allowed for man pages")
378

    
379
    # Check format and extract name and section
380
    m = _MAN_RE.match(title)
381
    if not m:
382
      raise ReSTError("Man page reference '%s' does not match regular"
383
                      " expression '%s'" % (title, _MAN_RE.pattern))
384

    
385
    name = m.group("name")
386
    section = int(m.group("section"))
387

    
388
    wanted_section = _autoconf.MAN_PAGES.get(name, None)
389

    
390
    if not (wanted_section is None or wanted_section == section):
391
      raise ReSTError("Referenced man page '%s' has section number %s, but the"
392
                      " reference uses section %s" %
393
                      (name, wanted_section, section))
394

    
395
    self.converted = bool(wanted_section is not None and
396
                          env.app.config.enable_manpages)
397

    
398
    if self.converted:
399
      # Create link to known man page
400
      return (title, "man-%s" % name)
401
    else:
402
      # No changes
403
      return (title, target)
404

    
405

    
406
def _ManPageRole(typ, rawtext, text, lineno, inliner, # pylint: disable=W0102
407
                 options={}, content=[]):
408
  """Custom role for man page references.
409

410
  Converts man pages to links if enabled during the build.
411

412
  """
413
  xref = _ManPageXRefRole()
414

    
415
  assert ht.TNone(xref.converted)
416

    
417
  # Check if it's a known man page
418
  try:
419
    result = xref(typ, rawtext, text, lineno, inliner,
420
                  options=options, content=content)
421
  except ReSTError, err:
422
    msg = inliner.reporter.error(str(err), line=lineno)
423
    return ([inliner.problematic(rawtext, rawtext, msg)], [msg])
424

    
425
  assert ht.TBool(xref.converted)
426

    
427
  # Return if the conversion was successful (i.e. the man page was known and
428
  # conversion was enabled)
429
  if xref.converted:
430
    return result
431

    
432
  # Fallback if man page links are disabled or an unknown page is referenced
433
  return orig_manpage_role(typ, rawtext, text, lineno, inliner,
434
                           options=options, content=content)
435

    
436

    
437
def _EncodeRapiResourceLink(method, uri):
438
  """Encodes a RAPI resource URI for use as a link target.
439

440
  """
441
  parts = [RAPI_URI_ENCODE_RE.sub("-", uri.lower()).strip("-")]
442

    
443
  if method is not None:
444
    parts.append(method.lower())
445

    
446
  return "rapi-res-%s" % "+".join(filter(None, parts))
447

    
448

    
449
def _MakeRapiResourceLink(method, uri):
450
  """Generates link target name for RAPI resource.
451

452
  """
453
  if uri in ["/", "/2"]:
454
    # Don't link these
455
    return None
456

    
457
  elif uri == "/version":
458
    return _EncodeRapiResourceLink(method, uri)
459

    
460
  elif uri.startswith("/2/"):
461
    return _EncodeRapiResourceLink(method, uri[len("/2/"):])
462

    
463
  else:
464
    raise ReSTError("Unhandled URI '%s'" % uri)
465

    
466

    
467
def _GetHandlerMethods(handler):
468
  """Returns list of HTTP methods supported by handler class.
469

470
  @type handler: L{rapi.baserlib.ResourceBase}
471
  @param handler: Handler class
472
  @rtype: list of strings
473

474
  """
475
  return sorted(method
476
                for (method, op_attr, _, _) in rapi.baserlib.OPCODE_ATTRS
477
                # Only if handler supports method
478
                if hasattr(handler, method) or hasattr(handler, op_attr))
479

    
480

    
481
def _DescribeHandlerAccess(handler, method):
482
  """Returns textual description of required RAPI permissions.
483

484
  @type handler: L{rapi.baserlib.ResourceBase}
485
  @param handler: Handler class
486
  @type method: string
487
  @param method: HTTP method (e.g. L{http.HTTP_GET})
488
  @rtype: string
489

490
  """
491
  access = rapi.baserlib.GetHandlerAccess(handler, method)
492

    
493
  if access:
494
    return utils.CommaJoin(sorted(access))
495
  else:
496
    return "*(none)*"
497

    
498

    
499
class _RapiHandlersForDocsHelper(object):
500
  @classmethod
501
  def Build(cls):
502
    """Returns dictionary of resource handlers.
503

504
    """
505
    resources = \
506
      rapi.connector.GetHandlers("[node_name]", "[instance_name]",
507
                                 "[group_name]", "[network_name]", "[job_id]",
508
                                 "[disk_index]", "[resource]",
509
                                 translate=cls._TranslateResourceUri)
510

    
511
    return resources
512

    
513
  @classmethod
514
  def _TranslateResourceUri(cls, *args):
515
    """Translates a resource URI for use in documentation.
516

517
    @see: L{rapi.connector.GetHandlers}
518

519
    """
520
    return "".join(map(cls._UriPatternToString, args))
521

    
522
  @staticmethod
523
  def _UriPatternToString(value):
524
    """Converts L{rapi.connector.UriPattern} to strings.
525

526
    """
527
    if isinstance(value, rapi.connector.UriPattern):
528
      return value.content
529
    else:
530
      return value
531

    
532

    
533
_RAPI_RESOURCES_FOR_DOCS = _RapiHandlersForDocsHelper.Build()
534

    
535

    
536
def _BuildRapiAccessTable(res):
537
  """Build a table with access permissions needed for all RAPI resources.
538

539
  """
540
  for (uri, handler) in utils.NiceSort(res.items(), key=compat.fst):
541
    reslink = _MakeRapiResourceLink(None, uri)
542
    if not reslink:
543
      # No link was generated
544
      continue
545

    
546
    yield ":ref:`%s <%s>`" % (uri, reslink)
547

    
548
    for method in _GetHandlerMethods(handler):
549
      yield ("  | :ref:`%s <%s>`: %s" %
550
             (method, _MakeRapiResourceLink(method, uri),
551
              _DescribeHandlerAccess(handler, method)))
552

    
553

    
554
class RapiAccessTable(s_compat.Directive):
555
  """Custom directive to generate table of all RAPI resources.
556

557
  See also <http://docutils.sourceforge.net/docs/howto/rst-directives.html>.
558

559
  """
560
  has_content = False
561
  required_arguments = 0
562
  optional_arguments = 0
563
  final_argument_whitespace = False
564
  option_spec = {}
565

    
566
  def run(self):
567
    include_text = "\n".join(_BuildRapiAccessTable(_RAPI_RESOURCES_FOR_DOCS))
568

    
569
    # Inject into state machine
570
    include_lines = docutils.statemachine.string2lines(include_text, _TAB_WIDTH,
571
                                                       convert_whitespace=1)
572
    self.state_machine.insert_input(include_lines, self.__class__.__name__)
573

    
574
    return []
575

    
576

    
577
class RapiResourceDetails(s_compat.Directive):
578
  """Custom directive for RAPI resource details.
579

580
  See also <http://docutils.sourceforge.net/docs/howto/rst-directives.html>.
581

582
  """
583
  has_content = False
584
  required_arguments = 1
585
  optional_arguments = 0
586
  final_argument_whitespace = False
587

    
588
  def run(self):
589
    uri = self.arguments[0]
590

    
591
    try:
592
      handler = _RAPI_RESOURCES_FOR_DOCS[uri]
593
    except KeyError:
594
      raise self.error("Unknown resource URI '%s'" % uri)
595

    
596
    lines = [
597
      ".. list-table::",
598
      "   :widths: 1 4",
599
      "   :header-rows: 1",
600
      "",
601
      "   * - Method",
602
      "     - :ref:`Required permissions <rapi-users>`",
603
      ]
604

    
605
    for method in _GetHandlerMethods(handler):
606
      lines.extend([
607
        "   * - :ref:`%s <%s>`" % (method, _MakeRapiResourceLink(method, uri)),
608
        "     - %s" % _DescribeHandlerAccess(handler, method),
609
        ])
610

    
611
    # Inject into state machine
612
    include_lines = \
613
      docutils.statemachine.string2lines("\n".join(lines), _TAB_WIDTH,
614
                                         convert_whitespace=1)
615
    self.state_machine.insert_input(include_lines, self.__class__.__name__)
616

    
617
    return []
618

    
619

    
620
def setup(app):
621
  """Sphinx extension callback.
622

623
  """
624
  # TODO: Implement Sphinx directive for query fields
625
  app.add_directive("opcode_params", OpcodeParams)
626
  app.add_directive("opcode_result", OpcodeResult)
627
  app.add_directive("pyassert", PythonAssert)
628
  app.add_role("pyeval", PythonEvalRole)
629
  app.add_directive("rapi_access_table", RapiAccessTable)
630
  app.add_directive("rapi_resource_details", RapiResourceDetails)
631

    
632
  app.add_config_value("enable_manpages", False, True)
633
  app.add_role("manpage", _ManPageRole)