Can use type hints as parameter types and return types

This commit is contained in:
Ashley Whetter 2020-10-05 13:18:35 -07:00
parent 8e4cd49e1a
commit 6f0299356a
7 changed files with 201 additions and 69 deletions

View File

@ -12,6 +12,13 @@ Breaking Changes
* Dropped support for Python 2 and Sphinx 1.x/2.x. * Dropped support for Python 2 and Sphinx 1.x/2.x.
Python 2 source code can still be parsed. 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) V1.5.1 (2020-10-01)
------------------- -------------------

View File

@ -1,4 +1,5 @@
import builtins import builtins
import collections
import itertools import itertools
import re import re
import sys import sys
@ -402,41 +403,33 @@ def merge_annotations(annotations, comment_annotations):
yield None yield None
def _format_args(args, defaults=None, annotations=None): def _format_annotation(annotation):
values = [] if annotation:
if isinstance(annotation, astroid.Const):
annotation = annotation.value
else:
annotation = annotation.as_string()
if args is None: return annotation
return ""
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) packed = itertools.zip_longest(args, annotations)
for i, (arg, annotation) in enumerate(packed): 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): if isinstance(arg, astroid.Tuple):
values.append("({})".format(_format_args(arg.elts))) name = "({})".format(", ".join(x.name for x in 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)
if defaults is not None and i >= default_offset: yield (name, _format_annotation(annotation), default)
if defaults[i - default_offset] is not None:
values[-1] += default_sep + defaults[i - default_offset].as_string()
return ", ".join(values)
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 = [] result = []
positional_only_defaults = [] positional_only_defaults = []
positional_or_keyword_defaults = args_node.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 () plain_annotations = args_node.annotations or ()
func_comment_annotations = args_node.parent.type_comment_args 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 = args_node.type_comment_posonlyargs + comment_annotations comment_annotations += args_node.type_comment_args or []
comment_annotations += args_node.type_comment_kwonlyargs comment_annotations += args_node.type_comment_kwonlyargs
annotations = list( annotations = list(
merge_annotations( 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 annotation_offset : annotation_offset + num_args
] ]
result.append( for arg, annotation, default in _iter_args(
_format_args( args_node.posonlyargs, posonlyargs_annotations, positional_only_defaults
args_node.posonlyargs, positional_only_defaults, posonlyargs_annotations ):
) result.append((None, arg, annotation, default))
)
result.append("/") result.append(("/", None, None, None))
if not any(args_node.posonlyargs_annotations): if not any(args_node.posonlyargs_annotations):
annotation_offset += num_args annotation_offset += num_args
if args_node.args: if args_node.args:
num_args = len(args_node.args) num_args = len(args_node.args)
result.append( for arg, annotation, default in _iter_args(
_format_args( args_node.args,
args_node.args, annotations[annotation_offset : annotation_offset + num_args],
positional_or_keyword_defaults, positional_or_keyword_defaults,
annotations[annotation_offset : annotation_offset + num_args], ):
) result.append((None, arg, annotation, default))
)
annotation_offset += num_args annotation_offset += num_args
if args_node.vararg: if args_node.vararg:
vararg_result = "*{}".format(args_node.vararg) annotation = None
if args_node.varargannotation: if args_node.varargannotation:
vararg_result = "{}: {}".format( annotation = _format_annotation(args_node.varargannotation)
vararg_result, args_node.varargannotation.as_string()
)
elif len(annotations) > annotation_offset and annotations[annotation_offset]: elif len(annotations) > annotation_offset and annotations[annotation_offset]:
vararg_result = "{}: {}".format( annotation = _format_annotation(annotations[annotation_offset])
vararg_result, annotations[annotation_offset].as_string()
)
annotation_offset += 1 annotation_offset += 1
result.append(vararg_result) result.append(("*", args_node.vararg, annotation, None))
if args_node.kwonlyargs: if args_node.kwonlyargs:
if not args_node.vararg: if not args_node.vararg:
result.append("*") result.append(("*", None, None, None))
kwonlyargs_annotations = args_node.kwonlyargs_annotations kwonlyargs_annotations = args_node.kwonlyargs_annotations
if not any(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 annotation_offset : annotation_offset + num_args
] ]
result.append( for arg, annotation, default in _iter_args(
_format_args( args_node.kwonlyargs,
args_node.kwonlyargs, kwonlyargs_annotations,
args_node.kw_defaults, args_node.kw_defaults,
kwonlyargs_annotations, ):
) result.append((None, arg, annotation, default))
)
if not any(args_node.kwonlyargs_annotations): if not any(args_node.kwonlyargs_annotations):
annotation_offset += num_args annotation_offset += num_args
if args_node.kwarg: if args_node.kwarg:
kwarg_result = "**{}".format(args_node.kwarg) annotation = None
if args_node.kwargannotation: if args_node.kwargannotation:
kwarg_result = "{}: {}".format( annotation = _format_annotation(args_node.kwargannotation)
kwarg_result, args_node.kwargannotation.as_string()
)
elif len(annotations) > annotation_offset and annotations[annotation_offset]: elif len(annotations) > annotation_offset and annotations[annotation_offset]:
kwarg_result = "{}: {}".format( annotation = _format_annotation(annotations[annotation_offset])
kwarg_result, annotations[annotation_offset].as_string()
)
annotation_offset += 1 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) 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): def get_func_docstring(node):
"""Get the docstring of a node, using a parent docstring if needed. """Get the docstring of a node, using a parent docstring if needed.

