Statistics
| Branch: | Tag: | Revision:

root / lib / build / sphinx_ext.py @ c8fca479

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 constants
51
from ganeti import compat
52
from ganeti import errors
53
from ganeti import utils
54
from ganeti import opcodes
55
from ganeti import opcodes_base
56
from ganeti import ht
57
from ganeti import rapi
58
from ganeti import luxi
59
from ganeti import objects
60
from ganeti import http
61
from ganeti import pathutils
62

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

    
66

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

    
70
_TAB_WIDTH = 2
71

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

    
74

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

78
  """
79

    
80

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

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

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

    
90
  return names
91

    
92

    
93
COMMON_PARAM_NAMES = _GetCommonParamNames()
94

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

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

    
109

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

    
113

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

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

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

    
123

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

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

131
  """
132
  result = {}
133

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

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

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

    
142
  return result
143

    
144

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

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

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

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

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

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

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

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

    
189

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

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

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

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

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

    
204
  return "``%s``" % result_fn
205

    
206

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

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

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

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

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

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

    
237
    return []
238

    
239

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

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

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

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

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

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

    
262
    return []
263

    
264

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

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

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

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

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

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

    
288
  return ([node], [])
289

    
290

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

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

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

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

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

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

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

    
320
    return []
321

    
322

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

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

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

    
334

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

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

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

    
346

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

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

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

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

    
359

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

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

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

    
371
    self.converted = None
372

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

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

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

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

    
389
    wanted_section = _constants.MAN_PAGES.get(name, None)
390

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

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

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

    
406

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

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

413
  """
414
  xref = _ManPageXRefRole()
415

    
416
  assert ht.TNone(xref.converted)
417

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

    
426
  assert ht.TBool(xref.converted)
427

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

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

    
437

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

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

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

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

    
449

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

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

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

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

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

    
467

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

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

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

    
481

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

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

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

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

    
499

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

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

    
512
    return resources
513

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

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

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

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

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

    
533

    
534
_RAPI_RESOURCES_FOR_DOCS = _RapiHandlersForDocsHelper.Build()
535

    
536

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

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

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

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

    
554

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

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

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

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

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

    
575
    return []
576

    
577

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

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

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

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

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

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

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

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

    
618
    return []
619

    
620

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

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

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