diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e87cdc1..6930adf 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,12 +14,21 @@ Features * `#265 ` Can resolve the qualified paths of parameters to generics. +Bug Fixes +^^^^^^^^^ + +* `#273 ` + Fixed setting ``autodoc_typehints`` to ``none`` or ``description`` + not turning off signature type hints. + ``autodoc_typehints`` integration is consisidered experimental until + the extension properly supports overload functions. + Trivial/Internal Changes ^^^^^^^^^^^^^^^^^^^^^^^^ -* Fixed `DeprecationWarning` for invalid escape sequence `\s` in tests. -* Fixed `FutureWarning` for `Node.traverse()` becoming an iterator instead of list. -* New example implementation of `autoapi-skip-member` Sphinx event. +* Fixed ``DeprecationWarning`` for invalid escape sequence ``\s`` in tests. +* Fixed ``FutureWarning`` for ``Node.traverse()`` becoming an iterator instead of list. +* New example implementation of ``autoapi-skip-member`` Sphinx event. * Can run tests with tox 4. diff --git a/autoapi/mappers/python/astroid_utils.py b/autoapi/mappers/python/astroid_utils.py index 61b3a93..d22170e 100644 --- a/autoapi/mappers/python/astroid_utils.py +++ b/autoapi/mappers/python/astroid_utils.py @@ -1,5 +1,4 @@ import builtins -import collections import itertools import re import sys @@ -114,7 +113,7 @@ def get_full_basenames(node): :returns: The full names. :rtype: iterable(str) """ - for base, basename in zip(node.bases, node.basenames): + for base in node.bases: yield _resolve_annotation(base) @@ -415,7 +414,9 @@ def _resolve_annotation(annotation): elif isinstance(annotation, astroid.Subscript): value = _resolve_annotation(annotation.value) if isinstance(annotation.slice, astroid.Tuple): - slice_ = ", ".join(_resolve_annotation(elt) for elt in annotation.slice.elts) + slice_ = ", ".join( + _resolve_annotation(elt) for elt in annotation.slice.elts + ) else: slice_ = _resolve_annotation(annotation.slice) resolved = f"{value}[{slice_}]" @@ -471,7 +472,7 @@ def _iter_args(args, annotations, defaults): yield (name, format_annotation(annotation, arg.parent), default) -def _get_args_info(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 @@ -570,31 +571,18 @@ def _get_args_info(args_node): # pylint: disable=too-many-branches,too-many-sta return result -def format_args(args_node): - result = [] +def get_return_annotation(node): + """Get the return annotation of a node. + :type node: astroid.nodes.FunctionDef + """ + return_annotation = None - 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) + if node.returns: + return_annotation = format_annotation(node.returns, node) + elif node.type_comment_returns: + return_annotation = format_annotation(node.type_comment_returns, node) - 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 + return return_annotation def get_func_docstring(node): diff --git a/autoapi/mappers/python/mapper.py b/autoapi/mappers/python/mapper.py index d7bb509..b9e75cd 100644 --- a/autoapi/mappers/python/mapper.py +++ b/autoapi/mappers/python/mapper.py @@ -213,7 +213,7 @@ def _link_objs(value): else: result += "\\ " elif sub_target: - result += f":py:obj:`{sub_target}`\ " + result += f":py:obj:`{sub_target}`\\ " # Strip off the extra "\ " return result[:-2] @@ -367,7 +367,7 @@ class PythonSphinxMapper(SphinxMapperBase): options=self.app.config.autoapi_options, jinja_env=self.jinja_env, app=self.app, - **kwargs + **kwargs, ) obj.url_root = self.url_root @@ -395,5 +395,14 @@ class PythonSphinxMapper(SphinxMapperBase): def _record_typehints(self, obj): if isinstance(obj, (PythonClass, PythonFunction, PythonMethod)): + obj_annotations = {} + for _, name, annotation, _ in obj.obj["args"]: + if name and annotation: + obj_annotations[name] = annotation + + return_annotation = obj.obj.get("return_annotation") + if return_annotation: + obj_annotations["return"] = return_annotation + annotations = self.app.env.temp_data.setdefault("annotations", {}) - annotations[obj.id] = obj.obj["annotations"] + annotations[obj.id] = obj_annotations diff --git a/autoapi/mappers/python/objects.py b/autoapi/mappers/python/objects.py index 7150d09..5a4f28a 100644 --- a/autoapi/mappers/python/objects.py +++ b/autoapi/mappers/python/objects.py @@ -8,6 +8,21 @@ from ..base import PythonMapperBase LOGGER = sphinx.util.logging.getLogger(__name__) +def _format_args(args_info, include_annotations=True): + result = [] + + for prefix, name, annotation, default in args_info: + formatted = "{}{}{}{}".format( + prefix or "", + name or "", + ": {}".format(annotation) if annotation and include_annotations else "", + (" = {}" if annotation else "={}").format(default) if default else "", + ) + result.append(formatted) + + return ", ".join(result) + + class PythonPythonMapper(PythonMapperBase): """A base class for all types of representations of Python objects. @@ -31,7 +46,6 @@ class PythonPythonMapper(PythonMapperBase): # Optional self.children = [] - self.args = obj.get("args") self.docstring = obj["doc"] self.imported = "original_path" in obj self.inherited = obj.get("inherited", False) @@ -45,21 +59,6 @@ class PythonPythonMapper(PythonMapperBase): self._display_cache = None # type: Optional[bool] - @property - def args(self): - """The arguments to this object, formatted as a string. - - This will only be set for a function, method, or class. - For classes, this does not include ``self``. - - :type: str or None - """ - return self._args - - @args.setter - def args(self, value): - self._args = value - @property def docstring(self): """The docstring for this object. @@ -170,7 +169,11 @@ class PythonFunction(PythonPythonMapper): def __init__(self, obj, **kwargs): super(PythonFunction, self).__init__(obj, **kwargs) - self.return_annotation = obj["return_annotation"] + autodoc_typehints = getattr(self.app.config, "autodoc_typehints", "signature") + show_annotations = autodoc_typehints not in ("none", "description") + self.args = _format_args(obj["args"], show_annotations) + + self.return_annotation = obj["return_annotation"] if show_annotations else None """The type annotation for the return type of this function. This will be ``None`` if an annotation @@ -185,12 +188,31 @@ class PythonFunction(PythonPythonMapper): :type: list(str) """ - self.overloads = obj["overloads"] + self.overloads = ( + [ + (_format_args(args), return_annotation) + for args, return_annotation in obj["overloads"] + ] + if show_annotations + else [] + ) """The list of overloaded signatures ``[(args, return_annotation), ...]`` of this function. :type: list(tuple(str, str)) """ + @property + def args(self): + """The arguments to this object, formatted as a string. + + :type: str + """ + return self._args + + @args.setter + def args(self, value): + self._args = value + class PythonMethod(PythonFunction): """The representation of a method.""" @@ -241,7 +263,7 @@ class PythonData(PythonPythonMapper): :type: str or None """ - self.annotation = obj.get("annotation", obj.get("return_annotation")) + self.annotation = obj.get("annotation") """The type annotation of this attribute. This will be ``None`` if an annotation @@ -323,6 +345,8 @@ class PythonClass(PythonPythonMapper): def __init__(self, obj, **kwargs): super(PythonClass, self).__init__(obj, **kwargs) + self.args = obj["args"] + self.bases = obj["bases"] """The fully qualified names of all base classes. @@ -331,6 +355,10 @@ class PythonClass(PythonPythonMapper): @property def args(self): + """The arguments to this object, formatted as a string. + + :type: str + """ args = self._args constructor = self.constructor diff --git a/autoapi/mappers/python/parser.py b/autoapi/mappers/python/parser.py index 3cf1e06..8390f07 100644 --- a/autoapi/mappers/python/parser.py +++ b/autoapi/mappers/python/parser.py @@ -86,16 +86,14 @@ class Parser(object): if astroid_utils.is_exception(node): type_ = "exception" - args = "" - annotations = {} + args = [] try: constructor = node.lookup("__init__")[1] except IndexError: pass else: if isinstance(constructor, astroid.nodes.FunctionDef): - args = astroid_utils.format_args(constructor.args) - annotations = astroid_utils.get_annotations_dict(constructor.args) + args = astroid_utils.get_args_info(constructor.args) basenames = list(astroid_utils.get_full_basenames(node)) @@ -104,7 +102,6 @@ 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, @@ -170,29 +167,15 @@ 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 = astroid_utils.format_annotation(node.returns, node) - annotations["return"] = return_annotation - elif node.type_comment_returns: - return_annotation = astroid_utils.format_annotation( - node.type_comment_returns, node - ) - annotations["return"] = return_annotation - - arg_string = astroid_utils.format_args(node.args) - data = { "type": type_, "name": node.name, "full_name": self._get_full_name(node.name), - "args": arg_string, - "annotations": annotations, + "args": astroid_utils.get_args_info(node.args), "doc": astroid_utils.get_func_docstring(node), "from_line_no": node.fromlineno, "to_line_no": node.tolineno, - "return_annotation": return_annotation, + "return_annotation": astroid_utils.get_return_annotation(node), "properties": properties, "is_overload": astroid_utils.is_decorated_with_overload(node), "overloads": [], diff --git a/docs/how_to.rst b/docs/how_to.rst index 196deaa..9231604 100644 --- a/docs/how_to.rst +++ b/docs/how_to.rst @@ -100,24 +100,30 @@ you can disable AutoAPI altogether from your project. How to Include Type Annotations as Types in Rendered Docstrings --------------------------------------------------------------- +.. warning:: + + This feature is experimental and may change or be removed in future versions. + 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: +For example the following function: .. code-block:: - .. py:function:: _func(a: int, b: Optional[str]) -> bool + def _func(a: int, b: Optional[str]) -> bool + """My function. :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 +.. py:function:: _func(a, b) :noindex: :param int a: The first arg. @@ -134,3 +140,12 @@ and set :confval:`autodoc_typehints` to ``description`` as normal:: extensions = ['sphinx.ext.autodoc', 'autoapi.extension'] autodoc_typehints = 'description' + +.. note:: + + The :mod:`sphinx.ext.autodoc.typehints` extension does not support overload functions. + Overloads will not be output when :confval:`autodoc_typehints` is set to + anything other than ``signature``. + When a documented parameter names a parameter that is specified in only an overload, + not the final function definition, the type will not be included in the description + when :confval:`autodoc_typehints` is set to ``description``. diff --git a/tests/python/py3example/example/example.py b/tests/python/py3example/example/example.py index e0f4536..1e4c3bd 100644 --- a/tests/python/py3example/example/example.py +++ b/tests/python/py3example/example/example.py @@ -159,8 +159,10 @@ class C: class D(C): class Da: ... + class DB(Da): ... + ... diff --git a/tests/python/test_parser.py b/tests/python/test_parser.py index 58c27b3..867daa2 100644 --- a/tests/python/test_parser.py +++ b/tests/python/test_parser.py @@ -74,7 +74,17 @@ class PythonParserTests(unittest.TestCase): " return True\n" ) data = self.parse(source)[0] - self.assertEqual(data["args"], "self, bar, baz=42, foo=True, *args, **kwargs") + self.assertEqual( + data["args"], + [ + (None, "self", None, None), + (None, "bar", None, None), + (None, "baz", None, "42"), + (None, "foo", None, "True"), + ("*", "args", None, None), + ("**", "kwargs", None, None), + ], + ) def test_advanced_arguments(self): """Advanced argument parsing""" @@ -88,23 +98,21 @@ class PythonParserTests(unittest.TestCase): data = self.parse(source)[0] self.assertEqual( data["args"], - ", ".join( - [ - "self", - "a", - "b", - "c=42", - "d='string'", - "e=(1, 2)", - "f={'a': True}", - "g=None", - "h=[1, 2, 3, 4]", - "i=dict(a=True)", - "j=False", - "*args", - "**kwargs", - ] - ), + [ + (None, "self", None, None), + (None, "a", None, None), + (None, "b", None, None), + (None, "c", None, "42"), + (None, "d", None, "'string'"), + (None, "e", None, "(1, 2)"), + (None, "f", None, "{'a': True}"), + (None, "g", None, "None"), + (None, "h", None, "[1, 2, 3, 4]"), + (None, "i", None, "dict(a=True)"), + (None, "j", None, "False"), + ("*", "args", None, None), + ("**", "kwargs", None, None), + ], ) def test_dict_key_assignment(self): diff --git a/tests/test_astroid_utils.py b/tests/test_astroid_utils.py index cce80ea..a1baf3f 100644 --- a/tests/test_astroid_utils.py +++ b/tests/test_astroid_utils.py @@ -1,7 +1,7 @@ import sys import astroid -from autoapi.mappers.python import astroid_utils +from autoapi.mappers.python import astroid_utils, objects import pytest @@ -105,24 +105,45 @@ class TestAstroidUtils(object): @pytest.mark.parametrize( "signature,expected", [ - ("a: bool, b: int = 5", {"a": "bool", "b": "int"}), + ( + "a: bool, b: int = 5", + [(None, "a", "bool", None), (None, "b", "int", "5")], + ), pytest.param( "a: bool, /, b: int, *, c: str", - {"a": "bool", "b": "int", "c": "str"}, + [ + (None, "a", "bool", None), + ("/", None, None, None), + (None, "b", "int", None), + ("*", None, None, None), + (None, "c", "str", None), + ], marks=pytest.mark.skipif( sys.version_info[:2] < (3, 8), reason="Uses Python 3.8+ syntax" ), ), pytest.param( "a: bool, /, b: int, *args, c: str, **kwargs", - {"a": "bool", "b": "int", "c": "str"}, + [ + (None, "a", "bool", None), + ("/", None, None, None), + (None, "b", "int", None), + ("*", "args", None, None), + (None, "c", "str", None), + ("**", "kwargs", None, None), + ], marks=pytest.mark.skipif( sys.version_info[:2] < (3, 8), reason="Uses Python 3.8+ syntax" ), ), pytest.param( "a: int, *args, b: str, **kwargs", - {"a": "int", "b": "str"}, + [ + (None, "a", "int", None), + ("*", "args", None, None), + (None, "b", "str", None), + ("**", "kwargs", None, None), + ], marks=pytest.mark.skipif( sys.version_info[:2] < (3, 8), reason="Uses Python 3.8+ syntax" ), @@ -139,7 +160,7 @@ class TestAstroidUtils(object): ) ) - annotations = astroid_utils.get_annotations_dict(node.args) + annotations = astroid_utils.get_args_info(node.args) assert annotations == expected @pytest.mark.parametrize( @@ -174,5 +195,6 @@ class TestAstroidUtils(object): ) ) - formatted = astroid_utils.format_args(node.args) + args_info = astroid_utils.get_args_info(node.args) + formatted = objects._format_args(args_info) assert formatted == expected