Statistics
| Branch: | Tag: | Revision:

root / lib / build / sphinx_ext.py @ 34af39e8

History | View | Annotate | Download (16.9 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 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)