Fix #8219: autodoc: Parameters for generic base class are not shown

This commit is contained in:
Takeshi KOMIYA 2020-09-21 21:47:58 +09:00
parent 5337e3848c
commit e2bf9166da
7 changed files with 311 additions and 10 deletions

View File

@ -54,6 +54,9 @@ Features added
Bugs fixed 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 Testing
-------- --------

View File

@ -37,7 +37,7 @@ from sphinx.util.docstrings import extract_metadata, prepare_docstring
from sphinx.util.inspect import ( from sphinx.util.inspect import (
evaluate_signature, getdoc, object_description, safe_getattr, stringify_signature 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: if False:
# For type annotation # For type annotation
@ -1574,13 +1574,16 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type:
if not self.doc_as_attr and self.options.show_inheritance: if not self.doc_as_attr and self.options.show_inheritance:
sourcename = self.get_sourcename() sourcename = self.get_sourcename()
self.add_line('', sourcename) self.add_line('', sourcename)
if hasattr(self.object, '__bases__') and len(self.object.__bases__):
bases = [':class:`%s`' % b.__name__ if hasattr(self.object, '__orig_bases__') and len(self.object.__orig_bases__):
if b.__module__ in ('__builtin__', 'builtins') # A subclass of generic types
else ':class:`%s.%s`' % (b.__module__, b.__qualname__) # refs: PEP-560 <https://www.python.org/dev/peps/pep-0560/>
for b in self.object.__bases__] bases = [restify(cls) for cls in self.object.__orig_bases__]
self.add_line(' ' + _('Bases: %s') % ', '.join(bases), self.add_line(' ' + _('Bases: %s') % ', '.join(bases), sourcename)
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]]: def get_doc(self, encoding: str = None, ignore: int = None) -> List[List[str]]:
if encoding is not None: if encoding is not None:

View File

@ -312,6 +312,9 @@ def isgenericalias(obj: Any) -> bool:
elif (hasattr(types, 'GenericAlias') and # only for py39+ elif (hasattr(types, 'GenericAlias') and # only for py39+
isinstance(obj, types.GenericAlias)): # type: ignore isinstance(obj, types.GenericAlias)): # type: ignore
return True return True
elif (hasattr(typing, '_SpecialGenericAlias') and # for py39+
isinstance(obj, typing._SpecialGenericAlias)): # type: ignore
return True
else: else:
return False return False

View File

@ -10,7 +10,7 @@
import sys import sys
import typing 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 import nodes
from docutils.parsers.rst.states import Inliner from docutils.parsers.rst.states import Inliner
@ -30,6 +30,10 @@ else:
ref = _ForwardRef(self.arg) ref = _ForwardRef(self.arg)
return ref._eval_type(globalns, localns) 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 # An entry of Directive.option_spec
DirectiveOption = Callable[[str], Any] DirectiveOption = Callable[[str], Any]
@ -60,6 +64,195 @@ def is_system_TypeVar(typ: Any) -> bool:
return modname == 'typing' and isinstance(typ, TypeVar) 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: def stringify(annotation: Any) -> str:
"""Stringify type annotation object.""" """Stringify type annotation object."""
if isinstance(annotation, str): if isinstance(annotation, str):

View File

@ -1,4 +1,5 @@
from inspect import Parameter, Signature from inspect import Parameter, Signature
from typing import List, Union
class Foo: class Foo:
@ -21,3 +22,8 @@ class Qux:
def __init__(self, x, y): def __init__(self, x, y):
pass pass
class Quux(List[Union[int, float]]):
"""A subclass of List[Union[int, float]]"""
pass

View File

@ -9,6 +9,8 @@
:license: BSD, see LICENSE for details. :license: BSD, see LICENSE for details.
""" """
import sys
import pytest import pytest
from test_ext_autodoc import do_autodoc from test_ext_autodoc import do_autodoc
@ -73,3 +75,20 @@ def test_decorators(app):
' :module: target.decorator', ' :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]]',
'',
]

View File

@ -16,7 +16,7 @@ from typing import (
import pytest import pytest
from sphinx.util.typing import stringify from sphinx.util.typing import restify, stringify
class MyClass1: class MyClass1:
@ -36,6 +36,80 @@ class BrokenType:
__args__ = int __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.<MyClass2>`]")
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.<MyClass2>`"
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(): def test_stringify():
assert stringify(int) == "int" assert stringify(int) == "int"
assert stringify(str) == "str" assert stringify(str) == "str"