Skip to content
Snippets Groups Projects
sphinx_ext.py 11.70 KiB
#
#

# Copyright (C) 2011, 2012 Google Inc.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.


"""Sphinx extension for building opcode documentation.

"""

import re
from cStringIO import StringIO

import docutils.statemachine
import docutils.nodes
import docutils.utils
import docutils.parsers.rst

import sphinx.errors
import sphinx.util.compat
import sphinx.roles
import sphinx.addnodes

s_compat = sphinx.util.compat

try:
  # Access to a protected member of a client class
  # pylint: disable=W0212
  orig_manpage_role = docutils.parsers.rst.roles._roles["manpage"]
except (AttributeError, ValueError, KeyError), err:
  # Normally the "manpage" role is registered by sphinx/roles.py
  raise Exception("Can't find reST role named 'manpage': %s" % err)

from ganeti import constants
from ganeti import compat
from ganeti import errors
from ganeti import utils
from ganeti import opcodes
from ganeti import ht
from ganeti import rapi
from ganeti import luxi
from ganeti import objects
from ganeti import _autoconf

import ganeti.rapi.rlib2 # pylint: disable=W0611


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


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

  """


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

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

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

  return names


COMMON_PARAM_NAMES = _GetCommonParamNames()

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

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


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


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

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

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


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

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

  """
  result = {}

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

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

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

  return result


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

  @type op_id: string
  @param op_id: Opcode ID

  """
  op_cls = opcodes.OP_MAPPING[op_id]

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

  for (rapi_name, name, default, test, doc) in params_with_alias:
    # Hide common parameters if not explicitly included
    if (name in COMMON_PARAM_NAMES and
        (not include or name not in include)):
      continue
    if exclude is not None and name in exclude:
      continue
    if include is not None and name not in include:
      continue

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

    buf = StringIO()
    buf.write("``%s``" % rapi_name)
    if has_default or has_test:
      buf.write(" (")
      if has_default:
        buf.write("defaults to ``%s``" % default)
        if has_test:
          buf.write(", ")
      if has_test:
        buf.write("must be ``%s``" % test)
      buf.write(")")
    yield buf.getvalue()

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


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

  @type op_id: string
  @param op_id: Opcode ID

  """
  op_cls = opcodes.OP_MAPPING[op_id]

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

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

  return "``%s``" % result_fn


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

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

  """
  has_content = False
  required_arguments = 1
  optional_arguments = 0
  final_argument_whitespace = False
  option_spec = dict(include=_SplitOption, exclude=_SplitOption,
                     alias=_ParseAlias)

  def run(self):
    op_id = self.arguments[0]
    include = self.options.get("include", None)
    exclude = self.options.get("exclude", None)
    alias = self.options.get("alias", {})

    tab_width = 2
    path = op_id
    include_text = "\n".join(_BuildOpcodeParams(op_id, include, exclude, alias))

    # Inject into state machine
    include_lines = docutils.statemachine.string2lines(include_text, tab_width,
                                                       convert_whitespace=1)
    self.state_machine.insert_input(include_lines, path)

    return []


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

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

  """
  has_content = False
  required_arguments = 1
  optional_arguments = 0
  final_argument_whitespace = False

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

    tab_width = 2
    path = op_id
    include_text = _BuildOpcodeResult(op_id)

    # Inject into state machine
    include_lines = docutils.statemachine.string2lines(include_text, tab_width,
                                                       convert_whitespace=1)
    self.state_machine.insert_input(include_lines, path)

    return []


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

  The expression's result is included as a literal.

  """
  # pylint: disable=W0102,W0613,W0142
  # W0102: Dangerous default value as argument
  # W0142: Used * or ** magic
  # W0613: Unused argument

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

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

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

  return ([node], [])


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

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

  """
  has_content = True
  required_arguments = 0
  optional_arguments = 0
  final_argument_whitespace = False

  def run(self):
    # Handle combinations of Sphinx and docutils not providing the wanted method
    if hasattr(self, "assert_has_content"):
      self.assert_has_content()
    else:
      assert self.content

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

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

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

    return []


def BuildQueryFields(fields):
  """Build query fields documentation.

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

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


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

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

  """
  for name, doc in values:
    assert len(doc.splitlines()) == 1
    yield "``%s``" % name
    yield "  %s" % doc


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

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

  # Force custom title
  kwargs["refexplicit"] = True

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


class _ManPageXRefRole(sphinx.roles.XRefRole):
  def __init__(self):
    """Initializes this class.

    """
    sphinx.roles.XRefRole.__init__(self, nodeclass=_ManPageNodeClass,
                                   warn_dangling=True)

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

    self.converted = None

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

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

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

    name = m.group("name")
    section = int(m.group("section"))

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

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

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

    if self.converted:
      # Create link to known man page
      return (title, "man-%s" % name)
    else:
      # No changes
      return (title, target)


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

  Converts man pages to links if enabled during the build.

  """
  xref = _ManPageXRefRole()

  assert ht.TNone(xref.converted)

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

  assert ht.TBool(xref.converted)

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

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


def setup(app):
  """Sphinx extension callback.

  """
  # TODO: Implement Sphinx directive for query fields
  app.add_directive("opcode_params", OpcodeParams)
  app.add_directive("opcode_result", OpcodeResult)
  app.add_directive("pyassert", PythonAssert)
  app.add_role("pyeval", PythonEvalRole)

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