diff --git a/CHANGES b/CHANGES index 53567756f91..cd065a3a7d1 100644 --- a/CHANGES +++ b/CHANGES @@ -54,6 +54,9 @@ Features added Bugs fixed ---------- +* #8219: autodoc: Parameters for generic class are not shown when super class is + a generic class and show-inheritance option is given (in Python 3.7 or above) + Testing -------- diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index a0c5cf61f1d..1cb8475df4a 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -37,7 +37,7 @@ from sphinx.util.inspect import ( evaluate_signature, getdoc, object_description, safe_getattr, stringify_signature ) -from sphinx.util.typing import stringify as stringify_typehint +from sphinx.util.typing import restify, stringify as stringify_typehint if False: # For type annotation @@ -1574,13 +1574,16 @@ def add_directive_header(self, sig: str) -> None: if not self.doc_as_attr and self.options.show_inheritance: sourcename = self.get_sourcename() self.add_line('', sourcename) - if hasattr(self.object, '__bases__') and len(self.object.__bases__): - bases = [':class:`%s`' % b.__name__ - if b.__module__ in ('__builtin__', 'builtins') - else ':class:`%s.%s`' % (b.__module__, b.__qualname__) - for b in self.object.__bases__] - self.add_line(' ' + _('Bases: %s') % ', '.join(bases), - sourcename) + + if hasattr(self.object, '__orig_bases__') and len(self.object.__orig_bases__): + # A subclass of generic types + # refs: PEP-560 + bases = [restify(cls) for cls in self.object.__orig_bases__] + self.add_line(' ' + _('Bases: %s') % ', '.join(bases), sourcename) + elif hasattr(self.object, '__bases__') and len(self.object.__bases__): + # A normal class + bases = [restify(cls) for cls in self.object.__bases__] + self.add_line(' ' + _('Bases: %s') % ', '.join(bases), sourcename) def get_doc(self, encoding: str = None, ignore: int = None) -> List[List[str]]: if encoding is not None: diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index 27f478675e1..1eaccb6789e 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -312,6 +312,9 @@ def isgenericalias(obj: Any) -> bool: elif (hasattr(types, 'GenericAlias') and # only for py39+ isinstance(obj, types.GenericAlias)): # type: ignore return True + elif (hasattr(typing, '_SpecialGenericAlias') and # for py39+ + isinstance(obj, typing._SpecialGenericAlias)): # type: ignore + return True else: return False diff --git a/sphinx/util/typing.py b/sphinx/util/typing.py index 76438889b38..7b3509b62d6 100644 --- a/sphinx/util/typing.py +++ b/sphinx/util/typing.py @@ -10,7 +10,7 @@ import sys import typing -from typing import Any, Callable, Dict, Generator, List, Tuple, TypeVar, Union +from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, TypeVar, Union from docutils import nodes from docutils.parsers.rst.states import Inliner @@ -30,6 +30,10 @@ def _evaluate(self, globalns: Dict, localns: Dict) -> Any: ref = _ForwardRef(self.arg) return ref._eval_type(globalns, localns) +if False: + # For type annotation + from typing import Type # NOQA # for python3.5.1 + # An entry of Directive.option_spec DirectiveOption = Callable[[str], Any] @@ -60,6 +64,195 @@ def is_system_TypeVar(typ: Any) -> bool: return modname == 'typing' and isinstance(typ, TypeVar) +def restify(cls: Optional["Type"]) -> str: + """Convert python class to a reST reference.""" + if cls is None or cls is NoneType: + return ':obj:`None`' + elif cls is Ellipsis: + return '...' + elif cls.__module__ in ('__builtin__', 'builtins'): + return ':class:`%s`' % cls.__name__ + else: + if sys.version_info >= (3, 7): # py37+ + return _restify_py37(cls) + else: + return _restify_py36(cls) + + +def _restify_py37(cls: Optional["Type"]) -> str: + """Convert python class to a reST reference.""" + from sphinx.util import inspect # lazy loading + + if (inspect.isgenericalias(cls) and + cls.__module__ == 'typing' and cls.__origin__ is Union): + # Union + if len(cls.__args__) > 1 and cls.__args__[-1] is NoneType: + if len(cls.__args__) > 2: + args = ', '.join(restify(a) for a in cls.__args__[:-1]) + return ':obj:`Optional`\\ [:obj:`Union`\\ [%s]]' % args + else: + return ':obj:`Optional`\\ [%s]' % restify(cls.__args__[0]) + else: + args = ', '.join(restify(a) for a in cls.__args__) + return ':obj:`Union`\\ [%s]' % args + elif inspect.isgenericalias(cls): + if getattr(cls, '_name', None): + if cls.__module__ == 'typing': + text = ':class:`%s`' % cls._name + else: + text = ':class:`%s.%s`' % (cls.__module__, cls._name) + else: + text = restify(cls.__origin__) + + if not hasattr(cls, '__args__'): + pass + elif all(is_system_TypeVar(a) for a in cls.__args__): + # Suppress arguments if all system defined TypeVars (ex. Dict[KT, VT]) + pass + elif cls.__module__ == 'typing' and cls._name == 'Callable': + args = ', '.join(restify(a) for a in cls.__args__[:-1]) + text += r"\ [[%s], %s]" % (args, restify(cls.__args__[-1])) + elif cls.__args__: + text += r"\ [%s]" % ", ".join(restify(a) for a in cls.__args__) + + return text + elif hasattr(cls, '__qualname__'): + if cls.__module__ == 'typing': + return ':class:`%s`' % cls.__qualname__ + else: + return ':class:`%s.%s`' % (cls.__module__, cls.__qualname__) + elif hasattr(cls, '_name'): + # SpecialForm + if cls.__module__ == 'typing': + return ':obj:`%s`' % cls._name + else: + return ':obj:`%s.%s`' % (cls.__module__, cls._name) + else: + # not a class (ex. TypeVar) + return ':obj:`%s.%s`' % (cls.__module__, cls.__name__) + + +def _restify_py36(cls: Optional["Type"]) -> str: + module = getattr(cls, '__module__', None) + 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.', '') + elif hasattr(cls, '__qualname__'): + qualname = '%s.%s' % (module, cls.__qualname__) + else: + qualname = repr(cls) + + if (isinstance(cls, typing.TupleMeta) and # type: ignore + not hasattr(cls, '__tuple_params__')): # for Python 3.6 + params = cls.__args__ + if params: + param_str = ', '.join(restify(p) for p in params) + return ':class:`%s`\\ [%s]' % (qualname, param_str) + else: + return ':class:`%s`' % qualname + elif isinstance(cls, typing.GenericMeta): + params = None + if hasattr(cls, '__args__'): + # for Python 3.5.2+ + if cls.__args__ is None or len(cls.__args__) <= 2: # type: ignore # NOQA + params = cls.__args__ # type: ignore + elif cls.__origin__ == Generator: # type: ignore + params = cls.__args__ # type: ignore + else: # typing.Callable + args = ', '.join(restify(arg) for arg in cls.__args__[:-1]) # type: ignore + result = restify(cls.__args__[-1]) # type: ignore + return ':class:`%s`\\ [[%s], %s]' % (qualname, args, result) + elif hasattr(cls, '__parameters__'): + # for Python 3.5.0 and 3.5.1 + params = cls.__parameters__ # type: ignore + + if params: + param_str = ', '.join(restify(p) for p in params) + return ':class:`%s`\\ [%s]' % (qualname, param_str) + else: + return ':class:`%s`' % qualname + elif (hasattr(typing, 'UnionMeta') and + isinstance(cls, typing.UnionMeta) and # type: ignore + hasattr(cls, '__union_params__')): # for Python 3.5 + params = cls.__union_params__ + if params is not None: + if len(params) == 2 and params[1] is NoneType: + return ':obj:`Optional`\\ [%s]' % restify(params[0]) + else: + param_str = ', '.join(restify(p) for p in params) + return ':obj:`%s`\\ [%s]' % (qualname, param_str) + else: + return ':obj:`%s`' % qualname + elif (hasattr(cls, '__origin__') and + cls.__origin__ is typing.Union): # for Python 3.5.2+ + params = cls.__args__ + if params is not None: + if len(params) > 1 and params[-1] is NoneType: + if len(params) > 2: + param_str = ", ".join(restify(p) for p in params[:-1]) + return ':obj:`Optional`\\ [:obj:`Union`\\ [%s]]' % param_str + else: + return ':obj:`Optional`\\ [%s]' % restify(params[0]) + else: + param_str = ', '.join(restify(p) for p in params) + return ':obj:`Union`\\ [%s]' % param_str + else: + return ':obj:`Union`' + elif (isinstance(cls, typing.CallableMeta) and # type: ignore + getattr(cls, '__args__', None) is not None and + hasattr(cls, '__result__')): # for Python 3.5 + # Skipped in the case of plain typing.Callable + args = cls.__args__ + if args is None: + return qualname + elif args is Ellipsis: + args_str = '...' + else: + formatted_args = (restify(a) for a in args) # type: ignore + args_str = '[%s]' % ', '.join(formatted_args) + + return ':class:`%s`\\ [%s, %s]' % (qualname, args_str, stringify(cls.__result__)) + elif (isinstance(cls, typing.TupleMeta) and # type: ignore + hasattr(cls, '__tuple_params__') and + hasattr(cls, '__tuple_use_ellipsis__')): # for Python 3.5 + params = cls.__tuple_params__ + if params is not None: + param_strings = [restify(p) for p in params] + if cls.__tuple_use_ellipsis__: + param_strings.append('...') + return ':class:`%s`\\ [%s]' % (qualname, ', '.join(param_strings)) + else: + return ':class:`%s`' % qualname + elif hasattr(cls, '__qualname__'): + if cls.__module__ == 'typing': + return ':class:`%s`' % cls.__qualname__ + else: + return ':class:`%s.%s`' % (cls.__module__, cls.__qualname__) + elif hasattr(cls, '_name'): + # SpecialForm + if cls.__module__ == 'typing': + return ':obj:`%s`' % cls._name + else: + return ':obj:`%s.%s`' % (cls.__module__, cls._name) + elif hasattr(cls, '__name__'): + # not a class (ex. TypeVar) + return ':obj:`%s.%s`' % (cls.__module__, cls.__name__) + else: + # others (ex. Any) + if cls.__module__ == 'typing': + return ':obj:`%s`' % qualname + else: + return ':obj:`%s.%s`' % (cls.__module__, qualname) + + def stringify(annotation: Any) -> str: """Stringify type annotation object.""" if isinstance(annotation, str): diff --git a/tests/roots/test-ext-autodoc/target/classes.py b/tests/roots/test-ext-autodoc/target/classes.py index 52c23748b77..7e7d7bcd30e 100644 --- a/tests/roots/test-ext-autodoc/target/classes.py +++ b/tests/roots/test-ext-autodoc/target/classes.py @@ -1,4 +1,5 @@ from inspect import Parameter, Signature +from typing import List, Union class Foo: @@ -21,3 +22,8 @@ class Qux: def __init__(self, x, y): pass + + +class Quux(List[Union[int, float]]): + """A subclass of List[Union[int, float]]""" + pass diff --git a/tests/test_ext_autodoc_autoclass.py b/tests/test_ext_autodoc_autoclass.py index 76f01f5b36d..fdbff1186b4 100644 --- a/tests/test_ext_autodoc_autoclass.py +++ b/tests/test_ext_autodoc_autoclass.py @@ -9,6 +9,8 @@ :license: BSD, see LICENSE for details. """ +import sys + import pytest from test_ext_autodoc import do_autodoc @@ -73,3 +75,20 @@ def test_decorators(app): ' :module: target.decorator', '', ] + + +@pytest.mark.skipif(sys.version_info < (3, 7), reason='python 3.7+ is required.') +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_show_inheritance_for_subclass_of_generic_type(app): + options = {'show-inheritance': True} + actual = do_autodoc(app, 'class', 'target.classes.Quux', options) + assert list(actual) == [ + '', + '.. py:class:: Quux(iterable=(), /)', + ' :module: target.classes', + '', + ' Bases: :class:`List`\\ [:obj:`Union`\\ [:class:`int`, :class:`float`]]', + '', + ' A subclass of List[Union[int, float]]', + '', + ] diff --git a/tests/test_util_typing.py b/tests/test_util_typing.py index 3f8ddbb37dc..4059dc6bdda 100644 --- a/tests/test_util_typing.py +++ b/tests/test_util_typing.py @@ -16,7 +16,7 @@ import pytest -from sphinx.util.typing import stringify +from sphinx.util.typing import restify, stringify class MyClass1: @@ -36,6 +36,80 @@ class BrokenType: __args__ = int +def test_restify(): + assert restify(int) == ":class:`int`" + assert restify(str) == ":class:`str`" + assert restify(None) == ":obj:`None`" + assert restify(Integral) == ":class:`numbers.Integral`" + assert restify(Any) == ":obj:`Any`" + + +def test_restify_type_hints_containers(): + assert restify(List) == ":class:`List`" + assert restify(Dict) == ":class:`Dict`" + assert restify(List[int]) == ":class:`List`\\ [:class:`int`]" + assert restify(List[str]) == ":class:`List`\\ [:class:`str`]" + assert restify(Dict[str, float]) == ":class:`Dict`\\ [:class:`str`, :class:`float`]" + assert restify(Tuple[str, str, str]) == ":class:`Tuple`\\ [:class:`str`, :class:`str`, :class:`str`]" + assert restify(Tuple[str, ...]) == ":class:`Tuple`\\ [:class:`str`, ...]" + assert restify(List[Dict[str, Tuple]]) == ":class:`List`\\ [:class:`Dict`\\ [:class:`str`, :class:`Tuple`]]" + assert restify(MyList[Tuple[int, int]]) == ":class:`test_util_typing.MyList`\\ [:class:`Tuple`\\ [:class:`int`, :class:`int`]]" + assert restify(Generator[None, None, None]) == ":class:`Generator`\\ [:obj:`None`, :obj:`None`, :obj:`None`]" + + +def test_restify_type_hints_Callable(): + assert restify(Callable) == ":class:`Callable`" + + if sys.version_info >= (3, 7): + assert restify(Callable[[str], int]) == ":class:`Callable`\\ [[:class:`str`], :class:`int`]" + assert restify(Callable[..., int]) == ":class:`Callable`\\ [[...], :class:`int`]" + else: + assert restify(Callable[[str], int]) == ":class:`Callable`\\ [:class:`str`, :class:`int`]" + assert restify(Callable[..., int]) == ":class:`Callable`\\ [..., :class:`int`]" + + +def test_restify_type_hints_Union(): + assert restify(Optional[int]) == ":obj:`Optional`\\ [:class:`int`]" + assert restify(Union[str, None]) == ":obj:`Optional`\\ [:class:`str`]" + assert restify(Union[int, str]) == ":obj:`Union`\\ [:class:`int`, :class:`str`]" + + if sys.version_info >= (3, 7): + assert restify(Union[int, Integral]) == ":obj:`Union`\\ [:class:`int`, :class:`numbers.Integral`]" + assert (restify(Union[MyClass1, MyClass2]) == + ":obj:`Union`\\ [:class:`test_util_typing.MyClass1`, :class:`test_util_typing.`]") + else: + assert restify(Union[int, Integral]) == ":class:`numbers.Integral`" + assert restify(Union[MyClass1, MyClass2]) == ":class:`test_util_typing.MyClass1`" + + +@pytest.mark.skipif(sys.version_info < (3, 7), reason='python 3.7+ is required.') +def test_restify_type_hints_typevars(): + T = TypeVar('T') + T_co = TypeVar('T_co', covariant=True) + T_contra = TypeVar('T_contra', contravariant=True) + + assert restify(T) == ":obj:`test_util_typing.T`" + assert restify(T_co) == ":obj:`test_util_typing.T_co`" + assert restify(T_contra) == ":obj:`test_util_typing.T_contra`" + assert restify(List[T]) == ":class:`List`\\ [:obj:`test_util_typing.T`]" + + +def test_restify_type_hints_custom_class(): + assert restify(MyClass1) == ":class:`test_util_typing.MyClass1`" + assert restify(MyClass2) == ":class:`test_util_typing.`" + + +def test_restify_type_hints_alias(): + MyStr = str + MyTuple = Tuple[str, str] + assert restify(MyStr) == ":class:`str`" + assert restify(MyTuple) == ":class:`Tuple`\\ [:class:`str`, :class:`str`]" # type: ignore + + +def test_restify_broken_type_hints(): + assert restify(BrokenType) == ':class:`test_util_typing.BrokenType`' + + def test_stringify(): assert stringify(int) == "int" assert stringify(str) == "str"