View File

@ -269,7 +269,8 @@ class PythonSphinxMapper(SphinxMapperBase):
parsed_data = Parser().parse_file(path) parsed_data = Parser().parse_file(path)
return parsed_data return parsed_data
except (IOError, TypeError, ImportError): 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 return None
def _resolve_placeholders(self): def _resolve_placeholders(self):
@ -325,8 +326,9 @@ class PythonSphinxMapper(SphinxMapperBase):
if lines and "autodoc-process-docstring" in self.app.events.events: if lines and "autodoc-process-docstring" in self.app.events.events:
self.app.emit( self.app.emit(
"autodoc-process-docstring", cls.type, obj.name, None, None, lines "autodoc-process-docstring", cls.type, obj.name, None, None, lines
) # object # options )
obj.docstring = "\n".join(lines) obj.docstring = "\n".join(lines)
self._record_typehints(obj)
for child_data in data.get("children", []): for child_data in data.get("children", []):
for child_obj in self.create_class( 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)) obj.children.sort(key=lambda x: (x.member_order, x.name))
yield obj 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"]

View File

@ -87,13 +87,15 @@ class Parser(object):
type_ = "exception" type_ = "exception"
args = "" args = ""
annotations = {}
try: try:
constructor = node.lookup("__init__")[1] constructor = node.lookup("__init__")[1]
except IndexError: except IndexError:
pass pass
else: else:
if isinstance(constructor, astroid.nodes.FunctionDef): 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)) basenames = list(astroid_utils.get_full_basenames(node.bases, node.basenames))
@ -102,6 +104,7 @@ class Parser(object):
"name": node.name, "name": node.name,
"full_name": self._get_full_name(node.name), "full_name": self._get_full_name(node.name),
"args": args, "args": args,
"annotations": annotations,
"bases": basenames, "bases": basenames,
"doc": astroid_utils.get_class_docstring(node), "doc": astroid_utils.get_class_docstring(node),
"from_line_no": node.fromlineno, "from_line_no": node.fromlineno,
@ -163,11 +166,14 @@ class Parser(object):
if isinstance(node, astroid.AsyncFunctionDef): if isinstance(node, astroid.AsyncFunctionDef):
properties.append("async") properties.append("async")
annotations = astroid_utils.get_annotations_dict(node.args)
return_annotation = None return_annotation = None
if node.returns: if node.returns:
return_annotation = node.returns.as_string() return_annotation = node.returns.as_string()
annotations["return"] = return_annotation
elif node.type_comment_returns: elif node.type_comment_returns:
return_annotation = node.type_comment_returns.as_string() return_annotation = node.type_comment_returns.as_string()
annotations["return"] = return_annotation
arg_string = astroid_utils.format_args(node.args) arg_string = astroid_utils.format_args(node.args)
@ -176,6 +182,7 @@ class Parser(object):
"name": node.name, "name": node.name,
"full_name": self._get_full_name(node.name), "full_name": self._get_full_name(node.name),
"args": arg_string, "args": arg_string,
"annotations": annotations,
"doc": astroid_utils.get_func_docstring(node), "doc": astroid_utils.get_func_docstring(node),
"from_line_no": node.fromlineno, "from_line_no": node.fromlineno,
"to_line_no": node.tolineno, "to_line_no": node.tolineno,

View File

@ -95,3 +95,42 @@ using the :confval:`autoapi_keep_files` option::
Once you have built your documentation with this option turned on, Once you have built your documentation with this option turned on,
you can disable AutoAPI altogether from your project. 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'

View File

@ -119,7 +119,12 @@ class A:
return a * 2 return a * 2
async def async_function(self, wait: bool) -> int: async def async_function(wait: bool) -> int:
"""Blah.
Args:
wait: Blah
"""
if wait: if wait:
await asyncio.sleep(1) await asyncio.sleep(1)

View File

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