Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 36 additions & 8 deletions sphinx/util/docfields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions tests/roots/test-ext-autodoc/target/typehints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
32 changes: 32 additions & 0 deletions tests/test_domain_py.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
16 changes: 16 additions & 0 deletions tests/test_ext_autodoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down