Source code for cloud_sptheme.ext.autodoc_sections

"""cloud_sptheme.ext.autodoc_sections - support ReST sections in docstrings"""
#=============================================================================
# imports
#=============================================================================
# core
import inspect
import re
import logging; log = logging.getLogger(__name__)
# site
# pkg
from cloud_sptheme import __version__
from cloud_sptheme.utils import u, patchapplier, monkeypatch
# local
__all__ = [
    "setup",
]

#=============================================================================
# internal helpers used for hacking up sphinx. almost ashamed to be doing this.
#=============================================================================
def get_caller_value(module, var, rtype=None, code=None):
    """
    helper which looks for nearest ancestor in call stack
    which occurred in module, and return specified local variable.

    :param module:
        name of module to look for on stack

    :param var:
        name of local variable to return

    :param rtype:
        do optional type-check to ensure **name** has expected type.

    :param code:
        optionally match based on function name (``frame.f_code.co_name``)
        as well as module.
    """
    frame = None
    try:
        frame = inspect.currentframe().f_back
        while True:
            if ((frame.f_globals.get("__name__") == module) and
                (not code or frame.f_code.co_name == code)):
                    break
            frame = frame.f_back
            if not frame:
                raise RuntimeError("couldn't find module=%r, code=%r in call stack" %
                                   (module, code or '<any>'))
        value = frame.f_locals[var]
        if rtype and not isinstance(value, rtype):
            raise TypeError("%s: expected a %r instance: %r" % (var, rtype, value))
        return value
    finally:
        frame = None

