diff --git a/CHANGES b/CHANGES index 902f5f9fa92..ef7c4137d1d 100644 --- a/CHANGES +++ b/CHANGES @@ -62,6 +62,8 @@ Features added * #8924: autodoc: Support ``bound`` argument for TypeVar * #4826: py domain: Add ``:canonical:`` option to python directives to describe the location where the object is defined +* #7199: py domain: Add :confval:`python_use_unqualified_type_names` to suppress + the module name of the python reference if it can be resolved (experimental) * #7784: i18n: The alt text for image is translated by default (without :confval:`gettext_additional_targets` setting) * #2018: html: :confval:`html_favicon` and :confval:`html_logo` now accept URL @@ -72,6 +74,8 @@ Features added * #8201: Emit a warning if toctree contains duplicated entries * #8326: ``master_doc`` is now renamed to :confval:`root_doc` * #8942: C++, add support for the C++20 spaceship operator, ``<=>``. +* #7199: A new node, ``sphinx.addnodes.pending_xref_condition`` has been added. + It can be used to choose appropriate content of the reference by conditions. Bugs fixed ---------- diff --git a/doc/extdev/nodes.rst b/doc/extdev/nodes.rst index e38393a78c5..3976de4c709 100644 --- a/doc/extdev/nodes.rst +++ b/doc/extdev/nodes.rst @@ -37,6 +37,7 @@ New inline nodes .. autoclass:: index .. autoclass:: pending_xref +.. autoclass:: pending_xref_condition .. autoclass:: literal_emphasis .. autoclass:: download_reference diff --git a/doc/usage/configuration.rst b/doc/usage/configuration.rst index 23db18fed78..989ce8737ed 100644 --- a/doc/usage/configuration.rst +++ b/doc/usage/configuration.rst @@ -2714,6 +2714,17 @@ Options for the C++ domain .. versionadded:: 1.5 +Options for the Python domain +----------------------------- + +.. confval:: python_use_unqualified_type_names + + If true, suppress the module name of the python reference if it can be + resolved. The default is ``False``. + + .. versionadded:: 4.0 + + .. note:: This configuration is still in experimental Example of configuration file ============================= diff --git a/sphinx/addnodes.py b/sphinx/addnodes.py index 9bcfaaabfce..9ec4898c161 100644 --- a/sphinx/addnodes.py +++ b/sphinx/addnodes.py @@ -338,6 +338,54 @@ class pending_xref(nodes.Inline, nodes.Element): """ +class pending_xref_condition(nodes.Inline, nodes.TextElement): + """Node for cross-references that are used to choose appropriate + content of the reference by conditions on the resolving phase. + + When the :py:class:`pending_xref` node contains one or more + **pending_xref_condition** nodes, the cross-reference resolver + should choose the content of the reference using defined conditions + in ``condition`` attribute of each pending_xref_condition nodes:: + + + + StringIO + + + io.StringIO + + After the processing of cross-reference resolver, one of the content node + under pending_xref_condition node is chosen by its condition and to be + removed all of pending_xref_condition nodes:: + + # When resolved the cross-reference successfully + + + StringIO + + # When resolution is failed + + + io.StringIO + + .. note:: This node is only allowed to be placed under pending_xref node. + It is not allows to place it under other nodes. In addition, + pending_xref node must contain only pending_xref_condition + nodes if it contains one or more pending_xref_condition nodes. + + The pending_xref_condition node should have **condition** attribute. + Domains can be store their individual conditions into the attribute to + filter contents on resolving phase. As a reserved condition name, + ``condition="*"`` is used for the fallback of resolution failure. + Additionally, as a recommended condition name, ``condition="resolved"`` + is used for the representation of resolstion success in the intersphinx + module. + + .. versionadded:: 4.0 + """ + + class number_reference(nodes.reference): """Node for number references, similar to pending_xref.""" diff --git a/sphinx/domains/python.py b/sphinx/domains/python.py index 5e430a1d732..40a67f82cc7 100644 --- a/sphinx/domains/python.py +++ b/sphinx/domains/python.py @@ -22,7 +22,7 @@ from docutils.parsers.rst import directives from sphinx import addnodes -from sphinx.addnodes import desc_signature, pending_xref +from sphinx.addnodes import desc_signature, pending_xref, pending_xref_condition from sphinx.application import Sphinx from sphinx.builders import Builder from sphinx.deprecation import RemovedInSphinx50Warning @@ -37,7 +37,7 @@ from sphinx.util.docfields import Field, GroupedField, TypedField from sphinx.util.docutils import SphinxDirective from sphinx.util.inspect import signature_from_str -from sphinx.util.nodes import make_id, make_refnode +from sphinx.util.nodes import find_pending_xref_condition, make_id, make_refnode from sphinx.util.typing import TextlikeNode logger = logging.getLogger(__name__) @@ -92,7 +92,17 @@ def type_to_xref(text: str, env: BuildEnvironment = None) -> addnodes.pending_xr else: kwargs = {} - return pending_xref('', nodes.Text(text), + if env.config.python_use_unqualified_type_names: + # Note: It would be better to use qualname to describe the object to support support + # nested classes. But python domain can't access the real python object because this + # module should work not-dynamically. + shortname = text.split('.')[-1] + contnodes = [pending_xref_condition('', shortname, condition='resolved'), + pending_xref_condition('', text, condition='*')] # type: List[Node] + else: + contnodes = [nodes.Text(text)] + + return pending_xref('', *contnodes, refdomain='py', reftype=reftype, reftarget=text, **kwargs) @@ -1209,7 +1219,15 @@ def resolve_xref(self, env: BuildEnvironment, fromdocname: str, builder: Builder if obj[2] == 'module': return self._make_module_refnode(builder, fromdocname, name, contnode) else: - return make_refnode(builder, fromdocname, obj[0], obj[1], contnode, name) + # determine the content of the reference by conditions + content = find_pending_xref_condition(node, 'resolved') + if content: + children = content.children + else: + # if not found, use contnode + children = [contnode] + + return make_refnode(builder, fromdocname, obj[0], obj[1], children, name) def resolve_any_xref(self, env: BuildEnvironment, fromdocname: str, builder: Builder, target: str, node: pending_xref, contnode: Element @@ -1226,9 +1244,17 @@ def resolve_any_xref(self, env: BuildEnvironment, fromdocname: str, builder: Bui self._make_module_refnode(builder, fromdocname, name, contnode))) else: + # determine the content of the reference by conditions + content = find_pending_xref_condition(node, 'resolved') + if content: + children = content.children + else: + # if not found, use contnode + children = [contnode] + results.append(('py:' + self.role_for_objtype(obj[2]), make_refnode(builder, fromdocname, obj[0], obj[1], - contnode, name))) + children, name))) return results def _make_module_refnode(self, builder: Builder, fromdocname: str, name: str, @@ -1295,6 +1321,7 @@ def setup(app: Sphinx) -> Dict[str, Any]: app.setup_extension('sphinx.directives') app.add_domain(PythonDomain) + app.add_config_value('python_use_unqualified_type_names', False, 'env') app.connect('object-description-transform', filter_meta_fields) app.connect('missing-reference', builtin_resolver, priority=900) diff --git a/sphinx/ext/intersphinx.py b/sphinx/ext/intersphinx.py index 5569ad9deda..a01bcc37ab6 100644 --- a/sphinx/ext/intersphinx.py +++ b/sphinx/ext/intersphinx.py @@ -33,10 +33,11 @@ from urllib.parse import urlsplit, urlunsplit from docutils import nodes -from docutils.nodes import Element, TextElement +from docutils.nodes import TextElement from docutils.utils import relative_path import sphinx +from sphinx.addnodes import pending_xref from sphinx.application import Sphinx from sphinx.builders.html import INVENTORY_FILENAME from sphinx.config import Config @@ -44,6 +45,7 @@ from sphinx.locale import _, __ from sphinx.util import logging, requests from sphinx.util.inventory import InventoryFile +from sphinx.util.nodes import find_pending_xref_condition from sphinx.util.typing import Inventory logger = logging.getLogger(__name__) @@ -257,8 +259,8 @@ def load_mappings(app: Sphinx) -> None: inventories.main_inventory.setdefault(type, {}).update(objects) -def missing_reference(app: Sphinx, env: BuildEnvironment, node: Element, contnode: TextElement - ) -> nodes.reference: +def missing_reference(app: Sphinx, env: BuildEnvironment, node: pending_xref, + contnode: TextElement) -> nodes.reference: """Attempt to resolve a missing reference via intersphinx references.""" target = node['reftarget'] inventories = InventoryAdapter(env) @@ -284,6 +286,17 @@ def missing_reference(app: Sphinx, env: BuildEnvironment, node: Element, contnod if 'py:attribute' in objtypes: # Since Sphinx-2.1, properties are stored as py:method objtypes.append('py:method') + + # determine the contnode by pending_xref_condition + content = find_pending_xref_condition(node, 'resolved') + if content: + # resolved condition found. + contnodes = content.children + contnode = content.children[0] # type: ignore + else: + # not resolved. Use the given contnode + contnodes = [contnode] + to_try = [(inventories.main_inventory, target)] if domain: full_qualified_name = env.get_domain(domain).get_full_qualified_name(node) @@ -316,7 +329,7 @@ def missing_reference(app: Sphinx, env: BuildEnvironment, node: Element, contnod newnode = nodes.reference('', '', internal=False, refuri=uri, reftitle=reftitle) if node.get('refexplicit'): # use whatever title was given - newnode.append(contnode) + newnode.extend(contnodes) elif dispname == '-' or \ (domain == 'std' and node['reftype'] == 'keyword'): # use whatever title was given, but strip prefix @@ -325,7 +338,7 @@ def missing_reference(app: Sphinx, env: BuildEnvironment, node: Element, contnod newnode.append(contnode.__class__(title[len(in_set) + 1:], title[len(in_set) + 1:])) else: - newnode.append(contnode) + newnode.extend(contnodes) else: # else use the given display name (used for :ref:) newnode.append(contnode.__class__(dispname, dispname)) diff --git a/sphinx/transforms/post_transforms/__init__.py b/sphinx/transforms/post_transforms/__init__.py index 9ddde59280e..b10c25b3e58 100644 --- a/sphinx/transforms/post_transforms/__init__.py +++ b/sphinx/transforms/post_transforms/__init__.py @@ -22,10 +22,14 @@ from sphinx.transforms import SphinxTransform from sphinx.util import logging from sphinx.util.docutils import SphinxTranslator -from sphinx.util.nodes import process_only_nodes +from sphinx.util.nodes import find_pending_xref_condition, process_only_nodes logger = logging.getLogger(__name__) +if False: + # For type annotation + from docutils.nodes import Node + class SphinxPostTransform(SphinxTransform): """A base class of post-transforms. @@ -97,8 +101,21 @@ def run(self, **kwargs: Any) -> None: if newnode is None: self.warn_missing_reference(refdoc, typ, target, node, domain) except NoUri: - newnode = contnode - node.replace_self(newnode or contnode) + newnode = None + + if newnode: + newnodes = [newnode] # type: List[Node] + else: + newnodes = [contnode] + if newnode is None and isinstance(node[0], addnodes.pending_xref_condition): + matched = find_pending_xref_condition(node, "*") + if matched: + newnodes = matched.children + else: + logger.warning(__('Could not determine the fallback text for the ' + 'cross-reference. Might be a bug.'), location=node) + + node.replace_self(newnodes) def resolve_anyref(self, refdoc: str, node: pending_xref, contnode: Element) -> Element: """Resolve reference generated by the "any" role.""" diff --git a/sphinx/util/nodes.py b/sphinx/util/nodes.py index 944fd3ecb0c..c7619a836fe 100644 --- a/sphinx/util/nodes.py +++ b/sphinx/util/nodes.py @@ -10,7 +10,7 @@ import re import unicodedata -from typing import TYPE_CHECKING, Any, Callable, Iterable, List, Set, Tuple, Type, cast +from typing import TYPE_CHECKING, Any, Callable, Iterable, List, Set, Tuple, Type, Union, cast from docutils import nodes from docutils.nodes import Element, Node @@ -531,8 +531,18 @@ def make_id(env: "BuildEnvironment", document: nodes.document, return node_id +def find_pending_xref_condition(node: addnodes.pending_xref, condition: str) -> Element: + """Pick matched pending_xref_condition node up from the pending_xref.""" + for subnode in node: + if (isinstance(subnode, addnodes.pending_xref_condition) and + subnode.get('condition') == condition): + return subnode + else: + return None + + def make_refnode(builder: "Builder", fromdocname: str, todocname: str, targetid: str, - child: Node, title: str = None) -> nodes.reference: + child: Union[Node, List[Node]], title: str = None) -> nodes.reference: """Shortcut to create a reference node.""" node = nodes.reference('', '', internal=True) if fromdocname == todocname and targetid: @@ -545,7 +555,7 @@ def make_refnode(builder: "Builder", fromdocname: str, todocname: str, targetid: node['refuri'] = builder.get_relative_uri(fromdocname, todocname) if title: node['reftitle'] = title - node.append(child) + node += child return node diff --git a/tests/roots/test-domain-py-python_use_unqualified_type_names/conf.py b/tests/roots/test-domain-py-python_use_unqualified_type_names/conf.py new file mode 100644 index 00000000000..c81bfe4c74d --- /dev/null +++ b/tests/roots/test-domain-py-python_use_unqualified_type_names/conf.py @@ -0,0 +1 @@ +python_use_unqualified_type_names = True diff --git a/tests/roots/test-domain-py-python_use_unqualified_type_names/index.rst b/tests/roots/test-domain-py-python_use_unqualified_type_names/index.rst new file mode 100644 index 00000000000..599206d8c7d --- /dev/null +++ b/tests/roots/test-domain-py-python_use_unqualified_type_names/index.rst @@ -0,0 +1,8 @@ +domain-py-smart_reference +========================= + +.. py:class:: Name + :module: foo + + +.. py:function:: hello(name: foo.Name, age: foo.Age) diff --git a/tests/test_domain_py.py b/tests/test_domain_py.py index 448c2c95424..03e865e8456 100644 --- a/tests/test_domain_py.py +++ b/tests/test_domain_py.py @@ -999,6 +999,25 @@ def test_noindexentry(app): assert_node(doctree[2], addnodes.index, entries=[]) +@pytest.mark.sphinx('html', testroot='domain-py-python_use_unqualified_type_names') +def test_python_python_use_unqualified_type_names(app, status, warning): + app.build() + content = (app.outdir / 'index.html').read_text() + assert ('' + 'Name' in content) + assert 'foo.Age' in content + + +@pytest.mark.sphinx('html', testroot='domain-py-python_use_unqualified_type_names', + confoverrides={'python_use_unqualified_type_names': False}) +def test_python_python_use_unqualified_type_names_disabled(app, status, warning): + app.build() + content = (app.outdir / 'index.html').read_text() + assert ('' + 'foo.Name' in content) + assert 'foo.Age' in content + + @pytest.mark.sphinx('dummy', testroot='domain-py-xref-warning') def test_warn_missing_reference(app, status, warning): app.build() diff --git a/tests/test_ext_intersphinx.py b/tests/test_ext_intersphinx.py index a876775257b..523ed2acc32 100644 --- a/tests/test_ext_intersphinx.py +++ b/tests/test_ext_intersphinx.py @@ -196,6 +196,14 @@ def test_missing_reference_pydomain(tempdir, app, status, warning): rn = missing_reference(app, app.env, node, contnode) assert rn.astext() == 'Foo.bar' + # pending_xref_condition="resolved" + node = addnodes.pending_xref('', reftarget='Foo.bar', refdomain='py', reftype='attr') + node['py:module'] = 'module1' + node += addnodes.pending_xref_condition('', 'Foo.bar', condition='resolved') + node += addnodes.pending_xref_condition('', 'module1.Foo.bar', condition='*') + rn = missing_reference(app, app.env, node, nodes.Text('dummy-cont-node')) + assert rn.astext() == 'Foo.bar' + def test_missing_reference_stddomain(tempdir, app, status, warning): inv_file = tempdir / 'inventory'