diff --git a/sphinx/ext/autodoc.py b/sphinx/ext/autodoc.py index ff6a30ecc..a6a2a11a3 100644 --- a/sphinx/ext/autodoc.py +++ b/sphinx/ext/autodoc.py @@ -17,7 +17,8 @@ import inspect import traceback from types import FunctionType, BuiltinFunctionType, MethodType -from six import iteritems, itervalues, text_type, class_types, string_types +from six import iteritems, itervalues, text_type, class_types, string_types, \ + StringIO from docutils import nodes from docutils.utils import assemble_option_dict from docutils.statemachine import ViewList @@ -33,6 +34,10 @@ from sphinx.util.inspect import getargspec, isdescriptor, safe_getmembers, \ safe_getattr, object_description, is_builtin_class_method from sphinx.util.docstrings import prepare_docstring +try: + import typing +except ImportError: + typing = None #: extended signature RE: with explicit module name separated by :: py_ext_sig_re = re.compile( @@ -245,9 +250,126 @@ def between(marker, what=None, keepempty=False, exclude=False): return process -def formatargspec(*argspec): - return inspect.formatargspec(*argspec, - formatvalue=lambda x: '=' + object_description(x)) +def format_annotation(annotation): + """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. + """ + qualified_name = (annotation.__module__ + '.' + annotation.__qualname__ + if annotation else repr(annotation)) + + if not isinstance(annotation, type): + return repr(annotation) + elif annotation.__module__ == 'builtins': + return annotation.__qualname__ + elif typing: + if isinstance(annotation, typing.TypeVar): + return annotation.__name__ + elif hasattr(typing, 'GenericMeta') and \ + isinstance(annotation, typing.GenericMeta) and \ + hasattr(annotation, '__parameters__'): + params = annotation.__parameters__ + if params is not None: + param_str = ', '.join(format_annotation(p) for p in params) + return '%s[%s]' % (qualified_name, param_str) + elif hasattr(typing, 'UnionMeta') and \ + isinstance(annotation, typing.UnionMeta) and \ + hasattr(annotation, '__union_params__'): + params = annotation.__union_params__ + if params is not None: + param_str = ', '.join(format_annotation(p) for p in params) + return '%s[%s]' % (qualified_name, param_str) + elif hasattr(typing, 'CallableMeta') and \ + isinstance(annotation, typing.CallableMeta) and \ + hasattr(annotation, '__args__') and \ + hasattr(annotation, '__result__'): + args = annotation.__args__ + if args is Ellipsis: + args_str = '...' + else: + formatted_args = (format_annotation(a) for a in args) + args_str = '[%s]' % ', '.join(formatted_args) + return '%s[%s, %s]' % (qualified_name, + args_str, + format_annotation(annotation.__result__)) + elif hasattr(typing, 'TupleMeta') and \ + isinstance(annotation, typing.TupleMeta) and \ + hasattr(annotation, '__tuple_params__') and \ + hasattr(annotation, '__tuple_use_ellipsis__'): + params = annotation.__tuple_params__ + if params is not None: + param_strings = [format_annotation(p) for p in params] + if annotation.__tuple_use_ellipsis__: + param_strings.append('...') + return '%s[%s]' % (qualified_name, + ', '.join(param_strings)) + return qualified_name + + +def formatargspec(function, args, varargs=None, varkw=None, defaults=None, + kwonlyargs=(), kwonlydefaults={}, annotations={}): + """Return a string representation of an ``inspect.FullArgSpec`` tuple. + + An enhanced version of ``inspect.formatargspec()`` that handles typing + annotations better. + """ + + def format_arg_with_annotation(name): + if name in annotations: + return '%s: %s' % (name, format_annotation(get_annotation(name))) + return name + + def get_annotation(name): + value = annotations[name] + if isinstance(value, string_types): + return introspected_hints.get(name, value) + else: + return value + + introspected_hints = (typing.get_type_hints(function) + if typing and hasattr(function, '__code__') else {}) + + fd = StringIO() + fd.write('(') + + formatted = [] + defaults_start = len(args) - len(defaults) if defaults else len(args) + + for i, arg in enumerate(args): + arg_fd = StringIO() + arg_fd.write(format_arg_with_annotation(arg)) + if defaults and i >= defaults_start: + arg_fd.write(' = ' if arg in annotations else '=') + arg_fd.write(object_description(defaults[i - defaults_start])) + formatted.append(arg_fd.getvalue()) + + if varargs: + formatted.append('*' + format_arg_with_annotation(varargs)) + + if kwonlyargs: + formatted.append('*') + for kwarg in kwonlyargs: + arg_fd = StringIO() + arg_fd.write(format_arg_with_annotation(kwarg)) + if kwonlydefaults and kwarg in kwonlydefaults: + arg_fd.write(' = ' if kwarg in annotations else '=') + arg_fd.write(object_description(kwonlydefaults[kwarg])) + formatted.append(arg_fd.getvalue()) + + if varkw: + formatted.append('**' + format_arg_with_annotation(varkw)) + + fd.write(', '.join(formatted)) + fd.write(')') + + if 'return' in annotations: + fd.write(' -> ') + fd.write(format_annotation(get_annotation('return'))) + + return fd.getvalue() class Documenter(object): @@ -1061,7 +1183,7 @@ class FunctionDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): argspec = getargspec(self.object.__init__) if argspec[0]: del argspec[0][0] - args = formatargspec(*argspec) + args = formatargspec(self.object, *argspec) # escape backslashes for reST args = args.replace('\\', '\\\\') return args @@ -1116,7 +1238,7 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): return None if argspec[0] and argspec[0][0] in ('cls', 'self'): del argspec[0][0] - return formatargspec(*argspec) + return formatargspec(initmeth, *argspec) def format_signature(self): if self.doc_as_attr: @@ -1283,7 +1405,7 @@ class MethodDocumenter(DocstringSignatureMixin, ClassLevelDocumenter): argspec = getargspec(self.object) if argspec[0] and argspec[0][0] in ('cls', 'self'): del argspec[0][0] - args = formatargspec(*argspec) + args = formatargspec(self.object, *argspec) # escape backslashes for reST args = args.replace('\\', '\\\\') return args diff --git a/tests/test_autodoc.py b/tests/test_autodoc.py index 2d61edabd..f13a08cff 100644 --- a/tests/test_autodoc.py +++ b/tests/test_autodoc.py @@ -11,8 +11,9 @@ """ # "raises" imported for usage by autodoc +from unittest import SkipTest from util import TestApp, Struct, raises -from nose.tools import with_setup +from nose.tools import with_setup, eq_ from six import StringIO from docutils.statemachine import ViewList @@ -968,3 +969,46 @@ class InstAttCls(object): self.ia2 = 'e' """Docstring for instance attribute InstAttCls.ia2.""" + + +def test_type_hints(): + from sphinx.ext.autodoc import formatargspec + from sphinx.util.inspect import getargspec + + try: + from typing_test_data import f0, f1, f2, f3, f4, f5, f6, f7, f8 + except ImportError: + raise SkipTest + + def verify_arg_spec(f, expected): + eq_(formatargspec(f, *getargspec(f)), expected) + + # Class annotations + verify_arg_spec(f0, '(x: int, y: numbers.Integral) -> None') + + # Generic types with concrete parameters + verify_arg_spec(f1, '(x: typing.List[int]) -> typing.List[int]') + + # TypeVars and generic types with TypeVars + verify_arg_spec(f2, '(x: typing.List[T],' + ' y: typing.List[T_co],' + ' z: T) -> typing.List[T_contra]') + + # Union types + verify_arg_spec(f3, '(x: typing.Union[str, numbers.Integral]) -> None') + + # Quoted annotations + verify_arg_spec(f4, '(x: str, y: str) -> None') + + # Keyword-only arguments + verify_arg_spec(f5, '(x: int, *, y: str, z: str) -> None') + + # Space around '=' for defaults + verify_arg_spec(f6, '(x: int = None, y: dict = {}) -> None') + + # Callable types + verify_arg_spec(f7, '(x: typing.Callable[[int, str], int]) -> None') + + # Tuple types + verify_arg_spec(f8, '(x: typing.Tuple[int, str],' + ' y: typing.Tuple[int, ...]) -> None') diff --git a/tests/typing_test_data.py b/tests/typing_test_data.py new file mode 100644 index 000000000..74a906ad1 --- /dev/null +++ b/tests/typing_test_data.py @@ -0,0 +1,48 @@ +from typing import List, TypeVar, Union, Callable, Tuple + +from numbers import Integral + + +def f0(x: int, y: Integral) -> None: + pass + + +def f1(x: List[int]) -> List[int]: + pass + + +T = TypeVar('T') +T_co = TypeVar('T_co', covariant=True) +T_contra = TypeVar('T_contra', contravariant=True) + + +def f2(x: List[T], y: List[T_co], z: T) -> List[T_contra]: + pass + + +def f3(x: Union[str, Integral]) -> None: + pass + + +MyStr = str + + +def f4(x: 'MyStr', y: MyStr) -> None: + pass + + +def f5(x: int, *, y: str, z: str) -> None: + pass + + +def f6(x: int = None, y: dict = {}) -> None: + pass + + +def f7(x: Callable[[int, str], int]) -> None: + # See https://github.com/ambv/typehinting/issues/149 for Callable[..., int] + pass + + +def f8(x: Tuple[int, str], y: Tuple[int, ...]) -> None: + pass