diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 467b2ef..a055e74 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,6 +12,13 @@ Breaking Changes * Dropped support for Python 2 and Sphinx 1.x/2.x. Python 2 source code can still be parsed. +Features +^^^^^^^^ + +* Added support for using type hints as parameter types and return types + via the ``sphinx.ext.autodoc.typehints`` extension. + + V1.5.1 (2020-10-01) ------------------- diff --git a/autoapi/mappers/python/astroid_utils.py b/autoapi/mappers/python/astroid_utils.py index bdc953e..e4bd94d 100644 --- a/autoapi/mappers/python/astroid_utils.py +++ b/autoapi/mappers/python/astroid_utils.py @@ -1,4 +1,5 @@ import builtins +import collections import itertools import re import sys @@ -402,41 +403,33 @@ def merge_annotations(annotations, comment_annotations): yield None -def _format_args(args, defaults=None, annotations=None): - values = [] +def _format_annotation(annotation): + if annotation: + if isinstance(annotation, astroid.Const): + annotation = annotation.value + else: + annotation = annotation.as_string() - if args is None: - return "" + return annotation - if annotations is None: - annotations = [] - - if defaults is not None: - default_offset = len(args) - len(defaults) +def _iter_args(args, annotations, defaults): + default_offset = len(args) - len(defaults) packed = itertools.zip_longest(args, annotations) for i, (arg, annotation) in enumerate(packed): + default = None + if defaults is not None and i >= default_offset: + if defaults[i - default_offset] is not None: + default = defaults[i - default_offset].as_string() + + name = arg.name if isinstance(arg, astroid.Tuple): - values.append("({})".format(_format_args(arg.elts))) - else: - argname = arg.name - default_sep = "=" - if annotation is not None: - ann_str = annotation.as_string() - if isinstance(annotation, astroid.Const): - ann_str = annotation.value - argname = "{}: {}".format(argname, ann_str) - default_sep = " = " - values.append(argname) + name = "({})".format(", ".join(x.name for x in arg.elts)) - if defaults is not None and i >= default_offset: - if defaults[i - default_offset] is not None: - values[-1] += default_sep + defaults[i - default_offset].as_string() - - return ", ".join(values) + yield (name, _format_annotation(annotation), default) -def format_args(args_node): # pylint: disable=too-many-branches,too-many-statements +def _get_args_info(args_node): # pylint: disable=too-many-branches,too-many-statements result = [] positional_only_defaults = [] positional_or_keyword_defaults = args_node.defaults @@ -449,8 +442,8 @@ def format_args(args_node): # pylint: disable=too-many-branches,too-many-statem plain_annotations = args_node.annotations or () func_comment_annotations = args_node.parent.type_comment_args or () - comment_annotations = args_node.type_comment_args or [] - comment_annotations = args_node.type_comment_posonlyargs + comment_annotations + comment_annotations = args_node.type_comment_posonlyargs + comment_annotations += args_node.type_comment_args or [] comment_annotations += args_node.type_comment_kwonlyargs annotations = list( merge_annotations( @@ -468,43 +461,39 @@ def format_args(args_node): # pylint: disable=too-many-branches,too-many-statem annotation_offset : annotation_offset + num_args ] - result.append( - _format_args( - args_node.posonlyargs, positional_only_defaults, posonlyargs_annotations - ) - ) - result.append("/") + for arg, annotation, default in _iter_args( + args_node.posonlyargs, posonlyargs_annotations, positional_only_defaults + ): + result.append((None, arg, annotation, default)) + + result.append(("/", None, None, None)) if not any(args_node.posonlyargs_annotations): annotation_offset += num_args if args_node.args: num_args = len(args_node.args) - result.append( - _format_args( - args_node.args, - positional_or_keyword_defaults, - annotations[annotation_offset : annotation_offset + num_args], - ) - ) + for arg, annotation, default in _iter_args( + args_node.args, + annotations[annotation_offset : annotation_offset + num_args], + positional_or_keyword_defaults, + ): + result.append((None, arg, annotation, default)) + annotation_offset += num_args if args_node.vararg: - vararg_result = "*{}".format(args_node.vararg) + annotation = None if args_node.varargannotation: - vararg_result = "{}: {}".format( - vararg_result, args_node.varargannotation.as_string() - ) + annotation = _format_annotation(args_node.varargannotation) elif len(annotations) > annotation_offset and annotations[annotation_offset]: - vararg_result = "{}: {}".format( - vararg_result, annotations[annotation_offset].as_string() - ) + annotation = _format_annotation(annotations[annotation_offset]) annotation_offset += 1 - result.append(vararg_result) + result.append(("*", args_node.vararg, annotation, None)) if args_node.kwonlyargs: if not args_node.vararg: - result.append("*") + result.append(("*", None, None, None)) kwonlyargs_annotations = args_node.kwonlyargs_annotations if not any(args_node.kwonlyargs_annotations): @@ -513,33 +502,55 @@ def format_args(args_node): # pylint: disable=too-many-branches,too-many-statem annotation_offset : annotation_offset + num_args ] - result.append( - _format_args( - args_node.kwonlyargs, - args_node.kw_defaults, - kwonlyargs_annotations, - ) - ) + for arg, annotation, default in _iter_args( + args_node.kwonlyargs, + kwonlyargs_annotations, + args_node.kw_defaults, + ): + result.append((None, arg, annotation, default)) if not any(args_node.kwonlyargs_annotations): annotation_offset += num_args if args_node.kwarg: - kwarg_result = "**{}".format(args_node.kwarg) + annotation = None if args_node.kwargannotation: - kwarg_result = "{}: {}".format( - kwarg_result, args_node.kwargannotation.as_string() - ) + annotation = _format_annotation(args_node.kwargannotation) elif len(annotations) > annotation_offset and annotations[annotation_offset]: - kwarg_result = "{}: {}".format( - kwarg_result, annotations[annotation_offset].as_string() - ) + annotation = _format_annotation(annotations[annotation_offset]) annotation_offset += 1 - result.append(kwarg_result) + result.append(("**", args_node.kwarg, annotation, None)) + + return result + + +def format_args(args_node): + result = [] + + args_info = _get_args_info(args_node) + for prefix, name, annotation, default in args_info: + formatted = "{}{}{}{}".format( + prefix or "", + name or "", + ": {}".format(annotation) if annotation else "", + (" = {}" if annotation else "={}").format(default) if default else "", + ) + result.append(formatted) return ", ".join(result) +def get_annotations_dict(args_node): + result = collections.OrderedDict() + + args_info = _get_args_info(args_node) + for _, name, annotation, __ in args_info: + if name and annotation: + result[name] = annotation + + return result + + def get_func_docstring(node): """Get the docstring of a node, using a parent docstring if needed. diff --git a/autoapi/mappers/python/mapper.py b/autoapi/mappers/python/mapper.py index 9a56561..21b685a 100644 --- a/autoapi/mappers/python/mapper.py +++ b/autoapi/mappers/python/mapper.py @@ -269,7 +269,8 @@ class PythonSphinxMapper(SphinxMapperBase): parsed_data = Parser().parse_file(path) return parsed_data except (IOError, TypeError, ImportError): - LOGGER.warning("Error reading file: {0}".format(path)) + LOGGER.warning("Unable to read file: {0}".format(path)) + LOGGER.debug("Reason:", exc_info=True) return None def _resolve_placeholders(self): @@ -325,8 +326,9 @@ class PythonSphinxMapper(SphinxMapperBase): if lines and "autodoc-process-docstring" in self.app.events.events: self.app.emit( "autodoc-process-docstring", cls.type, obj.name, None, None, lines - ) # object # options + ) obj.docstring = "\n".join(lines) + self._record_typehints(obj) for child_data in data.get("children", []): for child_obj in self.create_class( @@ -341,3 +343,8 @@ class PythonSphinxMapper(SphinxMapperBase): obj.children.sort(key=lambda x: (x.member_order, x.name)) yield obj + + def _record_typehints(self, obj): + if isinstance(obj, (PythonClass, PythonFunction, PythonMethod)): + annotations = self.app.env.temp_data.setdefault("annotations", {}) + annotations[obj.id] = obj.obj["annotations"] diff --git a/autoapi/mappers/python/parser.py b/autoapi/mappers/python/parser.py index e0cdc8c..5b28a12 100644 --- a/autoapi/mappers/python/parser.py +++ b/autoapi/mappers/python/parser.py @@ -87,13 +87,15 @@ class Parser(object): type_ = "exception" args = "" + annotations = {} try: constructor = node.lookup("__init__")[1] except IndexError: pass else: if isinstance(constructor, astroid.nodes.FunctionDef): - args = constructor.args.as_string() + args = astroid_utils.format_args(constructor.args) + annotations = astroid_utils.get_annotations_dict(constructor.args) basenames = list(astroid_utils.get_full_basenames(node.bases, node.basenames)) @@ -102,6 +104,7 @@ class Parser(object): "name": node.name, "full_name": self._get_full_name(node.name), "args": args, + "annotations": annotations, "bases": basenames, "doc": astroid_utils.get_class_docstring(node), "from_line_no": node.fromlineno, @@ -163,11 +166,14 @@ class Parser(object): if isinstance(node, astroid.AsyncFunctionDef): properties.append("async") + annotations = astroid_utils.get_annotations_dict(node.args) return_annotation = None if node.returns: return_annotation = node.returns.as_string() + annotations["return"] = return_annotation elif node.type_comment_returns: return_annotation = node.type_comment_returns.as_string() + annotations["return"] = return_annotation arg_string = astroid_utils.format_args(node.args) @@ -176,6 +182,7 @@ class Parser(object): "name": node.name, "full_name": self._get_full_name(node.name), "args": arg_string, + "annotations": annotations, "doc": astroid_utils.get_func_docstring(node), "from_line_no": node.fromlineno, "to_line_no": node.tolineno, diff --git a/docs/how_to.rst b/docs/how_to.rst index 8683e29..196deaa 100644 --- a/docs/how_to.rst +++ b/docs/how_to.rst @@ -95,3 +95,42 @@ using the :confval:`autoapi_keep_files` option:: Once you have built your documentation with this option turned on, you can disable AutoAPI altogether from your project. + + +How to Include Type Annotations as Types in Rendered Docstrings +--------------------------------------------------------------- + +Since v3.0, :mod:`sphinx` has included an :mod:`sphinx.ext.autodoc.typehints` +extension that is capable of rendering type annotations as +parameter types and return types. + +For example the following ReST: + +.. code-block:: + + .. py:function:: _func(a: int, b: Optional[str]) -> bool + + :param a: The first arg. + :param b: The second arg. + + :returns: Something. + +would be rendered as: + +.. py:function:: _func(a: int, b: Optional[str]) -> bool + :noindex: + + :param int a: The first arg. + :param b: The second arg. + :type b: Optional[str] + + :returns: Something. + :rtype: bool + +AutoAPI is capable of the same thing. +To enable this behaviour, load the :mod:`sphinx.ext.autodoc.typehints` +(or :mod:`sphinx.ext.autodoc`) extension in Sphinx's ``conf.py`` file +and set :confval:`autodoc_typehints` to ``description`` as normal:: + + extensions = ['sphinx.ext.autodoc', 'autoapi.extension'] + autodoc_typehints = 'description' diff --git a/tests/python/py3example/example/example.py b/tests/python/py3example/example/example.py index 83816e1..dd2d821 100644 --- a/tests/python/py3example/example/example.py +++ b/tests/python/py3example/example/example.py @@ -119,7 +119,12 @@ class A: return a * 2 -async def async_function(self, wait: bool) -> int: +async def async_function(wait: bool) -> int: + """Blah. + + Args: + wait: Blah + """ if wait: await asyncio.sleep(1) diff --git a/tests/python/test_astroid_utils.py b/tests/python/test_astroid_utils.py new file mode 100644 index 0000000..988049f --- /dev/null +++ b/tests/python/test_astroid_utils.py @@ -0,0 +1,56 @@ +import astroid +from autoapi.mappers.python import astroid_utils +import pytest + + +@pytest.mark.parametrize( + "signature,expected", + [ + ("a: bool, b: int = 5", {"a": "bool", "b": "int"}), + ("a: bool, /, b: int, *, c: str", {"a": "bool", "b": "int", "c": "str"}), + ( + "a: bool, /, b: int, *args, c: str, **kwargs", + {"a": "bool", "b": "int", "c": "str"}, + ), + ("a: int, *args, b: str, **kwargs", {"a": "int", "b": "str"}), + ], +) +def test_parse_annotations(signature, expected): + node = astroid.extract_node( + """ + def func({}) -> str: #@ + pass + """.format( + signature + ) + ) + + annotations = astroid_utils.get_annotations_dict(node.args) + assert annotations == expected + + +@pytest.mark.parametrize( + "signature,expected", + [ + ("a: bool, b: int = 5, c='hi'", "a: bool, b: int = 5, c='hi'"), + ("a: bool, /, b: int, *, c: str", "a: bool, /, b: int, *, c: str"), + ( + "a: bool, /, b: int, *args, c: str, **kwargs", + "a: bool, /, b: int, *args, c: str, **kwargs", + ), + ("a: int, *args, b: str, **kwargs", "a: int, *args, b: str, **kwargs"), + ("a: 'A'", "a: A"), + ], +) +def test_format_args(signature, expected): + node = astroid.extract_node( + """ + def func({}) -> str: #@ + pass + """.format( + signature + ) + ) + + formatted = astroid_utils.format_args(node.args) + assert formatted == expected