diff --git a/sphinx/ext/autodoc/type_comment.py b/sphinx/ext/autodoc/type_comment.py index c94020bf0..81391bc5c 100644 --- a/sphinx/ext/autodoc/type_comment.py +++ b/sphinx/ext/autodoc/type_comment.py @@ -8,13 +8,13 @@ :license: BSD, see LICENSE for details. """ -import ast -from inspect import getsource -from typing import Any, Dict +from inspect import Parameter, Signature, getsource +from typing import Any, Dict, List from typing import cast import sphinx from sphinx.application import Sphinx +from sphinx.pycode.ast import ast from sphinx.pycode.ast import parse as ast_parse from sphinx.pycode.ast import unparse as ast_unparse from sphinx.util import inspect @@ -23,11 +23,73 @@ from sphinx.util import logging logger = logging.getLogger(__name__) -def get_type_comment(obj: Any) -> ast.FunctionDef: +def not_suppressed(argtypes: List[ast.AST] = []) -> bool: + """Check given *argtypes* is suppressed type_comment or not.""" + if len(argtypes) == 0: # no argtypees + return False + elif len(argtypes) == 1 and ast_unparse(argtypes[0]) == "...": # suppressed + # Note: To support multiple versions of python, this uses ``ast_unparse()`` for + # comparison with Ellipsis. Since 3.8, ast.Constant has been used to represent + # Ellipsis node instead of ast.Ellipsis. + return False + else: # not suppressed + return True + + +def signature_from_ast(node: ast.FunctionDef, bound_method: bool, + type_comment: ast.FunctionDef) -> Signature: + """Return a Signature object for the given *node*. + + :param bound_method: Specify *node* is a bound method or not + """ + params = [] + if hasattr(node.args, "posonlyargs"): # for py38+ + for arg in node.args.posonlyargs: # type: ignore + param = Parameter(arg.arg, Parameter.POSITIONAL_ONLY, annotation=arg.type_comment) + params.append(param) + + for arg in node.args.args: + param = Parameter(arg.arg, Parameter.POSITIONAL_OR_KEYWORD, + annotation=arg.type_comment or Parameter.empty) + params.append(param) + + if node.args.vararg: + param = Parameter(node.args.vararg.arg, Parameter.VAR_POSITIONAL, + annotation=arg.type_comment or Parameter.empty) + params.append(param) + + for arg in node.args.kwonlyargs: + param = Parameter(arg.arg, Parameter.KEYWORD_ONLY, + annotation=arg.type_comment or Parameter.empty) + params.append(param) + + if node.args.kwarg: + param = Parameter(node.args.kwarg.arg, Parameter.VAR_KEYWORD, + annotation=arg.type_comment or Parameter.empty) + params.append(param) + + # Remove first parameter when *obj* is bound_method + if bound_method and params: + params.pop(0) + + # merge type_comment into signature + if not_suppressed(type_comment.argtypes): # type: ignore + for i, param in enumerate(params): + params[i] = param.replace(annotation=type_comment.argtypes[i]) # type: ignore + + if node.returns: + return Signature(params, return_annotation=node.returns) + elif type_comment.returns: + return Signature(params, return_annotation=ast_unparse(type_comment.returns)) + else: + return Signature(params) + + +def get_type_comment(obj: Any, bound_method: bool = False) -> Signature: """Get type_comment'ed FunctionDef object from living object. This tries to parse original code for living object and returns - AST node for given *obj*. It requires py38+ or typed_ast module. + Signature for given *obj*. It requires py38+ or typed_ast module. """ try: source = getsource(obj) @@ -41,7 +103,8 @@ def get_type_comment(obj: Any) -> ast.FunctionDef: subject = cast(ast.FunctionDef, module.body[0]) # type: ignore if getattr(subject, "type_comment", None): - return ast_parse(subject.type_comment, mode='func_type') # type: ignore + function = ast_parse(subject.type_comment, mode='func_type') + return signature_from_ast(subject, bound_method, function) # type: ignore else: return None except (OSError, TypeError): # failed to load source code @@ -53,17 +116,17 @@ def get_type_comment(obj: Any) -> ast.FunctionDef: def update_annotations_using_type_comments(app: Sphinx, obj: Any, bound_method: bool) -> None: """Update annotations info of *obj* using type_comments.""" try: - function = get_type_comment(obj) - if function and hasattr(function, 'argtypes'): - if function.argtypes != [ast.Ellipsis]: # type: ignore - sig = inspect.signature(obj, bound_method) - for i, param in enumerate(sig.parameters.values()): - if param.name not in obj.__annotations__: - annotation = ast_unparse(function.argtypes[i]) # type: ignore - obj.__annotations__[param.name] = annotation + type_sig = get_type_comment(obj, bound_method) + if type_sig: + sig = inspect.signature(obj, bound_method) + for param in sig.parameters.values(): + if param.name not in obj.__annotations__: + annotation = type_sig.parameters[param.name].annotation + if annotation is not Parameter.empty: + obj.__annotations__[param.name] = ast_unparse(annotation) if 'return' not in obj.__annotations__: - obj.__annotations__['return'] = ast_unparse(function.returns) # type: ignore + obj.__annotations__['return'] = type_sig.return_annotation except NotImplementedError as exc: # failed to ast.unparse() logger.warning("Failed to parse type_comment for %r: %s", obj, exc) diff --git a/tests/roots/test-ext-autodoc/target/typehints.py b/tests/roots/test-ext-autodoc/target/typehints.py index 842530c13..ab5bfb624 100644 --- a/tests/roots/test-ext-autodoc/target/typehints.py +++ b/tests/roots/test-ext-autodoc/target/typehints.py @@ -18,7 +18,27 @@ class Math: # type: (int, int) -> int return a - b + def nothing(self): + # type: () -> None + pass + + def horse(self, + a, # type: str + b, # type: int + ): + # type: (...) -> None + return + def complex_func(arg1, arg2, arg3=None, *args, **kwargs): # type: (str, List[int], Tuple[int, Union[str, Unknown]], *str, **str) -> None pass + + +def missing_attr(c, + a, # type: str + b=None # type: Optional[str] + ): + # type: (...) -> str + return a + (b or "") + diff --git a/tests/test_ext_autodoc_configs.py b/tests/test_ext_autodoc_configs.py index 6bd716c01..f21431238 100644 --- a/tests/test_ext_autodoc_configs.py +++ b/tests/test_ext_autodoc_configs.py @@ -483,9 +483,17 @@ def test_autodoc_typehints_signature(app): ' :module: target.typehints', ' ', ' ', + ' .. py:method:: Math.horse(a: str, b: int) -> None', + ' :module: target.typehints', + ' ', + ' ', ' .. py:method:: Math.incr(a: int, b: int = 1) -> int', ' :module: target.typehints', ' ', + ' ', + ' .. py:method:: Math.nothing() -> None', + ' :module: target.typehints', + ' ', '', '.. py:function:: complex_func(arg1: str, arg2: List[int], arg3: Tuple[int, ' 'Union[str, Unknown]] = None, *args: str, **kwargs: str) -> None', @@ -498,6 +506,10 @@ def test_autodoc_typehints_signature(app): '', '.. py:function:: incr(a: int, b: int = 1) -> int', ' :module: target.typehints', + '', + '', + '.. py:function:: missing_attr(c, a: str, b: Optional[str] = None) -> str', + ' :module: target.typehints', '' ] @@ -522,9 +534,17 @@ def test_autodoc_typehints_none(app): ' :module: target.typehints', ' ', ' ', + ' .. py:method:: Math.horse(a, b)', + ' :module: target.typehints', + ' ', + ' ', ' .. py:method:: Math.incr(a, b=1)', ' :module: target.typehints', ' ', + ' ', + ' .. py:method:: Math.nothing()', + ' :module: target.typehints', + ' ', '', '.. py:function:: complex_func(arg1, arg2, arg3=None, *args, **kwargs)', ' :module: target.typehints', @@ -536,6 +556,10 @@ def test_autodoc_typehints_none(app): '', '.. py:function:: incr(a, b=1)', ' :module: target.typehints', + '', + '', + '.. py:function:: missing_attr(c, a, b=None)', + ' :module: target.typehints', '' ]