diff --git a/CHANGES b/CHANGES index ef0bf0440..e33793525 100644 --- a/CHANGES +++ b/CHANGES @@ -40,6 +40,7 @@ Bugs fixed * autodoc: ImportError is replaced by AttributeError for deeper module * #2720, #4034: Incorrect links with ``:download:``, duplicate names, and parallel builds +* #5290: autodoc: failed to analyze source code in egg package Testing -------- diff --git a/sphinx/pycode/__init__.py b/sphinx/pycode/__init__.py index c4c055bf5..a97903a27 100644 --- a/sphinx/pycode/__init__.py +++ b/sphinx/pycode/__init__.py @@ -10,6 +10,9 @@ """ from __future__ import print_function +import re +from zipfile import ZipFile + from six import iteritems, BytesIO, StringIO from sphinx.errors import PycodeError @@ -42,9 +45,23 @@ class ModuleAnalyzer(object): obj = cls(f, modname, filename) # type: ignore cls.cache['file', filename] = obj except Exception as err: - raise PycodeError('error opening %r' % filename, err) + if '.egg/' in filename: + obj = cls.cache['file', filename] = cls.for_egg(filename, modname) + else: + raise PycodeError('error opening %r' % filename, err) return obj + @classmethod + def for_egg(cls, filename, modname): + # type: (unicode, unicode) -> ModuleAnalyzer + eggpath, relpath = re.split('(?<=\\.egg)/', filename) + try: + with ZipFile(eggpath) as egg: + code = egg.read(relpath).decode('utf-8') + return cls.for_string(code, modname, filename) + except Exception as exc: + raise PycodeError('error opening %r' % filename, exc) + @classmethod def for_module(cls, modname): # type: (str) -> ModuleAnalyzer diff --git a/sphinx/util/__init__.py b/sphinx/util/__init__.py index 29038978a..0c0c22c9c 100644 --- a/sphinx/util/__init__.py +++ b/sphinx/util/__init__.py @@ -314,6 +314,11 @@ def get_module_source(modname): filename += 'w' elif not (lfilename.endswith('.py') or lfilename.endswith('.pyw')): raise PycodeError('source is not a .py file: %r' % filename) + elif '.egg' in filename: + eggpath, _ = re.split('(?<=\\.egg)/', filename) + if path.isfile(eggpath): + return 'file', filename + if not path.isfile(filename): raise PycodeError('source file is not present: %r' % filename) return 'file', filename diff --git a/tests/roots/test-pycode-egg/conf.py b/tests/roots/test-pycode-egg/conf.py new file mode 100644 index 000000000..a8e25882b --- /dev/null +++ b/tests/roots/test-pycode-egg/conf.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- + +import os +import sys + +sys.path.insert(0, os.path.abspath('sample-0.0.0-py3.7.egg')) +master_doc = 'index' +extensions = ['sphinx.ext.autodoc'] diff --git a/tests/roots/test-pycode-egg/index.rst b/tests/roots/test-pycode-egg/index.rst new file mode 100644 index 000000000..affc7912a --- /dev/null +++ b/tests/roots/test-pycode-egg/index.rst @@ -0,0 +1,2 @@ +test-pycode-egg +=============== diff --git a/tests/roots/test-pycode-egg/sample-0.0.0-py3.7.egg b/tests/roots/test-pycode-egg/sample-0.0.0-py3.7.egg new file mode 100644 index 000000000..719dbea51 Binary files /dev/null and b/tests/roots/test-pycode-egg/sample-0.0.0-py3.7.egg differ diff --git a/tests/roots/test-pycode-egg/src/sample.py b/tests/roots/test-pycode-egg/src/sample.py new file mode 100644 index 000000000..c4d3d61e8 --- /dev/null +++ b/tests/roots/test-pycode-egg/src/sample.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- + +#: constant on sample.py +CONSTANT = 1 + + +def hello(s): + print('Hello %s' % s) diff --git a/tests/roots/test-pycode-egg/src/setup.py b/tests/roots/test-pycode-egg/src/setup.py new file mode 100644 index 000000000..f23c80a06 --- /dev/null +++ b/tests/roots/test-pycode-egg/src/setup.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +from setuptools import setup + + +setup(name='sample', + py_modules=['sample']) diff --git a/tests/test_autodoc.py b/tests/test_autodoc.py index cefceb833..e2dc37c56 100644 --- a/tests/test_autodoc.py +++ b/tests/test_autodoc.py @@ -1584,3 +1584,26 @@ def test_autodoc_default_options_with_values(app): assert ' list of weak references to the object (if defined)' not in actual assert ' .. py:method:: CustomIter.snafucate()' not in actual assert ' Makes this snafucated.' not in actual + + +@pytest.mark.sphinx('html', testroot='pycode-egg') +def test_autodoc_for_egged_code(app): + options = {"members": None, + "undoc-members": None} + actual = do_autodoc(app, 'module', 'sample', options) + assert list(actual) == [ + '', + '.. py:module:: sample', + '', + '', + '.. py:data:: CONSTANT', + ' :module: sample', + ' :annotation: = 1', + '', + ' constant on sample.py', + ' ', + '', + '.. py:function:: hello(s)', + ' :module: sample', + '' + ] diff --git a/tests/test_pycode.py b/tests/test_pycode.py index b4385e8a6..2eab456bc 100644 --- a/tests/test_pycode.py +++ b/tests/test_pycode.py @@ -10,6 +10,7 @@ """ import os +import sys from six import PY2 @@ -47,6 +48,31 @@ def test_ModuleAnalyzer_for_module(): assert analyzer.encoding == 'utf-8' +def test_ModuleAnalyzer_for_file_in_egg(rootdir): + try: + path = rootdir / 'test-pycode-egg' / 'sample-0.0.0-py3.7.egg' + sys.path.insert(0, path) + + import sample + analyzer = ModuleAnalyzer.for_file(sample.__file__, 'sample') + docs = analyzer.find_attr_docs() + assert docs == {('', 'CONSTANT'): ['constant on sample.py', '']} + finally: + sys.path.pop(0) + + +def test_ModuleAnalyzer_for_module_in_egg(rootdir): + try: + path = rootdir / 'test-pycode-egg' / 'sample-0.0.0-py3.7.egg' + sys.path.insert(0, path) + + analyzer = ModuleAnalyzer.for_module('sample') + docs = analyzer.find_attr_docs() + assert docs == {('', 'CONSTANT'): ['constant on sample.py', '']} + finally: + sys.path.pop(0) + + def test_ModuleAnalyzer_find_tags(): code = ('class Foo(object):\n' # line: 1 ' """class Foo!"""\n'