Statistics
| Branch: | Tag: | Revision:

root / lib / build / sphinx_ext.py @ daff2f81

History | View | Annotate | Download (14.9 kB)

1
#
2
#
3

    
4
# Copyright (C) 2011, 2012 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 _autoconf
59

    
60
import ganeti.rapi.rlib2 # pylint: disable=W0611
61
import ganeti.rapi.connector # pylint: disable=W0611
62

    
63

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

    
67
_TAB_WIDTH = 2
68

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

    
71
RAPI_ACCESS_TEXT = {
72
  rapi.RAPI_ACCESS_WRITE: "write",
73
  rapi.RAPI_ACCESS_READ: "read",
74
  }
75

    
76
assert frozenset(RAPI_ACCESS_TEXT.keys()) == rapi.RAPI_ACCESS_ALL
77

    
78

    
79
class ReSTError(Exception):
80
  """Custom class for generating errors in Sphinx.
81

82
  """
83

    
84

    
85
def _GetCommonParamNames():
86
  """Builds a list of parameters common to all opcodes.
87

88
  """
89
  names = set(map(compat.fst, opcodes.OpCode.OP_PARAMS))
90

    
91
  # The "depends" attribute should be listed
92
  names.remove(opcodes.DEPEND_ATTR)
93

    
94
  return names
95

    
96

    
97
COMMON_PARAM_NAMES = _GetCommonParamNames()
98

    
99
#: Namespace for evaluating expressions
100
EVAL_NS = dict(compat=compat, constants=constants, utils=utils, errors=errors,
101
               rlib2=rapi.rlib2, luxi=luxi, rapi=rapi, objects=objects)
102

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

    
112

    
113
class OpcodeError(sphinx.errors.SphinxError):
114
  category = "Opcode error"
115

    
116

    
117
def _SplitOption(text):
118
  """Split simple option list.
119

120
  @type text: string
121
  @param text: Options, e.g. "foo, bar, baz"
122

123
  """
124
  return [i.strip(",").strip() for i in text.split()]
125

    
126

    
127
def _ParseAlias(text):
128
  """Parse simple assignment option.
129

130
  @type text: string
131
  @param text: Assignments, e.g. "foo=bar, hello=world"
132
  @rtype: dict
133

134
  """
135
  result = {}
136

    
137
  for part in _SplitOption(text):
138
    if "=" not in part:
139
      raise OpcodeError("Invalid option format, missing equal sign")
140

    
141
    (name, value) = part.split("=", 1)
142

    
143
    result[name.strip()] = value.strip()
144

    
145
  return result
146

    
147

    
148
def _BuildOpcodeParams(op_id, include, exclude, alias):
149
  """Build opcode parameter documentation.
150

151
  @type op_id: string
152
  @param op_id: Opcode ID
153

154
  """
155
  op_cls = opcodes.OP_MAPPING[op_id]
156

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

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

    
172
    has_default = default is not ht.NoDefault
173
    has_test = not (test is None or test is ht.NoType)
174

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

    
188
    # Add text
189
    for line in doc.splitlines():
190
      yield "  %s" % line
191

    
192

    
193
def _BuildOpcodeResult(op_id):
194
  """Build opcode result documentation.
195

196
  @type op_id: string
197
  @param op_id: Opcode ID
198

199
  """
200
  op_cls = opcodes.OP_MAPPING[op_id]
201

    
202
  result_fn = getattr(op_cls, "OP_RESULT", None)
203

    
204
  if not result_fn:
205
    raise OpcodeError("Opcode '%s' has no result description" % op_id)
206

    
207
  return "``%s``" % result_fn
208

    
209

    
210
class OpcodeParams(s_compat.Directive):
211
  """Custom directive for opcode parameters.
212

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

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

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

    
229
    path = op_id
230
    include_text = "\n".join(_BuildOpcodeParams(op_id, include, exclude, 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 = _autoconf.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 _BuildRapiAccessTable(res):
469
  """Build a table with access permissions needed for all RAPI resources.
470

471
  """
472
  for (uri, handler) in utils.NiceSort(res.items(), key=compat.fst):
473
    reslink = _MakeRapiResourceLink(None, uri)
474
    if not reslink:
475
      # No link was generated
476
      continue
477

    
478
    yield ":ref:`%s <%s>`" % (uri, reslink)
479

    
480
    for (method, op_attr, _, _) in sorted(rapi.baserlib.OPCODE_ATTRS):
481
      if not (hasattr(handler, method) or hasattr(handler, op_attr)):
482
        # Handler doesn't support method
483
        continue
484

    
485
      access = rapi.baserlib.GetHandlerAccess(handler, method)
486

    
487
      perms = map(RAPI_ACCESS_TEXT.__getitem__, access)
488

    
489
      if not perms:
490
        perms.append("*everyone*")
491

    
492
      yield ("  | :ref:`%s <%s>`: %s" %
493
             (method, _MakeRapiResourceLink(method, uri),
494
              utils.CommaJoin(perms)))
495

    
496

    
497
class RapiAccessTable(s_compat.Directive):
498
  """Custom directive to generate table of all RAPI resources.
499

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

502
  """
503
  has_content = False
504
  required_arguments = 0
505
  optional_arguments = 0
506
  final_argument_whitespace = False
507
  option_spec = {}
508

    
509
  def run(self):
510
    resources = \
511
      rapi.connector.GetHandlers("[node_name]", "[instance_name]",
512
                                 "[group_name]", "[network_name]", "[job_id]",
513
                                 "[disk_index]", "[resource]",
514
                                 translate=self._TranslateResourceUri)
515

    
516
    include_text = "\n".join(_BuildRapiAccessTable(resources))
517

    
518
    # Inject into state machine
519
    include_lines = docutils.statemachine.string2lines(include_text, _TAB_WIDTH,
520
                                                       convert_whitespace=1)
521
    self.state_machine.insert_input(include_lines, self.__class__.__name__)
522

    
523
    return []
524

    
525
  @classmethod
526
  def _TranslateResourceUri(cls, *args):
527
    """Translates a resource URI for use in documentation.
528

529
    @see: L{rapi.connector.GetHandlers}
530

531
    """
532
    return "".join(map(cls._UriPatternToString, args))
533

    
534
  @staticmethod
535
  def _UriPatternToString(value):
536
    """Converts L{rapi.connector.UriPattern} to strings.
537

538
    """
539
    if isinstance(value, rapi.connector.UriPattern):
540
      return value.content
541
    else:
542
      return value
543

    
544

    
545
def setup(app):
546
  """Sphinx extension callback.
547

548
  """
549
  # TODO: Implement Sphinx directive for query fields
550
  app.add_directive("opcode_params", OpcodeParams)
551
  app.add_directive("opcode_result", OpcodeResult)
552
  app.add_directive("pyassert", PythonAssert)
553
  app.add_role("pyeval", PythonEvalRole)
554
  app.add_directive("rapi_access_table", RapiAccessTable)
555

    
556
  app.add_config_value("enable_manpages", False, True)
557
  app.add_role("manpage", _ManPageRole)