#=============================================================================
# autodoc monkeypatches / hacks
#=============================================================================
@patchapplier
def _patch_sphinx():
    """
    helper which monkeypatches sphinx to install some of our hooks.
    """

    #----------------------------------------------------------------------
    # patch document.note_implicit_target() to look for _modify_new_desc_section
    # attribute, as signal that it should munge up node that's passed to it,
    # to represent new description-level section, rather than document-level section.
    # this flag is then set by RSTState.new_subsection() patch, below.
    # NOTE: ideally, all this action could be done by a hook w/in
    #       RSTState.new_subsection(), but would have to modify source.
    #----------------------------------------------------------------------
    from docutils.nodes import document, make_id

    @monkeypatch(document)
    def note_implicit_target(_wrapped, self, target, *args, **kwds):
        # use default behavior unless signal flag is set
        entry = self._modify_new_desc_section
        if not entry:
            return _wrapped(self, target, *args, **kwds)
        self._modify_new_desc_section = None

        # NOTE: target should be section() node we're modifying,
        #       as we just got called from RSTState.new_subsection(),
        #       which is what sets modify_new_desc_section flag.
        #       'entry' should be last item in memo.desc_stack
        #       (see below).

        # add our custom css classes for styling
        # NOTE: adding 'section-header' class to H<N> node,
        #       so that our css rules don't have to be duplicated for every H<N> value.
        target['classes'].append("desc-section")
        target['classes'].append("desc-section-%d" % entry['level'])
        target[0]['classes'].append("section-header")

        # for duration of call, modify settings.id_prefix to include
        # decription prefix in any auto-generated ids. this helpers
        # section names remaining unique even if used between classes
        # (e.g. common names such as 'Constructor Options')
        settings = self.settings
        orig = settings.id_prefix
        try:
            if entry['prefix']:
                if orig:
                    settings.id_prefix = u("%s-%s") % (settings.id_prefix, entry['prefix'])
                else:
                    settings.id_prefix = entry['prefix']
            return _wrapped(self, target, *args, **kwds)
        finally:
            settings.id_prefix = orig

    document._modify_new_desc_section = None

    #----------------------------------------------------------------------
    # patch RSTState.new_subsection() to generate sections nested within
    # a description. It reads ``memo.desc_stack`` to determine if it's within
    # a description. If set, this attr should be a list of dicts,
    # each entry representing a nested description (e.g. an ObjectDescription)
    # whose content is being parsed, most recent should be last.
    # Each entry should be a dict containing:
    #
    # If there are no description entries active, the normal behavior is used.
    #
    # Each dict should contain the following keys:
    #   * prefix -- None, or string to use as prefix for section identifiers.
    #               helps keep links unique w/in document.
    #   * signode -- signature node used to generate prefix (for debugging)
    #   * owner -- arbitary object (ObjectDescription in our case)
    #              which added this entry to list. intended as sanity check
    #              when popping entries back off stack.
    #   * level -- section level w/in declaration (autoset by code below)
    #----------------------------------------------------------------------
    from docutils.parsers.rst.states import RSTState

    @monkeypatch(RSTState)
    def new_subsection(_wrapped, self, *args, **kwds):
        desc_stack = getattr(self.memo, "desc_stack", None)
        if desc_stack:
            # after new_subsection() creates section node,
            # it will invoke document.note_implicit_target().
            # setting this attr signals our monkeypatch of that method (above)
            # to make changes to that node based on desc_stack entry.
            # NOTE: ideally, the note_implicit_target() monkeypatch,
            #       as well as this code, would be placed inside RSTState.new_subsection(),
            #       but that would require modifying sphinx's source :(
            entry = desc_stack[-1]
            entry['level'] = self.memo.section_level+1 # set level w/in description
            self.document._modify_new_desc_section = entry # enable note hack

        # hand off to real method
        return _wrapped(self, *args, **kwds)

    #----------------------------------------------------------------------
    # monkeypatch ObjectDescription.run() so that:
    # 1. before calling state.nested_parse(), we push a desc context
    #    onto state_machine.desc_context_stack (see above).
    # 2. when it does call state.nested_parse() the first time,
    #    ``match_titles=True`` gets set.
    #    FIXME: using a really awkward way to accomplish this :|
    # 3. pop our context off desc_context_stack when done.
    #----------------------------------------------------------------------
    from sphinx.directives import ObjectDescription
    from sphinx.addnodes import desc as DescNodeType

    @monkeypatch(ObjectDescription)
    def run(_wrapped_run, self):
        # ObjectDescription.before_content() will be invoked right before
        # run calls ``self.state.nested_parse()``. We take advantage of that
        # by wrapping the instance's before_content() call so that the last
        # thing is does is set up ``self.state`` the way we want.

        # NOTE: have to patch per-instance, since subclasses that override this
        #       don't tend to invoke super()
        @monkeypatch(self)
        def before_content(_wrapped_before):
            # let real method do all the setup it wants.
            _wrapped_before()

            #----------------------------------------------------------------------
            # need to figure out prefix to prepend to our description sections,
            # so their IDs are unique. for now, using signature node
            # ObjectDescription.run() has finished generating right before before_content()
            # was called. Unfortunately, it's not available via self,
            # so we have to reach into call stack to grab it...
            # NOTE: ideally we would do this in ObjectDescription.run().
            #----------------------------------------------------------------------
            node = get_caller_value("sphinx.directives", "node",
                                    rtype=DescNodeType, code="run")
            # FIXME: would like a more bullet-proof way of deriving our id prefix...
            signode = node.children[0]
            if signode.get("ids"):
                base = signode['ids'][0]
            elif signode.get("names"):
                base = signode['names'][0]
            else:
                base = signode.astext()
            prefix = re.sub("[^a-zA-Z0-9_.]+", "-", base).strip("-") + "-"

            # now that we've got that info, add our description context entry
            # to the stack
            memo = self.state.memo
            if not hasattr(memo, "desc_stack"):
                memo.desc_stack = []
            memo.desc_stack.append(dict( # see new_subsection() above for dict format
                prefix=prefix,
                owner=self,
                signode=signode,
                level=0,
            ))

            #----------------------------------------------------------------------
            # hack up ``state.nested_parse()`` so that the next time it's called,
            # 'match_titles=True' is set. that call should happen as soon as this
            # function returns back to ObjectDescription.run()
            # NOTE: ideally, we would just set match_titles=True within ObjectDescription.run()
            #----------------------------------------------------------------------
            state = self.state
            if not hasattr(state, "_set_next_match_titles_flag"):
                # state is persistent object, only want to patch it once.
                @monkeypatch(state)
                def nested_parse(_wrapped_parse, *args, **kwds):
                    if state._set_next_match_titles_flag:
                        kwds['match_titles'] = True
                        state._set_next_match_titles_flag = False
                    return _wrapped_parse(*args, **kwds)

            # signal our hack to set match_titles
            state._set_next_match_titles_flag = True

        @monkeypatch(self)
        def after_content(_wrapped_after):
            # remove our description context entry
            # NOTE: ideally would do this in ObjectDescription.run()
            desc = self.state.memo.desc_stack.pop()
            assert desc['owner'] is self, "sanity check failed"

            # let real method do it's work
            return _wrapped_after()

        # now invoke the real run() method.
        # as soon as it calls before_content(), our hack above will patch self.state.
        # after before_content() returns, real run method will call self.state.nested_parse(),
        # and invoke our patched version instead.
        return _wrapped_run(self)

    #----------------------------------------------------------------------
    # make autodoc invoke parse_nested_section_with_titles() for ALL objects
    # if this isn't done, autodoc generates paragraphs instead of sections.
    # this causes all nested content to be omitted
    # FIXME: why is the lack of this causing a problem? should track it down.
    #----------------------------------------------------------------------
    from sphinx.ext.autodoc import Documenter
    Documenter.titles_allowed = True

    #----------------------------------------------------------------------
    # finally, monkeypatch DocFieldTransformer.transform_all()
    # so that it transforms doc fields  within one of our nested sections
    # (default code only looks at top-level nodes)
    #
    # FIXME: find a cleaner way to do this :|
    #----------------------------------------------------------------------
    from sphinx.util.docfields import DocFieldTransformer
    from docutils.nodes import section

    @monkeypatch(DocFieldTransformer)
    def transform_all(_wrapped, self, node):
        # transform immediate node contents like normal
        _wrapped(self, node)

        # our nested sections show up as definition lists,
        # so make sure transform_all is also invoked for the contents
        # of any definition list
        for child in node:
            if isinstance(child, section):
                _wrapped(self, child.children)

    #----------------------------------------------------------------------
    # sigh. done monkeypatching.
    #----------------------------------------------------------------------

