Statistics
| Branch: | Tag: | Revision:

root / lib / build / sphinx_ext.py @ f7f03738

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