Can use type hints as parameter types and return types

pull/253/head
Ashley Whetter 4 years ago
parent 8e4cd49e1a
commit 6f0299356a

@ -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)
-------------------

@ -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 = []
if args is None:
return ""
def _format_annotation(annotation):
if annotation:
if isinstance(annotation, astroid.Const):
annotation = annotation.value
else:
annotation = annotation.as_string()
if annotations is None:
annotations = []
return annotation
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):
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)
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()
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()
name = arg.name
if isinstance(arg, astroid.Tuple):
name = "({})".format(", ".join(x.name for x in arg.elts))
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.

@ -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"]

@ -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,

@ -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'

@ -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)

@ -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
Loading…
Cancel
Save