#=============================================================================
# docstring mangling
#=============================================================================
def trim_module_header(app, what, name, obj, options, lines):
    """
    helper to remove one-line description from top of module (if preset).
    """
    if what != "module":
        return
    _title_re = re.compile(r"""
        ^ \s*
        ( {0} \s* -- \s* )?
        [a-z0-9 _."']*
        $
    """.format(re.escape(name)), re.X|re.I)
    if len(lines) > 1 and _title_re.match(lines[0]) and lines[1].strip() == '':
        del lines[:2]

#=============================================================================
# sphinx extension entrypoint
#=============================================================================
def setup(app):
    # don't patch sphinx unless this extension is actually in use
    _patch_sphinx()

    # clean up leading bit of module docstring
    app.connect('autodoc-process-docstring', trim_module_header)

    # identifies the version of our extension
    return {'version': __version__}

#=============================================================================
# documentation helper
#
# NOTE: this function doesn't actually do anything,
#       it exists to test this extension's behavior as part of docs/cloud_theme_test
#=============================================================================
[docs]def _doctestfunc(): """ The :mod:`~cloud_sptheme.ext.autodoc_sections` extension should generate nested sections as found within object docstrings. Nested Section ============== :param arg: xxx .. attribute:: foo :noindex: bar These sections can in turn contain others: Child Section ------------- Which allows breaking long class docstrings up in meaningful ways. Child Section 2 --------------- And more content Nested Section 2 ================ end of class """ pass
#============================================================================= # eof #=============================================================================