From b0a6b3f285359fd3637a0fc031bf462328e31f1b Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Sat, 21 Mar 2020 14:40:12 +0900 Subject: [PATCH] Close #7341: py domain: type annotations are converted to cross refs --- CHANGES | 1 + sphinx/domains/python.py | 59 ++++++++++++++++++++++++++++++++++++++-- sphinx/testing/util.py | 2 +- tests/test_domain_py.py | 57 +++++++++++++++++++++++++++++--------- 4 files changed, 103 insertions(+), 16 deletions(-) diff --git a/CHANGES b/CHANGES index ecba1c5e52e..fc03bdb323a 100644 --- a/CHANGES +++ b/CHANGES @@ -83,6 +83,7 @@ Features added * #6417: py domain: Allow to make a style for arguments of functions and methods * #7238, #7239: py domain: Emit a warning on describing a python object if the entry is already added as the same name +* #7341: py domain: type annotations in singature are converted to cross refs * Support priority of event handlers. For more detail, see :py:meth:`.Sphinx.connect()` * #3077: Implement the scoping for :rst:dir:`productionlist` as indicated diff --git a/sphinx/domains/python.py b/sphinx/domains/python.py index 072bbe02f72..558eb3fa802 100644 --- a/sphinx/domains/python.py +++ b/sphinx/domains/python.py @@ -30,6 +30,7 @@ from sphinx.domains import Domain, ObjType, Index, IndexEntry from sphinx.environment import BuildEnvironment from sphinx.locale import _, __ +from sphinx.pycode.ast import ast, parse as ast_parse from sphinx.roles import XRefRole from sphinx.util import logging from sphinx.util.docfields import Field, GroupedField, TypedField @@ -67,6 +68,58 @@ } +def _parse_annotation(annotation: str) -> List[Node]: + """Parse type annotation.""" + def make_xref(text: str) -> addnodes.pending_xref: + return pending_xref('', nodes.Text(text), + refdomain='py', reftype='class', reftarget=text) + + def unparse(node: ast.AST) -> List[Node]: + if isinstance(node, ast.Attribute): + return [nodes.Text("%s.%s" % (unparse(node.value)[0], node.attr))] + elif isinstance(node, ast.Expr): + return unparse(node.value) + elif isinstance(node, ast.Index): + return unparse(node.value) + elif isinstance(node, ast.List): + result = [addnodes.desc_sig_punctuation('', '[')] # type: List[Node] + for elem in node.elts: + result.extend(unparse(elem)) + result.append(addnodes.desc_sig_punctuation('', ', ')) + result.pop() + result.append(addnodes.desc_sig_punctuation('', ']')) + return result + elif isinstance(node, ast.Module): + return sum((unparse(e) for e in node.body), []) + elif isinstance(node, ast.Name): + return [nodes.Text(node.id)] + elif isinstance(node, ast.Subscript): + result = unparse(node.value) + result.append(addnodes.desc_sig_punctuation('', '[')) + result.extend(unparse(node.slice)) + result.append(addnodes.desc_sig_punctuation('', ']')) + return result + elif isinstance(node, ast.Tuple): + result = [] + for elem in node.elts: + result.extend(unparse(elem)) + result.append(addnodes.desc_sig_punctuation('', ', ')) + result.pop() + return result + else: + raise SyntaxError # unsupported syntax + + try: + tree = ast_parse(annotation) + result = unparse(tree) + for i, node in enumerate(result): + if isinstance(node, nodes.Text): + result[i] = make_xref(str(node)) + return result + except SyntaxError: + return [make_xref(annotation)] + + def _parse_arglist(arglist: str) -> addnodes.desc_parameterlist: """Parse a list of arguments using AST parser""" params = addnodes.desc_parameterlist(arglist) @@ -93,9 +146,10 @@ def _parse_arglist(arglist: str) -> addnodes.desc_parameterlist: node += addnodes.desc_sig_name('', param.name) if param.annotation is not param.empty: + children = _parse_annotation(param.annotation) node += addnodes.desc_sig_punctuation('', ':') node += nodes.Text(' ') - node += addnodes.desc_sig_name('', param.annotation) + node += addnodes.desc_sig_name('', '', *children) # type: ignore if param.default is not param.empty: if param.annotation is not param.empty: node += nodes.Text(' ') @@ -354,7 +408,8 @@ def handle_signature(self, sig: str, signode: desc_signature) -> Tuple[str, str] signode += addnodes.desc_parameterlist() if retann: - signode += addnodes.desc_returns(retann, retann) + children = _parse_annotation(retann) + signode += addnodes.desc_returns(retann, '', *children) anno = self.options.get('annotation') if anno: diff --git a/sphinx/testing/util.py b/sphinx/testing/util.py index 75cd0f411bb..450241f5513 100644 --- a/sphinx/testing/util.py +++ b/sphinx/testing/util.py @@ -63,7 +63,7 @@ def assert_node(node: Node, cls: Any = None, xpath: str = "", **kwargs: Any) -> 'The node%s has %d child nodes, not one' % (xpath, len(node)) assert_node(node[0], cls[1:], xpath=xpath + "[0]", **kwargs) elif isinstance(cls, tuple): - assert isinstance(node, nodes.Element), \ + assert isinstance(node, (list, nodes.Element)), \ 'The node%s does not have any items' % xpath assert len(node) == len(cls), \ 'The node%s has %d child nodes, not %r' % (xpath, len(node), len(cls)) diff --git a/tests/test_domain_py.py b/tests/test_domain_py.py index b3a510a8bd7..51f860dac6d 100644 --- a/tests/test_domain_py.py +++ b/tests/test_domain_py.py @@ -18,11 +18,11 @@ from sphinx.addnodes import ( desc, desc_addname, desc_annotation, desc_content, desc_name, desc_optional, desc_parameter, desc_parameterlist, desc_returns, desc_signature, - desc_sig_name, desc_sig_operator, desc_sig_punctuation, + desc_sig_name, desc_sig_operator, desc_sig_punctuation, pending_xref, ) from sphinx.domains import IndexEntry from sphinx.domains.python import ( - py_sig_re, _pseudo_parse_arglist, PythonDomain, PythonModuleIndex + py_sig_re, _parse_annotation, _pseudo_parse_arglist, PythonDomain, PythonModuleIndex ) from sphinx.testing import restructuredtext from sphinx.testing.util import assert_node @@ -78,7 +78,7 @@ def assert_refnode(node, module_name, class_name, target, reftype=None, assert_node(node, **attributes) doctree = app.env.get_doctree('roles') - refnodes = list(doctree.traverse(addnodes.pending_xref)) + refnodes = list(doctree.traverse(pending_xref)) assert_refnode(refnodes[0], None, None, 'TopLevel', 'class') assert_refnode(refnodes[1], None, None, 'top_level', 'meth') assert_refnode(refnodes[2], None, 'NestedParentA', 'child_1', 'meth') @@ -96,7 +96,7 @@ def assert_refnode(node, module_name, class_name, target, reftype=None, assert len(refnodes) == 13 doctree = app.env.get_doctree('module') - refnodes = list(doctree.traverse(addnodes.pending_xref)) + refnodes = list(doctree.traverse(pending_xref)) assert_refnode(refnodes[0], 'module_a.submodule', None, 'ModTopLevel', 'class') assert_refnode(refnodes[1], 'module_a.submodule', 'ModTopLevel', @@ -125,7 +125,7 @@ def assert_refnode(node, module_name, class_name, target, reftype=None, assert len(refnodes) == 16 doctree = app.env.get_doctree('module_option') - refnodes = list(doctree.traverse(addnodes.pending_xref)) + refnodes = list(doctree.traverse(pending_xref)) print(refnodes) print(refnodes[0]) print(refnodes[1]) @@ -236,13 +236,44 @@ def test_get_full_qualified_name(): assert domain.get_full_qualified_name(node) == 'module1.Class.func' +def test_parse_annotation(): + doctree = _parse_annotation("int") + assert_node(doctree, ([pending_xref, "int"],)) + + doctree = _parse_annotation("List[int]") + assert_node(doctree, ([pending_xref, "List"], + [desc_sig_punctuation, "["], + [pending_xref, "int"], + [desc_sig_punctuation, "]"])) + + doctree = _parse_annotation("Tuple[int, int]") + assert_node(doctree, ([pending_xref, "Tuple"], + [desc_sig_punctuation, "["], + [pending_xref, "int"], + [desc_sig_punctuation, ", "], + [pending_xref, "int"], + [desc_sig_punctuation, "]"])) + + doctree = _parse_annotation("Callable[[int, int], int]") + assert_node(doctree, ([pending_xref, "Callable"], + [desc_sig_punctuation, "["], + [desc_sig_punctuation, "["], + [pending_xref, "int"], + [desc_sig_punctuation, ", "], + [pending_xref, "int"], + [desc_sig_punctuation, "]"], + [desc_sig_punctuation, ", "], + [pending_xref, "int"], + [desc_sig_punctuation, "]"])) + + def test_pyfunction_signature(app): text = ".. py:function:: hello(name: str) -> str" doctree = restructuredtext.parse(app, text) assert_node(doctree, (addnodes.index, [desc, ([desc_signature, ([desc_name, "hello"], desc_parameterlist, - [desc_returns, "str"])], + [desc_returns, pending_xref, "str"])], desc_content)])) assert_node(doctree[1], addnodes.desc, desctype="function", domain="py", objtype="function", noindex=False) @@ -250,7 +281,7 @@ def test_pyfunction_signature(app): [desc_parameterlist, desc_parameter, ([desc_sig_name, "name"], [desc_sig_punctuation, ":"], " ", - [nodes.inline, "str"])]) + [nodes.inline, pending_xref, "str"])]) def test_pyfunction_signature_full(app): @@ -260,7 +291,7 @@ def test_pyfunction_signature_full(app): assert_node(doctree, (addnodes.index, [desc, ([desc_signature, ([desc_name, "hello"], desc_parameterlist, - [desc_returns, "str"])], + [desc_returns, pending_xref, "str"])], desc_content)])) assert_node(doctree[1], addnodes.desc, desctype="function", domain="py", objtype="function", noindex=False) @@ -268,7 +299,7 @@ def test_pyfunction_signature_full(app): [desc_parameterlist, ([desc_parameter, ([desc_sig_name, "a"], [desc_sig_punctuation, ":"], " ", - [desc_sig_name, "str"])], + [desc_sig_name, pending_xref, "str"])], [desc_parameter, ([desc_sig_name, "b"], [desc_sig_operator, "="], [nodes.inline, "1"])], @@ -276,11 +307,11 @@ def test_pyfunction_signature_full(app): [desc_sig_name, "args"], [desc_sig_punctuation, ":"], " ", - [desc_sig_name, "str"])], + [desc_sig_name, pending_xref, "str"])], [desc_parameter, ([desc_sig_name, "c"], [desc_sig_punctuation, ":"], " ", - [desc_sig_name, "bool"], + [desc_sig_name, pending_xref, "bool"], " ", [desc_sig_operator, "="], " ", @@ -289,7 +320,7 @@ def test_pyfunction_signature_full(app): [desc_sig_name, "kwargs"], [desc_sig_punctuation, ":"], " ", - [desc_sig_name, "str"])])]) + [desc_sig_name, pending_xref, "str"])])]) @pytest.mark.skipif(sys.version_info < (3, 8), reason='python 3.8+ is required.') @@ -340,7 +371,7 @@ def test_optional_pyfunction_signature(app): assert_node(doctree, (addnodes.index, [desc, ([desc_signature, ([desc_name, "compile"], desc_parameterlist, - [desc_returns, "ast object"])], + [desc_returns, pending_xref, "ast object"])], desc_content)])) assert_node(doctree[1], addnodes.desc, desctype="function", domain="py", objtype="function", noindex=False)