Statistics
| Branch: | Tag: | Revision:

root / lib / build / sphinx_ext.py @ 5d0b2888

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

    
72
class ReSTError(Exception):
73
  """Custom class for generating errors in Sphinx.
74

75
  """
76

    
77

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

81
  """
82
  names = set(map(compat.fst, opcodes.OpCode.OP_PARAMS))
83

    
84
  # The "depends" attribute should be listed
85
  names.remove(opcodes.DEPEND_ATTR)
86

    
87
  return names
88

    
89

    
90
COMMON_PARAM_NAMES = _GetCommonParamNames()
91

    
92
#: Namespace for evaluating expressions
93
EVAL_NS = dict(compat=compat, constants=constants, utils=utils, errors=errors,
94
               rlib2=rapi.rlib2, luxi=luxi, rapi=rapi, objects=objects)
95

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

    
105

    
106
class OpcodeError(sphinx.errors.SphinxError):
107
  category = "Opcode error"
108

    
109

    
110
def _SplitOption(text):
111
  """Split simple option list.
112

113
  @type text: string
114
  @param text: Options, e.g. "foo, bar, baz"
115

116
  """
117
  return [i.strip(",").strip() for i in text.split()]
118

    
119

    
120
def _ParseAlias(text):
121
  """Parse simple assignment option.
122

123
  @type text: string
124
  @param text: Assignments, e.g. "foo=bar, hello=world"
125
  @rtype: dict
126

127
  """
128
  result = {}
129

    
130
  for part in _SplitOption(text):
131
    if "=" not in part:
132
      raise OpcodeError("Invalid option format, missing equal sign")
133

    
134
    (name, value) = part.split("=", 1)
135

    
136
    result[name.strip()] = value.strip()
137

    
138
  return result
139

    
140

    
141
def _BuildOpcodeParams(op_id, include, exclude, alias):
142
  """Build opcode parameter documentation.
143

144
  @type op_id: string
145
  @param op_id: Opcode ID
146

147
  """
148
  op_cls = opcodes.OP_MAPPING[op_id]
149

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

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

    
165
    has_default = default is not ht.NoDefault
166
    has_test = not (test is None or test is ht.NoType)
167

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

    
181
    # Add text
182
    for line in doc.splitlines():
183
      yield "  %s" % line
184

    
185

    
186
def _BuildOpcodeResult(op_id):
187
  """Build opcode result documentation.
188

189
  @type op_id: string
190
  @param op_id: Opcode ID
191

192
  """
193
  op_cls = opcodes.OP_MAPPING[op_id]
194

    
195
  result_fn = getattr(op_cls, "OP_RESULT", None)
196

    
197
  if not result_fn:
198
    raise OpcodeError("Opcode '%s' has no result description" % op_id)
199

    
200
  return "``%s``" % result_fn
201

    
202

    
203
class OpcodeParams(s_compat.Directive):
204
  """Custom directive for opcode parameters.
205

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

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

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

    
222
    path = op_id
223
    include_text = "\n".join(_BuildOpcodeParams(op_id, include, exclude, alias))
224

    
225
    # Inject into state machine
226
    include_lines = docutils.statemachine.string2lines(include_text, _TAB_WIDTH,
227
                                                       convert_whitespace=1)
228
    self.state_machine.insert_input(include_lines, path)
229

    
230
    return []
231

    
232

    
233
class OpcodeResult(s_compat.Directive):
234
  """Custom directive for opcode result.
235

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

238
  """
239
  has_content = False
240
  required_arguments = 1
241
  optional_arguments = 0
242
  final_argument_whitespace = False
243

    
244
  def run(self):
245
    op_id = self.arguments[0]
246

    
247
    path = op_id
248
    include_text = _BuildOpcodeResult(op_id)
249

    
250
    # Inject into state machine
251
    include_lines = docutils.statemachine.string2lines(include_text, _TAB_WIDTH,
252
                                                       convert_whitespace=1)
253
    self.state_machine.insert_input(include_lines, path)
254

    
255
    return []
256

    
257

    
258
def PythonEvalRole(role, rawtext, text, lineno, inliner,
259
                   options={}, content=[]):
260
  """Custom role to evaluate Python expressions.
261

262
  The expression's result is included as a literal.
263

264
  """
265
  # pylint: disable=W0102,W0613,W0142
266
  # W0102: Dangerous default value as argument
267
  # W0142: Used * or ** magic
268
  # W0613: Unused argument
269

    
270
  code = docutils.utils.unescape(text, restore_backslashes=True)
271

    
272
  try:
273
    result = eval(code, EVAL_NS)
