diff --git a/CHANGES b/CHANGES index acc3ce1ca..f824827f7 100644 --- a/CHANGES +++ b/CHANGES @@ -55,6 +55,7 @@ Features added * Automatically compile ``*.mo`` files from ``*.po`` files when :confval:`gettext_auto_build` is True (default) and ``*.po`` is newer than ``*.mo`` file. +* #623: :mod:`~sphinx.ext.viewcode` support imported function/class aliases. Bugs fixed ---------- diff --git a/doc/ext/viewcode.rst b/doc/ext/viewcode.rst index 6e77914d3..f2b6c9283 100644 --- a/doc/ext/viewcode.rst +++ b/doc/ext/viewcode.rst @@ -18,3 +18,23 @@ from the source to the description will also be inserted. There are currently no configuration values for this extension; you just need to add ``'sphinx.ext.viewcode'`` to your :confval:`extensions` value for it to work. + +There is also an additional config value: + +.. confval:: viewcode_import + + If this is ``True``, viewcode extension will follow alias objects that + imported from another module such as functions, classes and attributes. + As side effects, this option + else they produce nothing. The default is ``True``. + + .. warning:: + + :confval:`viewcode_import` **imports** the modules to be followed real + location. If any modules have side effects on import, these will be + executed by ``viewcode`` when ``sphinx-build`` is run. + + If you document scripts (as opposed to library modules), make sure their + main routine is protected by a ``if __name__ == '__main__'`` condition. + + .. versionadded:: 1.3 diff --git a/sphinx/ext/viewcode.py b/sphinx/ext/viewcode.py index 4a62bf6d6..9976ecc4a 100644 --- a/sphinx/ext/viewcode.py +++ b/sphinx/ext/viewcode.py @@ -9,21 +9,43 @@ :license: BSD, see LICENSE for details. """ +import traceback + from six import iteritems, text_type from docutils import nodes from sphinx import addnodes from sphinx.locale import _ from sphinx.pycode import ModuleAnalyzer +from sphinx.util import get_full_modname from sphinx.util.nodes import make_refnode +def _get_full_modname(app, modname, attribute): + try: + return get_full_modname(modname, attribute) + except AttributeError: + # sphinx.ext.viewcode can't follow class instance attribute + # then AttributeError logging output only verbose mode. + app.verbose('Didn\'t find %s in %s' % (attribute, modname)) + return None + except Exception as e: + # sphinx.ext.viewcode follow python domain directives. + # because of that, if there are no real modules exists that specified + # by py:function or other directives, viewcode emits a lot of warnings. + # It should be displayed only verbose mode. + app.verbose(traceback.format_exc().rstrip()) + app.verbose('viewcode can\'t import %s, failed with error "%s"' % + (modname, e)) + return None + + def doctree_read(app, doctree): env = app.builder.env if not hasattr(env, '_viewcode_modules'): env._viewcode_modules = {} - def has_tag(modname, fullname, docname): + def has_tag(modname, fullname, docname, refname): entry = env._viewcode_modules.get(modname, None) try: analyzer = ModuleAnalyzer.for_module(modname) @@ -36,11 +58,11 @@ def doctree_read(app, doctree): code = analyzer.code if entry is None or entry[0] != code: analyzer.find_tags() - entry = code, analyzer.tags, {} + entry = code, analyzer.tags, {}, refname env._viewcode_modules[modname] = entry elif entry is False: return - code, tags, used = entry + _, tags, used, _ = entry if fullname in tags: used[fullname] = docname return True @@ -53,10 +75,14 @@ def doctree_read(app, doctree): if not isinstance(signode, addnodes.desc_signature): continue modname = signode.get('module') + fullname = signode.get('fullname') + refname = modname + if env.config.viewcode_import: + modname = _get_full_modname(app, modname, fullname) if not modname: continue fullname = signode.get('fullname') - if not has_tag(modname, fullname, env.docname): + if not has_tag(modname, fullname, env.docname, refname): continue if fullname in names: # only one link per name, please @@ -95,7 +121,7 @@ def collect_pages(app): for modname, entry in iteritems(env._viewcode_modules): if not entry: continue - code, tags, used = entry + code, tags, used, refname = entry # construct a page name for the highlighted source pagename = '_modules/' + modname.replace('.', '/') # highlight the source using the builder's highlighter @@ -112,7 +138,7 @@ def collect_pages(app): maxindex = len(lines) - 1 for name, docname in iteritems(used): type, start, end = tags[name] - backlink = urito(pagename, docname) + '#' + modname + '.' + name + backlink = urito(pagename, docname) + '#' + refname + '.' + name lines[start] = ( '
%s' % (name, backlink, _('[docs]')) @@ -171,6 +197,7 @@ def collect_pages(app): def setup(app): + app.add_config_value('viewcode_import', True, False) app.connect('doctree-read', doctree_read) app.connect('html-collect-pages', collect_pages) app.connect('missing-reference', missing_reference) diff --git a/sphinx/util/__init__.py b/sphinx/util/__init__.py index 2c6731a22..0f11a4c54 100644 --- a/sphinx/util/__init__.py +++ b/sphinx/util/__init__.py @@ -238,6 +238,20 @@ def get_module_source(modname): return 'file', filename +def get_full_modname(modname, attribute): + __import__(modname) + module = sys.modules[modname] + + # Allow an attribute to have multiple parts and incidentially allow + # repeated .s in the attribute. + value = module + for attr in attribute.split('.'): + if attr: + value = getattr(value, attr) + + return getattr(value, '__module__', None) + + # a regex to recognize coding cookies _coding_re = re.compile(r'coding[:=]\s*([-\w.]+)') diff --git a/tests/roots/test-ext-viewcode/conf.py b/tests/roots/test-ext-viewcode/conf.py new file mode 100644 index 000000000..946cb786d --- /dev/null +++ b/tests/roots/test-ext-viewcode/conf.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- + +import sys +import os + +sys.path.insert(0, os.path.abspath('.')) +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] +master_doc = 'index' diff --git a/tests/roots/test-ext-viewcode/index.rst b/tests/roots/test-ext-viewcode/index.rst new file mode 100644 index 000000000..72e943219 --- /dev/null +++ b/tests/roots/test-ext-viewcode/index.rst @@ -0,0 +1,29 @@ +viewcode +======== + +.. py:module:: spam + +.. autofunction:: func1 + +.. autofunction:: func2 + +.. autofunction:: spam.mod1.func1 + +.. autofunction:: spam.mod2.func2 + +.. autofunction:: Class1 + +.. autofunction:: Class2 + +.. autofunction:: spam.mod1.Class1 + +.. autofunction:: spam.mod2.Class2 + + +.. literalinclude:: spam/__init__.py + :language: python + :pyobject: func1 + +.. literalinclude:: spam/mod1.py + :language: python + :pyobject: func1 diff --git a/tests/roots/test-ext-viewcode/spam/__init__.py b/tests/roots/test-ext-viewcode/spam/__init__.py new file mode 100644 index 000000000..75430ddd0 --- /dev/null +++ b/tests/roots/test-ext-viewcode/spam/__init__.py @@ -0,0 +1,5 @@ +from mod1 import func1, Class1 +from mod2 import ( + func2, + Class2, +) diff --git a/tests/roots/test-ext-viewcode/spam/mod1.py b/tests/roots/test-ext-viewcode/spam/mod1.py new file mode 100644 index 000000000..e5eb0d473 --- /dev/null +++ b/tests/roots/test-ext-viewcode/spam/mod1.py @@ -0,0 +1,15 @@ +""" +mod1 +""" + +def func1(a, b): + """ + this is func1 + """ + return a, b + + +class Class1(object): + """ + this is Class1 + """ diff --git a/tests/roots/test-ext-viewcode/spam/mod2.py b/tests/roots/test-ext-viewcode/spam/mod2.py new file mode 100644 index 000000000..1841db1ec --- /dev/null +++ b/tests/roots/test-ext-viewcode/spam/mod2.py @@ -0,0 +1,15 @@ +""" +mod2 +""" + +def func2(a, b): + """ + this is func2 + """ + return a, b + + +class Class2(object): + """ + this is Class2 + """ diff --git a/tests/test_ext_viewcode.py b/tests/test_ext_viewcode.py new file mode 100644 index 000000000..deab0cb20 --- /dev/null +++ b/tests/test_ext_viewcode.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +""" + test_ext_viewcode + ~~~~~~~~~~~~~~~~~ + + Test sphinx.ext.viewcode extension. + + :copyright: Copyright 2007-2014 by the Sphinx team, see AUTHORS. + :license: BSD, see LICENSE for details. +""" + +import re +from StringIO import StringIO + +from util import test_roots, with_app + + +warnfile = StringIO() +root = test_roots / 'test-ext-viewcode' +doctreedir = root / '_build' / 'doctree' + + +def teardown_module(): + (root / '_build').rmtree(True) + + +@with_app(srcdir=root, warning=warnfile) +def test_simple(app): + app.builder.build_all() + + warnings = re.sub(r'\\+', '/', warnfile.getvalue()) + assert re.findall( + r"index.rst:\d+: WARNING: Object named 'func1' not found in include " + + r"file .*/spam/__init__.py'", + warnings + ) + + result = (app.outdir / 'index.html').text(encoding='utf-8') + assert result.count('href="_modules/spam/mod1.html#func1"') == 2 + assert result.count('href="_modules/spam/mod2.html#func2"') == 2