diff --git a/CHANGES b/CHANGES index 9617665c1..c1946d51d 100644 --- a/CHANGES +++ b/CHANGES @@ -21,6 +21,9 @@ Deprecated * ``sphinx.roles.Index`` * ``sphinx.util.detect_encoding()`` * ``sphinx.util.get_module_source()`` +* ``sphinx.util.inspect.Signature.format_annotation()`` +* ``sphinx.util.inspect.Signature.format_annotation_new()`` +* ``sphinx.util.inspect.Signature.format_annotation_old()`` Features added -------------- diff --git a/doc/extdev/deprecated.rst b/doc/extdev/deprecated.rst index 36b33e829..ec6db2c16 100644 --- a/doc/extdev/deprecated.rst +++ b/doc/extdev/deprecated.rst @@ -81,6 +81,21 @@ The following is a list of deprecated interfaces. - 4.0 - N/A + * - ``sphinx.util.inspect.Signature.format_annotation()`` + - 2.4 + - 4.0 + - ``sphinx.util.typing.stringify()`` + + * - ``sphinx.util.inspect.Signature.format_annotation_new()`` + - 2.4 + - 4.0 + - ``sphinx.util.typing.stringify()`` + + * - ``sphinx.util.inspect.Signature.format_annotation_old()`` + - 2.4 + - 4.0 + - ``sphinx.util.typing.stringify()`` + * - ``sphinx.builders.gettext.POHEADER`` - 2.3 - 4.0 diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index 0b55a92bd..967a10d51 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -22,9 +22,9 @@ from inspect import ( # NOQA from io import StringIO from typing import Any, Callable, Mapping, List, Tuple -from sphinx.deprecation import RemovedInSphinx30Warning +from sphinx.deprecation import RemovedInSphinx30Warning, RemovedInSphinx40Warning from sphinx.util import logging -from sphinx.util.typing import NoneType +from sphinx.util.typing import stringify as stringify_annotation if sys.version_info > (3, 7): from types import ( @@ -403,11 +403,11 @@ class Signature: return None def format_args(self, show_annotation: bool = True) -> str: - def format_param_annotation(param: inspect.Parameter) -> str: + def get_annotation(param: inspect.Parameter) -> Any: if isinstance(param.annotation, str) and param.name in self.annotations: - return self.format_annotation(self.annotations[param.name]) + return self.annotations[param.name] else: - return self.format_annotation(param.annotation) + return param.annotation args = [] last_kind = None @@ -431,7 +431,7 @@ class Signature: arg.write(param.name) if show_annotation and param.annotation is not param.empty: arg.write(': ') - arg.write(format_param_annotation(param)) + arg.write(stringify_annotation(get_annotation(param))) if param.default is not param.empty: if param.annotation is param.empty or show_annotation is False: arg.write('=') @@ -444,13 +444,13 @@ class Signature: arg.write(param.name) if show_annotation and param.annotation is not param.empty: arg.write(': ') - arg.write(format_param_annotation(param)) + arg.write(stringify_annotation(get_annotation(param))) elif param.kind == param.VAR_KEYWORD: arg.write('**') arg.write(param.name) if show_annotation and param.annotation is not param.empty: arg.write(': ') - arg.write(format_param_annotation(param)) + arg.write(stringify_annotation(get_annotation(param))) args.append(arg.getvalue()) last_kind = param.kind @@ -459,164 +459,29 @@ class Signature: return '(%s)' % ', '.join(args) else: if 'return' in self.annotations: - annotation = self.format_annotation(self.annotations['return']) + annotation = stringify_annotation(self.annotations['return']) else: - annotation = self.format_annotation(self.return_annotation) + annotation = stringify_annotation(self.return_annotation) return '(%s) -> %s' % (', '.join(args), annotation) def format_annotation(self, annotation: Any) -> str: - """Return formatted representation of a type annotation. - - Show qualified names for types and additional details for types from - the ``typing`` module. - - Displaying complex types from ``typing`` relies on its private API. - """ - if isinstance(annotation, str): - return annotation - elif isinstance(annotation, typing.TypeVar): # type: ignore - return annotation.__name__ - elif not annotation: - return repr(annotation) - elif annotation is NoneType: # type: ignore - return 'None' - elif getattr(annotation, '__module__', None) == 'builtins': - return annotation.__qualname__ - elif annotation is Ellipsis: - return '...' - - if sys.version_info >= (3, 7): # py37+ - return self.format_annotation_new(annotation) - else: - return self.format_annotation_old(annotation) + """Return formatted representation of a type annotation.""" + warnings.warn('format_annotation() is deprecated', + RemovedInSphinx40Warning) + return stringify_annotation(annotation) def format_annotation_new(self, annotation: Any) -> str: """format_annotation() for py37+""" - module = getattr(annotation, '__module__', None) - if module == 'typing': - if getattr(annotation, '_name', None): - qualname = annotation._name - elif getattr(annotation, '__qualname__', None): - qualname = annotation.__qualname__ - elif getattr(annotation, '__forward_arg__', None): - qualname = annotation.__forward_arg__ - else: - qualname = self.format_annotation(annotation.__origin__) # ex. Union - elif hasattr(annotation, '__qualname__'): - qualname = '%s.%s' % (module, annotation.__qualname__) - else: - qualname = repr(annotation) - - if getattr(annotation, '__args__', None): - if qualname == 'Union': - if len(annotation.__args__) == 2 and annotation.__args__[1] is NoneType: # type: ignore # NOQA - return 'Optional[%s]' % self.format_annotation(annotation.__args__[0]) - else: - args = ', '.join(self.format_annotation(a) for a in annotation.__args__) - return '%s[%s]' % (qualname, args) - elif qualname == 'Callable': - args = ', '.join(self.format_annotation(a) for a in annotation.__args__[:-1]) - returns = self.format_annotation(annotation.__args__[-1]) - return '%s[[%s], %s]' % (qualname, args, returns) - elif annotation._special: - return qualname - else: - args = ', '.join(self.format_annotation(a) for a in annotation.__args__) - return '%s[%s]' % (qualname, args) - - return qualname + warnings.warn('format_annotation_new() is deprecated', + RemovedInSphinx40Warning) + return stringify_annotation(annotation) def format_annotation_old(self, annotation: Any) -> str: """format_annotation() for py36 or below""" - module = getattr(annotation, '__module__', None) - if module == 'typing': - if getattr(annotation, '_name', None): - qualname = annotation._name - elif getattr(annotation, '__qualname__', None): - qualname = annotation.__qualname__ - elif getattr(annotation, '__forward_arg__', None): - qualname = annotation.__forward_arg__ - elif getattr(annotation, '__origin__', None): - qualname = self.format_annotation(annotation.__origin__) # ex. Union - else: - qualname = repr(annotation).replace('typing.', '') - elif hasattr(annotation, '__qualname__'): - qualname = '%s.%s' % (module, annotation.__qualname__) - else: - qualname = repr(annotation) - - if (isinstance(annotation, typing.TupleMeta) and # type: ignore - not hasattr(annotation, '__tuple_params__')): # for Python 3.6 - params = annotation.__args__ - if params: - param_str = ', '.join(self.format_annotation(p) for p in params) - return '%s[%s]' % (qualname, param_str) - else: - return qualname - elif isinstance(annotation, typing.GenericMeta): - params = None - if hasattr(annotation, '__args__'): - # for Python 3.5.2+ - if annotation.__args__ is None or len(annotation.__args__) <= 2: # type: ignore # NOQA - params = annotation.__args__ # type: ignore - else: # typing.Callable - args = ', '.join(self.format_annotation(arg) for arg - in annotation.__args__[:-1]) # type: ignore - result = self.format_annotation(annotation.__args__[-1]) # type: ignore - return '%s[[%s], %s]' % (qualname, args, result) - elif hasattr(annotation, '__parameters__'): - # for Python 3.5.0 and 3.5.1 - params = annotation.__parameters__ # type: ignore - if params is not None: - param_str = ', '.join(self.format_annotation(p) for p in params) - return '%s[%s]' % (qualname, param_str) - elif (hasattr(typing, 'UnionMeta') and - isinstance(annotation, typing.UnionMeta) and # type: ignore - hasattr(annotation, '__union_params__')): # for Python 3.5 - params = annotation.__union_params__ - if params is not None: - if len(params) == 2 and params[1] is NoneType: # type: ignore - return 'Optional[%s]' % self.format_annotation(params[0]) - else: - param_str = ', '.join(self.format_annotation(p) for p in params) - return '%s[%s]' % (qualname, param_str) - elif (hasattr(annotation, '__origin__') and - annotation.__origin__ is typing.Union): # for Python 3.5.2+ - params = annotation.__args__ - if params is not None: - if len(params) == 2 and params[1] is NoneType: # type: ignore - return 'Optional[%s]' % self.format_annotation(params[0]) - else: - param_str = ', '.join(self.format_annotation(p) for p in params) - return 'Union[%s]' % param_str - elif (isinstance(annotation, typing.CallableMeta) and # type: ignore - getattr(annotation, '__args__', None) is not None and - hasattr(annotation, '__result__')): # for Python 3.5 - # Skipped in the case of plain typing.Callable - args = annotation.__args__ - if args is None: - return qualname - elif args is Ellipsis: - args_str = '...' - else: - formatted_args = (self.format_annotation(a) for a in args) - args_str = '[%s]' % ', '.join(formatted_args) - return '%s[%s, %s]' % (qualname, - args_str, - self.format_annotation(annotation.__result__)) - elif (isinstance(annotation, typing.TupleMeta) and # type: ignore - hasattr(annotation, '__tuple_params__') and - hasattr(annotation, '__tuple_use_ellipsis__')): # for Python 3.5 - params = annotation.__tuple_params__ - if params is not None: - param_strings = [self.format_annotation(p) for p in params] - if annotation.__tuple_use_ellipsis__: - param_strings.append('...') - return '%s[%s]' % (qualname, - ', '.join(param_strings)) - - return qualname + warnings.warn('format_annotation_old() is deprecated', + RemovedInSphinx40Warning) + return stringify_annotation(annotation) def getdoc(obj: Any, attrgetter: Callable = safe_getattr, diff --git a/sphinx/util/typing.py b/sphinx/util/typing.py index 1b2ec3f60..ccceefed6 100644 --- a/sphinx/util/typing.py +++ b/sphinx/util/typing.py @@ -8,7 +8,9 @@ :license: BSD, see LICENSE for details. """ -from typing import Any, Callable, Dict, List, Tuple, Union +import sys +import typing +from typing import Any, Callable, Dict, List, Tuple, TypeVar, Union from docutils import nodes from docutils.parsers.rst.states import Inliner @@ -35,3 +37,153 @@ TitleGetter = Callable[[nodes.Node], str] # inventory data on memory Inventory = Dict[str, Dict[str, Tuple[str, str, str, str]]] + + +def stringify(annotation: Any) -> str: + """Stringify type annotation object.""" + if isinstance(annotation, str): + return annotation + elif isinstance(annotation, TypeVar): # type: ignore + return annotation.__name__ + elif not annotation: + return repr(annotation) + elif annotation is NoneType: # type: ignore + return 'None' + elif getattr(annotation, '__module__', None) == 'builtins': + return annotation.__qualname__ + elif annotation is Ellipsis: + return '...' + + if sys.version_info >= (3, 7): # py37+ + return _stringify_py37(annotation) + else: + return _stringify_py36(annotation) + + +def _stringify_py37(annotation: Any) -> str: + """stringify() for py37+.""" + module = getattr(annotation, '__module__', None) + if module == 'typing': + if getattr(annotation, '_name', None): + qualname = annotation._name + elif getattr(annotation, '__qualname__', None): + qualname = annotation.__qualname__ + elif getattr(annotation, '__forward_arg__', None): + qualname = annotation.__forward_arg__ + else: + qualname = stringify(annotation.__origin__) # ex. Union + elif hasattr(annotation, '__qualname__'): + qualname = '%s.%s' % (module, annotation.__qualname__) + else: + qualname = repr(annotation) + + if getattr(annotation, '__args__', None): + if qualname == 'Union': + if len(annotation.__args__) == 2 and annotation.__args__[1] is NoneType: # type: ignore # NOQA + return 'Optional[%s]' % stringify(annotation.__args__[0]) + else: + args = ', '.join(stringify(a) for a in annotation.__args__) + return '%s[%s]' % (qualname, args) + elif qualname == 'Callable': + args = ', '.join(stringify(a) for a in annotation.__args__[:-1]) + returns = stringify(annotation.__args__[-1]) + return '%s[[%s], %s]' % (qualname, args, returns) + elif annotation._special: + return qualname + else: + args = ', '.join(stringify(a) for a in annotation.__args__) + return '%s[%s]' % (qualname, args) + + return qualname + + +def _stringify_py36(annotation: Any) -> str: + """stringify() for py35 and py36.""" + module = getattr(annotation, '__module__', None) + if module == 'typing': + if getattr(annotation, '_name', None): + qualname = annotation._name + elif getattr(annotation, '__qualname__', None): + qualname = annotation.__qualname__ + elif getattr(annotation, '__forward_arg__', None): + qualname = annotation.__forward_arg__ + elif getattr(annotation, '__origin__', None): + qualname = stringify(annotation.__origin__) # ex. Union + else: + qualname = repr(annotation).replace('typing.', '') + elif hasattr(annotation, '__qualname__'): + qualname = '%s.%s' % (module, annotation.__qualname__) + else: + qualname = repr(annotation) + + if (isinstance(annotation, typing.TupleMeta) and # type: ignore + not hasattr(annotation, '__tuple_params__')): # for Python 3.6 + params = annotation.__args__ + if params: + param_str = ', '.join(stringify(p) for p in params) + return '%s[%s]' % (qualname, param_str) + else: + return qualname + elif isinstance(annotation, typing.GenericMeta): + params = None + if hasattr(annotation, '__args__'): + # for Python 3.5.2+ + if annotation.__args__ is None or len(annotation.__args__) <= 2: # type: ignore # NOQA + params = annotation.__args__ # type: ignore + else: # typing.Callable + args = ', '.join(stringify(arg) for arg + in annotation.__args__[:-1]) # type: ignore + result = stringify(annotation.__args__[-1]) # type: ignore + return '%s[[%s], %s]' % (qualname, args, result) + elif hasattr(annotation, '__parameters__'): + # for Python 3.5.0 and 3.5.1 + params = annotation.__parameters__ # type: ignore + if params is not None: + param_str = ', '.join(stringify(p) for p in params) + return '%s[%s]' % (qualname, param_str) + elif (hasattr(typing, 'UnionMeta') and + isinstance(annotation, typing.UnionMeta) and # type: ignore + hasattr(annotation, '__union_params__')): # for Python 3.5 + params = annotation.__union_params__ + if params is not None: + if len(params) == 2 and params[1] is NoneType: # type: ignore + return 'Optional[%s]' % stringify(params[0]) + else: + param_str = ', '.join(stringify(p) for p in params) + return '%s[%s]' % (qualname, param_str) + elif (hasattr(annotation, '__origin__') and + annotation.__origin__ is typing.Union): # for Python 3.5.2+ + params = annotation.__args__ + if params is not None: + if len(params) == 2 and params[1] is NoneType: # type: ignore + return 'Optional[%s]' % stringify(params[0]) + else: + param_str = ', '.join(stringify(p) for p in params) + return 'Union[%s]' % param_str + elif (isinstance(annotation, typing.CallableMeta) and # type: ignore + getattr(annotation, '__args__', None) is not None and + hasattr(annotation, '__result__')): # for Python 3.5 + # Skipped in the case of plain typing.Callable + args = annotation.__args__ + if args is None: + return qualname + elif args is Ellipsis: + args_str = '...' + else: + formatted_args = (stringify(a) for a in args) + args_str = '[%s]' % ', '.join(formatted_args) + return '%s[%s, %s]' % (qualname, + args_str, + stringify(annotation.__result__)) + elif (isinstance(annotation, typing.TupleMeta) and # type: ignore + hasattr(annotation, '__tuple_params__') and + hasattr(annotation, '__tuple_use_ellipsis__')): # for Python 3.5 + params = annotation.__tuple_params__ + if params is not None: + param_strings = [stringify(p) for p in params] + if annotation.__tuple_use_ellipsis__: + param_strings.append('...') + return '%s[%s]' % (qualname, + ', '.join(param_strings)) + + return qualname diff --git a/tests/test_util_typing.py b/tests/test_util_typing.py new file mode 100644 index 000000000..9a225f0f1 --- /dev/null +++ b/tests/test_util_typing.py @@ -0,0 +1,98 @@ +""" + test_util_typing + ~~~~~~~~~~~~~~~~ + + Tests util.typing functions. + + :copyright: Copyright 2007-2019 by the Sphinx team, see AUTHORS. + :license: BSD, see LICENSE for details. +""" + +import sys +from numbers import Integral +from typing import Any, Dict, List, TypeVar, Union, Callable, Tuple, Optional + +from sphinx.util.typing import stringify + + +class MyClass1: + pass + + +class MyClass2(MyClass1): + __qualname__ = '' + + +def test_stringify(): + assert stringify(int) == "int" + assert stringify(str) == "str" + assert stringify(None) == "None" + assert stringify(Integral) == "numbers.Integral" + assert stringify(Any) == "Any" + + +def test_stringify_type_hints_containers(): + assert stringify(List) == "List" + assert stringify(Dict) == "Dict" + assert stringify(List[int]) == "List[int]" + assert stringify(List[str]) == "List[str]" + assert stringify(Dict[str, float]) == "Dict[str, float]" + assert stringify(Tuple[str, str, str]) == "Tuple[str, str, str]" + assert stringify(Tuple[str, ...]) == "Tuple[str, ...]" + assert stringify(List[Dict[str, Tuple]]) == "List[Dict[str, Tuple]]" + + +def test_stringify_type_hints_string(): + assert stringify("int") == "int" + assert stringify("str") == "str" + assert stringify(List["int"]) == "List[int]" + assert stringify("Tuple[str]") == "Tuple[str]" + assert stringify("unknown") == "unknown" + + +def test_stringify_type_hints_Callable(): + assert stringify(Callable) == "Callable" + + if sys.version_info >= (3, 7): + assert stringify(Callable[[str], int]) == "Callable[[str], int]" + assert stringify(Callable[..., int]) == "Callable[[...], int]" + else: + assert stringify(Callable[[str], int]) == "Callable[str, int]" + assert stringify(Callable[..., int]) == "Callable[..., int]" + + +def test_stringify_type_hints_Union(): + assert stringify(Optional[int]) == "Optional[int]" + assert stringify(Union[str, None]) == "Optional[str]" + assert stringify(Union[int, str]) == "Union[int, str]" + + if sys.version_info >= (3, 7): + assert stringify(Union[int, Integral]) == "Union[int, numbers.Integral]" + assert (stringify(Union[MyClass1, MyClass2]) == + "Union[test_util_typing.MyClass1, test_util_typing.]") + else: + assert stringify(Union[int, Integral]) == "numbers.Integral" + assert stringify(Union[MyClass1, MyClass2]) == "test_util_typing.MyClass1" + + +def test_stringify_type_hints_typevars(): + T = TypeVar('T') + T_co = TypeVar('T_co', covariant=True) + T_contra = TypeVar('T_contra', contravariant=True) + + assert stringify(T) == "T" + assert stringify(T_co) == "T_co" + assert stringify(T_contra) == "T_contra" + assert stringify(List[T]) == "List[T]" + + +def test_stringify_type_hints_custom_class(): + assert stringify(MyClass1) == "test_util_typing.MyClass1" + assert stringify(MyClass2) == "test_util_typing." + + +def test_stringify_type_hints_alias(): + MyStr = str + MyTuple = Tuple[str, str] + assert stringify(MyStr) == "str" + assert stringify(MyTuple) == "Tuple[str, str]" # type: ignore