diff --git a/sphinx/util/typing.py b/sphinx/util/typing.py index 87707d48f48..81fbd736271 100644 --- a/sphinx/util/typing.py +++ b/sphinx/util/typing.py @@ -74,10 +74,10 @@ def _evaluate(self, globalns: Dict, localns: Dict) -> Any: def get_type_hints(obj: Any, globalns: Dict = None, localns: Dict = None) -> Dict[str, Any]: - """Return a dictionary containing type hints for a function, method, module or class object. + """Return collected type hints for a callable, module, or class. - This is a simple wrapper of `typing.get_type_hints()` that does not raise an error on - runtime. + This is a simple wrapper of ``typing.get_type_hints()`` that does not raise an error + at runtime. """ from sphinx.util.inspect import safe_getattr # lazy loading @@ -192,10 +192,40 @@ def _restify_py37(cls: Optional[Type]) -> str: elif isinstance(cls, typing._SpecialForm): return ':py:obj:`~%s.%s`' % (cls.__module__, cls._name) elif hasattr(cls, '__qualname__'): - if cls.__module__ == 'typing': - return ':py:class:`~%s.%s`' % (cls.__module__, cls.__qualname__) - else: - return ':py:class:`%s.%s`' % (cls.__module__, cls.__qualname__) + module = getattr(cls, '__module__', None) + + def _normalized(value: Any) -> Optional[str]: + if value is None: + return None + text = value if isinstance(value, str) else str(value) + text = text.strip() + if not text: + return None + if module and text.startswith(module + '.'): + text = text[len(module) + 1:] + if text.endswith('.__name__'): + text = text[: -len('.__name__')] + elif text.endswith('.__qualname__'): + text = text[: -len('.__qualname__')] + return text + + qualname = _normalized(getattr(cls, '__qualname__', None)) + if not qualname: + qualname = _normalized(getattr(cls, '__name__', None)) + + if qualname: + if module == 'typing': + return ':py:class:`~%s.%s`' % (module, qualname) + elif module: + return ':py:class:`%s.%s`' % (module, qualname) + else: + return ':py:class:`%s`' % qualname + fallback = _normalized(getattr(cls, '__name__', None)) + if fallback: + if module: + return ':py:class:`%s.%s`' % (module, fallback) + return ':py:class:`%s`' % fallback + return repr(cls) elif isinstance(cls, ForwardRef): return ':py:class:`%s`' % cls.__forward_arg__ else: @@ -208,19 +238,58 @@ def _restify_py37(cls: Optional[Type]) -> str: def _restify_py36(cls: Optional[Type]) -> str: module = getattr(cls, '__module__', None) + + def _normalized(value: Any) -> Optional[str]: + if value is None: + return None + text = value if isinstance(value, str) else str(value) + text = text.strip() + if not text: + return None + if module and text.startswith(module + '.'): + text = text[len(module) + 1:] + if text.endswith('.__name__'): + text = text[: -len('.__name__')] + elif text.endswith('.__qualname__'): + text = text[: -len('.__qualname__')] + return text + if module == 'typing': if getattr(cls, '_name', None): qualname = cls._name - elif getattr(cls, '__qualname__', None): - qualname = cls.__qualname__ - elif getattr(cls, '__forward_arg__', None): - qualname = cls.__forward_arg__ - elif getattr(cls, '__origin__', None): - qualname = stringify(cls.__origin__) # ex. Union else: - qualname = repr(cls).replace('typing.', '') + qualname = _normalized(getattr(cls, '__qualname__', None)) + if not qualname: + qualname = _normalized(getattr(cls, '__name__', None)) + + if not qualname and getattr(cls, '__forward_arg__', None): + qualname = cls.__forward_arg__ + elif not qualname and getattr(cls, '__origin__', None): + qualname = stringify(cls.__origin__) # ex. Union + elif not qualname: + qualname = repr(cls).replace('typing.', '') elif hasattr(cls, '__qualname__'): - qualname = '%s.%s' % (module, cls.__qualname__) + qual = _normalized(getattr(cls, '__qualname__', None)) + if qual: + qualname = '%s.%s' % (module, qual) if module else qual + else: + fallback = _normalized(getattr(cls, '__name__', None)) + if fallback: + qualname = '%s.%s' % (module, fallback) if module else fallback + else: + qualname = repr(cls) + elif getattr(cls, '__forward_arg__', None): + qualname = cls.__forward_arg__ + elif getattr(cls, '__origin__', None): + qualname = stringify(cls.__origin__) + elif hasattr(cls, '_name') and getattr(cls, '_name'): + qualname = getattr(cls, '_name') + elif hasattr(cls, '__name__'): + qual = _normalized(getattr(cls, '__name__', None)) + if qual: + qualname = '%s.%s' % (module, qual) if module else qual + else: + qualname = repr(cls) else: qualname = repr(cls) @@ -274,10 +343,25 @@ def _restify_py36(cls: Optional[Type]) -> str: else: return ':py:obj:`Union`' elif hasattr(cls, '__qualname__'): - if cls.__module__ == 'typing': - return ':py:class:`~%s.%s`' % (cls.__module__, cls.__qualname__) - else: - return ':py:class:`%s.%s`' % (cls.__module__, cls.__qualname__) + qual = _normalized(getattr(cls, '__qualname__', None)) + if not qual: + qual = _normalized(getattr(cls, '__name__', None)) + + if qual: + if module == 'typing': + return ':py:class:`~%s.%s`' % (module, qual) + elif module: + return ':py:class:`%s.%s`' % (module, qual) + else: + return ':py:class:`%s`' % qual + + fallback = _normalized(getattr(cls, '__name__', None)) + if fallback: + if module: + return ':py:class:`%s.%s`' % (module, fallback) + return ':py:class:`%s`' % fallback + + return repr(cls) elif hasattr(cls, '_name'): # SpecialForm if cls.__module__ == 'typing': diff --git a/tests/roots/test-autodoc-mocked-bases/conf.py b/tests/roots/test-autodoc-mocked-bases/conf.py new file mode 100644 index 00000000000..6ca4397971f --- /dev/null +++ b/tests/roots/test-autodoc-mocked-bases/conf.py @@ -0,0 +1,12 @@ +import os +import sys + +sys.path.insert(0, os.path.abspath('.')) + +extensions = ['sphinx.ext.autodoc'] + +source_suffix = '.rst' + +autodoc_mock_imports = ['torch'] + +nitpicky = True diff --git a/tests/roots/test-autodoc-mocked-bases/index.rst b/tests/roots/test-autodoc-mocked-bases/index.rst new file mode 100644 index 00000000000..08c642dda61 --- /dev/null +++ b/tests/roots/test-autodoc-mocked-bases/index.rst @@ -0,0 +1,6 @@ +Mocked Bases +============ + +.. automodule:: mocked + :members: + :show-inheritance: diff --git a/tests/roots/test-autodoc-mocked-bases/mocked.py b/tests/roots/test-autodoc-mocked-bases/mocked.py new file mode 100644 index 00000000000..308946ba91d --- /dev/null +++ b/tests/roots/test-autodoc-mocked-bases/mocked.py @@ -0,0 +1,7 @@ +import torch.nn as nn + + +class ModuleSubclass(nn.Module): + """Docstring for a mocked torch module subclass.""" + + pass diff --git a/tests/test_ext_autodoc.py b/tests/test_ext_autodoc.py index db5760cd143..02093cba15e 100644 --- a/tests/test_ext_autodoc.py +++ b/tests/test_ext_autodoc.py @@ -1030,6 +1030,15 @@ def test_autodoc_classmethod(app): ] +@pytest.mark.sphinx('html', testroot='autodoc-mocked-bases') +def test_autodoc_mocked_bases_rendering(app): + options = {'show-inheritance': None} + + actual = do_autodoc(app, 'class', 'mocked.ModuleSubclass', options) + + assert ' Bases: :py:class:`torch.nn.Module`' in list(actual) + + @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_autodoc_staticmethod(app): actual = do_autodoc(app, 'method', 'target.inheritance.Base.inheritedstaticmeth') diff --git a/tests/test_util_typing.py b/tests/test_util_typing.py index d493a004035..640fc349154 100644 --- a/tests/test_util_typing.py +++ b/tests/test_util_typing.py @@ -9,6 +9,7 @@ """ import sys +import typing from numbers import Integral from struct import Struct from types import TracebackType @@ -17,7 +18,7 @@ import pytest -from sphinx.util.typing import restify, stringify +from sphinx.util.typing import _restify_py36, restify, stringify class MyClass1: @@ -129,6 +130,39 @@ def test_restify_type_hints_custom_class(): assert restify(MyClass2) == ":py:class:`tests.test_util_typing.`" +def test_restify_mocked_class_falls_back_to_name(): + class MockModule: + pass + + MockModule.__module__ = 'torch.nn' + MockModule.__name__ = 'Module' + MockModule.__qualname__ = '' + + assert restify(MockModule) == ":py:class:`torch.nn.Module`" + + MockModule.__qualname__ = 'Module' + assert restify(MockModule) == ":py:class:`torch.nn.Module`" + + +def test_restify_py36_mocked_class_falls_back_to_name(monkeypatch): + if not hasattr(typing, 'TupleMeta'): + monkeypatch.setattr(typing, 'TupleMeta', type('TupleMeta', (type,), {}), raising=False) + if not hasattr(typing, 'GenericMeta'): + monkeypatch.setattr(typing, 'GenericMeta', type('GenericMeta', (type,), {}), raising=False) + + class MockModule: + pass + + MockModule.__module__ = 'torch.nn' + MockModule.__name__ = 'Module' + MockModule.__qualname__ = '' + + assert _restify_py36(MockModule) == ":py:class:`torch.nn.Module`" + + MockModule.__qualname__ = 'Module' + assert _restify_py36(MockModule) == ":py:class:`torch.nn.Module`" + + def test_restify_type_hints_alias(): MyStr = str MyTuple = Tuple[str, str]