diff --git a/sphinx/util/docfields.py b/sphinx/util/docfields.py index 3a3367ebe5d..f92a962e2b0 100644 --- a/sphinx/util/docfields.py +++ b/sphinx/util/docfields.py @@ -9,7 +9,7 @@ :license: BSD, see LICENSE for details. """ -from typing import TYPE_CHECKING, Any, Dict, List, Tuple, Type, Union, cast +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Type, Union, cast from docutils import nodes from docutils.nodes import Node @@ -35,6 +35,37 @@ def _is_single_paragraph(node: nodes.field_body) -> bool: return False +def _split_typed_field_argument(fieldarg: str) -> Optional[Tuple[str, str]]: + """Split *fieldarg* into type/name around the first top-level whitespace.""" + stripped = fieldarg.lstrip() + if not stripped: + return None + + depth = 0 + opening = { + '(': ')', + '[': ']', + '{': '}', + '<': '>', + } + closing = {v: k for k, v in opening.items()} + + for index, char in enumerate(stripped): + if char in opening: + depth += 1 + elif char in closing: + if depth > 0: + depth -= 1 + elif char.isspace() and depth == 0: + type_part = stripped[:index].rstrip() + name_part = stripped[index:].lstrip() + if type_part and name_part: + return type_part, name_part + return None + + return None + + class Field: """A doc field that is never grouped. It can have an argument or not, the argument can be linked using a specified *rolename*. Field should be used @@ -297,13 +328,10 @@ def transform(self, node: nodes.field_list) -> None: # also support syntax like ``:param type name:`` if typedesc.is_typed: - try: - argtype, argname = fieldarg.split(None, 1) - except ValueError: - pass - else: - types.setdefault(typename, {})[argname] = \ - [nodes.Text(argtype)] + split_result = _split_typed_field_argument(fieldarg) + if split_result: + argtype, argname = split_result + types.setdefault(typename, {})[argname] = [nodes.Text(argtype)] fieldarg = argname translatable_content = nodes.inline(field_body.rawsource, diff --git a/tests/roots/test-ext-autodoc/target/typehints.py b/tests/roots/test-ext-autodoc/target/typehints.py index bb56054c30f..c3453f41fb5 100644 --- a/tests/roots/test-ext-autodoc/target/typehints.py +++ b/tests/roots/test-ext-autodoc/target/typehints.py @@ -62,6 +62,16 @@ def complex_func(arg1, arg2, arg3=None, *args, **kwargs): pass +def docstring_typed_params(opc_meta, nested): + """Legacy typed field syntax using balanced delimiters. + + :param dict(str, str) opc_meta: metadata mapping + :param dict(str, Tuple[int, int]) nested: nested mapping + """ + + pass + + def missing_attr(c, a, # type: str b=None # type: Optional[str] diff --git a/tests/test_domain_py.py b/tests/test_domain_py.py index 569390c403d..c3642e18c43 100644 --- a/tests/test_domain_py.py +++ b/tests/test_domain_py.py @@ -984,6 +984,38 @@ def test_info_field_list(app): **{"py:module": "example", "py:class": "Class"}) +def test_info_field_list_param_parentheses_commas(app): + text = (".. py:function:: func\n" + "\n" + " :param dict(str, str) opc_meta: blah\n") + doctree = restructuredtext.parse(app, text) + + field_list = doctree.traverse(nodes.field_list)[0] + paragraph = field_list[0][1][0] + + assert_node(paragraph[0], addnodes.literal_strong, "opc_meta") + assert_node(paragraph[2], pending_xref, refdomain="py", reftype="class", reftarget="dict") + assert_node(paragraph[4], pending_xref, refdomain="py", reftype="class", reftarget="str") + assert_node(paragraph[6], pending_xref, refdomain="py", reftype="class", reftarget="str") + + +def test_info_field_list_param_nested_tuple(app): + text = (".. py:function:: func\n" + "\n" + " :param dict(str, Tuple[int, int]) opc_meta: blah\n") + doctree = restructuredtext.parse(app, text) + + field_list = doctree.traverse(nodes.field_list)[0] + paragraph = field_list[0][1][0] + + assert_node(paragraph[0], addnodes.literal_strong, "opc_meta") + assert_node(paragraph[2], pending_xref, refdomain="py", reftype="class", reftarget="dict") + assert_node(paragraph[4], pending_xref, refdomain="py", reftype="class", reftarget="str") + assert_node(paragraph[6], pending_xref, refdomain="py", reftype="class", reftarget="Tuple") + assert_node(paragraph[8], pending_xref, refdomain="py", reftype="class", reftarget="int") + assert_node(paragraph[10], pending_xref, refdomain="py", reftype="class", reftarget="int") + + def test_info_field_list_var(app): text = (".. py:class:: Class\n" "\n" diff --git a/tests/test_ext_autodoc.py b/tests/test_ext_autodoc.py index 4c16886b3fc..58bd575cc39 100644 --- a/tests/test_ext_autodoc.py +++ b/tests/test_ext_autodoc.py @@ -1903,6 +1903,22 @@ def test_autodoc_typed_inherited_instance_variables(app): ] +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_autodoc_docstring_typed_params(app): + actual = do_autodoc(app, 'function', 'target.typehints.docstring_typed_params') + assert list(actual) == [ + '', + '.. py:function:: docstring_typed_params(opc_meta, nested)', + ' :module: target.typehints', + '', + ' Legacy typed field syntax using balanced delimiters.', + '', + ' :param dict(str, str) opc_meta: metadata mapping', + ' :param dict(str, Tuple[int, int]) nested: nested mapping', + '', + ] + + @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_autodoc_GenericAlias(app): options = {"members": None,