274
  except Exception, err: # pylint: disable=W0703
275
    msg = inliner.reporter.error("Failed to evaluate %r: %s" % (code, err),
276
                                 line=lineno)
277
    return ([inliner.problematic(rawtext, rawtext, msg)], [msg])
278

    
279
  node = docutils.nodes.literal("", unicode(result), **options)
280

    
281
  return ([node], [])
282

    
283

    
284
class PythonAssert(s_compat.Directive):
285
  """Custom directive for writing assertions.
286

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

290
  """
291
  has_content = True
292
  required_arguments = 0
293
  optional_arguments = 0
294
  final_argument_whitespace = False
295

    
296
  def run(self):
297
    # Handle combinations of Sphinx and docutils not providing the wanted method
298
    if hasattr(self, "assert_has_content"):
299
      self.assert_has_content()
300
    else:
301
      assert self.content
302

    
303
    code = "\n".join(self.content)
304

    
305
    try:
306
      result = eval(code, EVAL_NS)
307
    except Exception, err:
308
      raise self.error("Failed to evaluate %r: %s" % (code, err))
309

    
310
    if not result:
311
      raise self.error("Assertion failed: %s" % (code, ))
312

    
313
    return []
314

    
315

    
316
def BuildQueryFields(fields):
317
  """Build query fields documentation.
318

319
  @type fields: dict (field name as key, field details as value)
320

321
  """
322
  defs = [(fdef.name, fdef.doc)
323
           for (_, (fdef, _, _, _)) in utils.NiceSort(fields.items(),
324
                                                      key=compat.fst)]
325
  return BuildValuesDoc(defs)
326

    
327

    
328
def BuildValuesDoc(values):
329
  """Builds documentation for a list of values
330

331
  @type values: list of tuples in the form (value, documentation)
332

333
  """
334
  for name, doc in values:
335
    assert len(doc.splitlines()) == 1
336
    yield "``%s``" % name
337
    yield "  %s" % doc
338

    
339

    
340
def _ManPageNodeClass(*args, **kwargs):
341
  """Generates a pending XRef like a ":doc:`...`" reference.
342

343
  """
344
  # Type for sphinx/environment.py:BuildEnvironment.resolve_references
345
  kwargs["reftype"] = "doc"
346

    
347
  # Force custom title
348
  kwargs["refexplicit"] = True
349

    
350
  return sphinx.addnodes.pending_xref(*args, **kwargs)
351

    
352

    
353
class _ManPageXRefRole(sphinx.roles.XRefRole):
354
  def __init__(self):
355
    """Initializes this class.
356

357
    """
358
    sphinx.roles.XRefRole.__init__(self, nodeclass=_ManPageNodeClass,
359
                                   warn_dangling=True)
360

    
361
    assert not hasattr(self, "converted"), \
362
      "Sphinx base class gained an attribute named 'converted'"
363

    
364
    self.converted = None
365

    
366
  def process_link(self, env, refnode, has_explicit_title, title, target):
367
    """Specialization for man page links.
368

369
    """
370
    if has_explicit_title:
371
      raise ReSTError("Setting explicit title is not allowed for man pages")
372

    
373
    # Check format and extract name and section
374
    m = _MAN_RE.match(title)
375
    if not m:
376
      raise ReSTError("Man page reference '%s' does not match regular"
377
                      " expression '%s'" % (title, _MAN_RE.pattern))
378

    
379
    name = m.group("name")
380
    section = int(m.group("section"))
381

    
382
    wanted_section = _autoconf.MAN_PAGES.get(name, None)
383

    
384
    if not (wanted_section is None or wanted_section == section):
385
      raise ReSTError("Referenced man page '%s' has section number %s, but the"
386
                      " reference uses section %s" %
387
                      (name, wanted_section, section))
388

    
389
    self.converted = bool(wanted_section is not None and
390
                          env.app.config.enable_manpages)
391

    
392
    if self.converted:
393
      # Create link to known man page
394
      return (title, "man-%s" % name)
395
    else:
396
      # No changes
397
      return (title, target)
398

    
399

    
400
def _ManPageRole(typ, rawtext, text, lineno, inliner, # pylint: disable=W0102
401
                 options={}, content=[]):
402
  """Custom role for man page references.
403

404
  Converts man pages to links if enabled during the build.
405

406
  """
407
  xref = _ManPageXRefRole()
408

    
409
  assert ht.TNone(xref.converted)
410

    
411
  # Check if it's a known man page
412
  try:
413
    result = xref(typ, rawtext, text, lineno, inliner,
414
                  options=options, content=content)
415
  except ReSTError, err:
