diff --git a/doc/usage/extensions/autodoc.rst b/doc/usage/extensions/autodoc.rst index 034c86e1132..5ee71c78293 100644 --- a/doc/usage/extensions/autodoc.rst +++ b/doc/usage/extensions/autodoc.rst @@ -571,6 +571,19 @@ There are also config values that you can set: New option ``'description'`` is added. +.. confval:: autodoc_typehints_description_target + + This value controls whether the types of undocumented parameters and return + values are documented when ``autodoc_typehints`` is set to ``description``. + + The default value is ``"all"``, meaning that types are documented for all + parameters and return values, whether they are documented or not. + + When set to ``"documented"``, types will only be documented for a parameter + or a return value that is already documented by the docstring. + + .. versionadded:: 4.0 + .. confval:: autodoc_type_aliases A dictionary for users defined `type aliases`__ that maps a type name to the diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index 3d33b6a8e83..57499ec6fd9 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -2656,6 +2656,8 @@ def setup(app: Sphinx) -> Dict[str, Any]: app.add_config_value('autodoc_mock_imports', [], True) app.add_config_value('autodoc_typehints', "signature", True, ENUM("signature", "description", "none")) + app.add_config_value('autodoc_typehints_description_target', 'all', True, + ENUM('all', 'documented')) app.add_config_value('autodoc_type_aliases', {}, True) app.add_config_value('autodoc_warningiserror', True, True) app.add_config_value('autodoc_inherit_docstrings', True, True) diff --git a/sphinx/ext/autodoc/typehints.py b/sphinx/ext/autodoc/typehints.py index 9811bdb554c..1cc2abd090a 100644 --- a/sphinx/ext/autodoc/typehints.py +++ b/sphinx/ext/autodoc/typehints.py @@ -10,7 +10,7 @@ import re from collections import OrderedDict -from typing import Any, Dict, Iterable, cast +from typing import Any, Dict, Iterable, Set, cast from docutils import nodes from docutils.nodes import Element @@ -42,8 +42,6 @@ def merge_typehints(app: Sphinx, domain: str, objtype: str, contentnode: Element return if app.config.autodoc_typehints != 'description': return - if objtype == 'class' and app.config.autoclass_content not in ('init', 'both'): - return try: signature = cast(addnodes.desc_signature, contentnode.parent[0]) @@ -63,7 +61,10 @@ def merge_typehints(app: Sphinx, domain: str, objtype: str, contentnode: Element field_lists.append(field_list) for field_list in field_lists: - modify_field_list(field_list, annotations[fullname]) + if app.config.autodoc_typehints_description_target == "all": + modify_field_list(field_list, annotations[fullname]) + else: + augment_descriptions_with_types(field_list, annotations[fullname]) def insert_field_list(node: Element) -> nodes.field_list: @@ -126,6 +127,52 @@ def modify_field_list(node: nodes.field_list, annotations: Dict[str, str]) -> No node += field +def augment_descriptions_with_types( + node: nodes.field_list, + annotations: Dict[str, str], +) -> None: + fields = cast(Iterable[nodes.field], node) + has_description = set() # type: Set[str] + has_type = set() # type: Set[str] + for field in fields: + field_name = field[0].astext() + parts = re.split(' +', field_name) + if parts[0] == 'param': + if len(parts) == 2: + # :param xxx: + has_description.add(parts[1]) + elif len(parts) > 2: + # :param xxx yyy: + name = ' '.join(parts[2:]) + has_description.add(name) + has_type.add(name) + elif parts[0] == 'type': + name = ' '.join(parts[1:]) + has_type.add(name) + elif parts[0] == 'return': + has_description.add('return') + elif parts[0] == 'rtype': + has_type.add('return') + + # Add 'type' for parameters with a description but no declared type. + for name in annotations: + if name == 'return': + continue + if name in has_description and name not in has_type: + field = nodes.field() + field += nodes.field_name('', 'type ' + name) + field += nodes.field_body('', nodes.paragraph('', annotations[name])) + node += field + + # Add 'rtype' if 'return' is present and 'rtype' isn't. + if 'return' in annotations: + if 'return' in has_description and 'return' not in has_type: + field = nodes.field() + field += nodes.field_name('', 'rtype') + field += nodes.field_body('', nodes.paragraph('', annotations['return'])) + node += field + + def setup(app: Sphinx) -> Dict[str, Any]: app.connect('autodoc-process-signature', record_typehints) app.connect('object-description-transform', merge_typehints) diff --git a/tests/roots/test-ext-autodoc/target/typehints.py b/tests/roots/test-ext-autodoc/target/typehints.py index 2c903965082..bb56054c30f 100644 --- a/tests/roots/test-ext-autodoc/target/typehints.py +++ b/tests/roots/test-ext-autodoc/target/typehints.py @@ -68,3 +68,13 @@ def missing_attr(c, ): # type: (...) -> str return a + (b or "") + + +class _ClassWithDocumentedInit: + """Class docstring.""" + + def __init__(self, x: int) -> None: + """Init docstring. + + :param x: Some integer + """ diff --git a/tests/test_ext_autodoc_configs.py b/tests/test_ext_autodoc_configs.py index cc34143ca36..9477e3f957a 100644 --- a/tests/test_ext_autodoc_configs.py +++ b/tests/test_ext_autodoc_configs.py @@ -682,6 +682,90 @@ def test_autodoc_typehints_description(app): in context) +@pytest.mark.sphinx('text', testroot='ext-autodoc', + confoverrides={'autodoc_typehints': "description", + 'autodoc_typehints_description_target': 'documented'}) +def test_autodoc_typehints_description_no_undoc(app): + # No :type: or :rtype: will be injected for `incr`, which does not have + # a description for its parameters or its return. `tuple_args` does + # describe them, so :type: and :rtype: will be added. + (app.srcdir / 'index.rst').write_text( + '.. autofunction:: target.typehints.incr\n' + '\n' + '.. autofunction:: target.typehints.tuple_args\n' + '\n' + ' :param x: arg\n' + ' :return: another tuple\n' + ) + app.build() + context = (app.outdir / 'index.txt').read_text() + assert ('target.typehints.incr(a, b=1)\n' + '\n' + 'target.typehints.tuple_args(x)\n' + '\n' + ' Parameters:\n' + ' **x** (*Tuple**[**int**, **Union**[**int**, **str**]**]*) -- arg\n' + '\n' + ' Returns:\n' + ' another tuple\n' + '\n' + ' Return type:\n' + ' Tuple[int, int]\n' + in context) + + +@pytest.mark.sphinx('text', testroot='ext-autodoc', + confoverrides={'autodoc_typehints': "description"}) +def test_autodoc_typehints_description_with_documented_init(app): + (app.srcdir / 'index.rst').write_text( + '.. autoclass:: target.typehints._ClassWithDocumentedInit\n' + ' :special-members: __init__\n' + ) + app.build() + context = (app.outdir / 'index.txt').read_text() + assert ('class target.typehints._ClassWithDocumentedInit(x)\n' + '\n' + ' Class docstring.\n' + '\n' + ' Parameters:\n' + ' **x** (*int*) --\n' + '\n' + ' Return type:\n' + ' None\n' + '\n' + ' __init__(x)\n' + '\n' + ' Init docstring.\n' + '\n' + ' Parameters:\n' + ' **x** (*int*) -- Some integer\n' + '\n' + ' Return type:\n' + ' None\n' == context) + + +@pytest.mark.sphinx('text', testroot='ext-autodoc', + confoverrides={'autodoc_typehints': "description", + 'autodoc_typehints_description_target': 'documented'}) +def test_autodoc_typehints_description_with_documented_init_no_undoc(app): + (app.srcdir / 'index.rst').write_text( + '.. autoclass:: target.typehints._ClassWithDocumentedInit\n' + ' :special-members: __init__\n' + ) + app.build() + context = (app.outdir / 'index.txt').read_text() + assert ('class target.typehints._ClassWithDocumentedInit(x)\n' + '\n' + ' Class docstring.\n' + '\n' + ' __init__(x)\n' + '\n' + ' Init docstring.\n' + '\n' + ' Parameters:\n' + ' **x** (*int*) -- Some integer\n' == context) + + @pytest.mark.sphinx('text', testroot='ext-autodoc', confoverrides={'autodoc_typehints': "description"}) def test_autodoc_typehints_description_for_invalid_node(app):