416
    msg = inliner.reporter.error(str(err), line=lineno)
417
    return ([inliner.problematic(rawtext, rawtext, msg)], [msg])
418

    
419
  assert ht.TBool(xref.converted)
420

    
421
  # Return if the conversion was successful (i.e. the man page was known and
422
  # conversion was enabled)
423
  if xref.converted:
424
    return result
425

    
426
  # Fallback if man page links are disabled or an unknown page is referenced
427
  return orig_manpage_role(typ, rawtext, text, lineno, inliner,
428
                           options=options, content=content)
429

    
430

    
431
def _EncodeRapiResourceLink(method, uri):
432
  """Encodes a RAPI resource URI for use as a link target.
433

434
  """
435
  parts = [RAPI_URI_ENCODE_RE.sub("-", uri.lower()).strip("-")]
436

    
437
  if method is not None:
438
    parts.append(method.lower())
439

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

    
442

    
443
def _MakeRapiResourceLink(method, uri):
444
  """Generates link target name for RAPI resource.
445

446
  """
447
  if uri in ["/", "/2"]:
448
    # Don't link these
449
    return None
450

    
451
  elif uri == "/version":
452
    return _EncodeRapiResourceLink(method, uri)
453

    
454
  elif uri.startswith("/2/"):
455
    return _EncodeRapiResourceLink(method, uri[len("/2/"):])
456

    
457
  else:
458
    raise ReSTError("Unhandled URI '%s'" % uri)
459

    
460

    
461
def _BuildRapiAccessTable(res):
462
  """Build a table with access permissions needed for all RAPI resources.
463

464
  """
465
  for (uri, handler) in utils.NiceSort(res.items(), key=compat.fst):
466
    reslink = _MakeRapiResourceLink(None, uri)
467
    if not reslink:
468
      # No link was generated
469
      continue
470

    
471
    yield ":ref:`%s <%s>`" % (uri, reslink)
472

    
473
    for (method, op_attr, _, _) in sorted(rapi.baserlib.OPCODE_ATTRS):
474
      if not (hasattr(handler, method) or hasattr(handler, op_attr)):
475
        # Handler doesn't support method
476
        continue
477

    
478
      access = rapi.baserlib.GetHandlerAccess(handler, method)
479

    
480
      if access:
481
        perms = utils.CommaJoin(sorted(access))
482
      else:
483
        perms = "*everyone*"
484

    
485
      yield ("  | :ref:`%s <%s>`: %s" %
486
             (method, _MakeRapiResourceLink(method, uri), perms))
487

    
488

    
489
class RapiAccessTable(s_compat.Directive):
490
  """Custom directive to generate table of all RAPI resources.
491

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

494
  """
495
  has_content = False
496
  required_arguments = 0
497
  optional_arguments = 0
498
  final_argument_whitespace = False
499
  option_spec = {}
500

    
501
  def run(self):
502
    resources = \
503
      rapi.connector.GetHandlers("[node_name]", "[instance_name]",
504
                                 "[group_name]", "[network_name]", "[job_id]",
505
                                 "[disk_index]", "[resource]",
506
                                 translate=self._TranslateResourceUri)
507

    
508
    include_text = "\n".join(_BuildRapiAccessTable(resources))
509

    
510
    # Inject into state machine
511
    include_lines = docutils.statemachine.string2lines(include_text, _TAB_WIDTH,
512
                                                       convert_whitespace=1)
513
    self.state_machine.insert_input(include_lines, self.__class__.__name__)
514

    
515
    return []
516

    
517
  @classmethod
518
  def _TranslateResourceUri(cls, *args):
519
    """Translates a resource URI for use in documentation.
520

521
    @see: L{rapi.connector.GetHandlers}
522

523
    """
524
    return "".join(map(cls._UriPatternToString, args))
525

    
526
  @staticmethod
527
  def _UriPatternToString(value):
528
    """Converts L{rapi.connector.UriPattern} to strings.
529

530
    """
531
    if isinstance(value, rapi.connector.UriPattern):
532
      return value.content
533
    else:
534
      return value
535

    
536

    
537
def setup(app):
538
  """Sphinx extension callback.
539

540
  """
541
  # TODO: Implement Sphinx directive for query fields
542
  app.add_directive("opcode_params", OpcodeParams)
543
  app.add_directive("opcode_result", OpcodeResult)
544
  app.add_directive("pyassert", PythonAssert)
545
  app.add_role("pyeval", PythonEvalRole)
546
  app.add_directive("rapi_access_table", RapiAccessTable)
547

    
548
  app.add_config_value("enable_manpages", False, True)
549
  app.add_role("manpage", _ManPageRole)