diff --git a/sphinx/ext/intersphinx.py b/sphinx/ext/intersphinx.py index 5c6af2dfb75..f3e1f1db6aa 100644 --- a/sphinx/ext/intersphinx.py +++ b/sphinx/ext/intersphinx.py @@ -29,12 +29,13 @@ import sys import time from os import path -from typing import IO, Any, Dict, List, Tuple +from types import ModuleType +from typing import IO, Any, Dict, List, Optional, Tuple, cast from urllib.parse import urlsplit, urlunsplit from docutils import nodes -from docutils.nodes import TextElement -from docutils.utils import relative_path +from docutils.nodes import Node, TextElement, system_message +from docutils.utils import Reporter, relative_path import sphinx from sphinx.addnodes import pending_xref @@ -42,11 +43,14 @@ from sphinx.builders.html import INVENTORY_FILENAME from sphinx.config import Config from sphinx.environment import BuildEnvironment +from sphinx.errors import ExtensionError from sphinx.locale import _, __ +from sphinx.transforms.post_transforms import ReferencesResolver from sphinx.util import logging, requests +from sphinx.util.docutils import CustomReSTDispatcher, SphinxRole from sphinx.util.inventory import InventoryFile from sphinx.util.nodes import find_pending_xref_condition -from sphinx.util.typing import Inventory +from sphinx.util.typing import Inventory, RoleFunction logger = logging.getLogger(__name__) @@ -351,6 +355,115 @@ def missing_reference(app: Sphinx, env: BuildEnvironment, node: pending_xref, return None +class IntersphinxDispatcher(CustomReSTDispatcher): + """Custom dispatcher for intersphinx role. + + This enables :intersphinx:***: roles on parsing reST document. + """ + + def __init__(self) -> None: + super().__init__() + + def role(self, role_name: str, language_module: ModuleType, lineno: int, reporter: Reporter + ) -> Tuple[RoleFunction, List[system_message]]: + if role_name.split(':')[0] == 'intersphinx': + return IntersphinxRole(), [] + else: + return super().role(role_name, language_module, lineno, reporter) + + +class IntersphinxRole(SphinxRole): + def run(self) -> Tuple[List[Node], List[system_message]]: + role_name = self.get_role_name(self.name) + if role_name is None: + logger.warning(__('role not found: %s'), self.name, + location=(self.env.docname, self.lineno)) + return [], [] + + result, messages = self.invoke_role(role_name) + for node in result: + if isinstance(node, pending_xref): + node['intersphinx'] = True + + return result, messages + + def get_role_name(self, name: str) -> Optional[Tuple[str, str]]: + names = name.split(':') + if len(names) == 2: + # :intersphinx:role: + domain = self.env.temp_data.get('default_domain') + role = names[1] + elif len(names) == 3: + # :intersphinx:domain:role: + domain = names[1] + role = names[2] + else: + return None + + if domain and self.is_existent_role(domain, role): + return (domain, role) + elif self.is_existent_role('std', role): + return ('std', role) + else: + return None + + def is_existent_role(self, domain_name: str, role_name: str) -> bool: + try: + domain = self.env.get_domain(domain_name) + if role_name in domain.roles: + return True + else: + return False + except ExtensionError: + return False + + def invoke_role(self, role: Tuple[str, str]) -> Tuple[List[Node], List[system_message]]: + domain = self.env.get_domain(role[0]) + if domain: + role_func = domain.role(role[1]) + + return role_func(':'.join(role), self.rawtext, self.text, self.lineno, + self.inliner, self.options, self.content) + else: + return [], [] + + +class IntersphinxRoleResolver(ReferencesResolver): + """pending_xref node resolver for intersphinx role. + + This resolves pending_xref nodes generated by :intersphinx:***: role. + """ + + default_priority = ReferencesResolver.default_priority - 1 + + def run(self, **kwargs: Any) -> None: + for node in self.document.traverse(pending_xref): + if 'intersphinx' in node: + contnode = cast(nodes.TextElement, node[0].deepcopy()) + refdoc = node.get('refdoc', self.env.docname) + try: + domain = self.env.get_domain(node['refdomain']) + except Exception: + domain = None + + newnode = missing_reference(self.app, self.env, node, contnode) + if newnode is None: + self.warn_missing_reference(refdoc, node['reftype'], node['reftarget'], + node, domain) + else: + node.replace_self(newnode) + + +def install_dispatcher(app: Sphinx, docname: str, source: List[str]) -> None: + """Enable IntersphinxDispatcher. + + .. note:: The installed dispatcher will uninstalled on disabling sphinx_domain + automatically. + """ + dispatcher = IntersphinxDispatcher() + dispatcher.enable() + + def normalize_intersphinx_mapping(app: Sphinx, config: Config) -> None: for key, value in config.intersphinx_mapping.copy().items(): try: @@ -381,7 +494,9 @@ def setup(app: Sphinx) -> Dict[str, Any]: app.add_config_value('intersphinx_timeout', None, False) app.connect('config-inited', normalize_intersphinx_mapping, priority=800) app.connect('builder-inited', load_mappings) + app.connect('source-read', install_dispatcher) app.connect('missing-reference', missing_reference) + app.add_post_transform(IntersphinxRoleResolver) return { 'version': sphinx.__display_version__, 'env_version': 1, diff --git a/sphinx/util/docutils.py b/sphinx/util/docutils.py index c2e12e15254..725d7ba3223 100644 --- a/sphinx/util/docutils.py +++ b/sphinx/util/docutils.py @@ -166,16 +166,14 @@ def patch_docutils(confdir: Optional[str] = None) -> Generator[None, None, None] yield -class ElementLookupError(Exception): - pass - +class CustomReSTDispatcher: + """Custom reST's mark-up dispatcher. -class sphinx_domains: - """Monkey-patch directive and role dispatch, so that domain-specific - markup takes precedence. + This replaces docutils's directives and roles dispatch mechanism for reST parser + by original one temporarily. """ - def __init__(self, env: "BuildEnvironment") -> None: - self.env = env + + def __init__(self) -> None: self.directive_func: Callable = lambda *args: (None, []) self.roles_func: Callable = lambda *args: (None, []) @@ -189,13 +187,35 @@ def enable(self) -> None: self.directive_func = directives.directive self.role_func = roles.role - directives.directive = self.lookup_directive - roles.role = self.lookup_role + directives.directive = self.directive + roles.role = self.role def disable(self) -> None: directives.directive = self.directive_func roles.role = self.role_func + def directive(self, + directive_name: str, language_module: ModuleType, document: nodes.document + ) -> Tuple[Optional[Type[Directive]], List[system_message]]: + return self.directive_func(directive_name, language_module, document) + + def role(self, role_name: str, language_module: ModuleType, lineno: int, reporter: Reporter + ) -> Tuple[RoleFunction, List[system_message]]: + return self.role_func(role_name, language_module, lineno, reporter) + + +class ElementLookupError(Exception): + pass + + +class sphinx_domains(CustomReSTDispatcher): + """Monkey-patch directive and role dispatch, so that domain-specific + markup takes precedence. + """ + def __init__(self, env: "BuildEnvironment") -> None: + self.env = env + super().__init__() + def lookup_domain_element(self, type: str, name: str) -> Any: """Lookup a markup element (directive or role), given its name which can be a full name (with domain). @@ -226,17 +246,20 @@ def lookup_domain_element(self, type: str, name: str) -> Any: raise ElementLookupError - def lookup_directive(self, directive_name: str, language_module: ModuleType, document: nodes.document) -> Tuple[Optional[Type[Directive]], List[system_message]]: # NOQA + def directive(self, + directive_name: str, language_module: ModuleType, document: nodes.document + ) -> Tuple[Optional[Type[Directive]], List[system_message]]: try: return self.lookup_domain_element('directive', directive_name) except ElementLookupError: - return self.directive_func(directive_name, language_module, document) + return super().directive(directive_name, language_module, document) - def lookup_role(self, role_name: str, language_module: ModuleType, lineno: int, reporter: Reporter) -> Tuple[RoleFunction, List[system_message]]: # NOQA + def role(self, role_name: str, language_module: ModuleType, lineno: int, reporter: Reporter + ) -> Tuple[RoleFunction, List[system_message]]: try: return self.lookup_domain_element('role', role_name) except ElementLookupError: - return self.role_func(role_name, language_module, lineno, reporter) + return super().role(role_name, language_module, lineno, reporter) class WarningStream: diff --git a/tests/roots/test-ext-intersphinx-role/conf.py b/tests/roots/test-ext-intersphinx-role/conf.py new file mode 100644 index 00000000000..9485eb2075b --- /dev/null +++ b/tests/roots/test-ext-intersphinx-role/conf.py @@ -0,0 +1 @@ +extensions = ['sphinx.ext.intersphinx'] diff --git a/tests/roots/test-ext-intersphinx-role/index.rst b/tests/roots/test-ext-intersphinx-role/index.rst new file mode 100644 index 00000000000..b8a7d871606 --- /dev/null +++ b/tests/roots/test-ext-intersphinx-role/index.rst @@ -0,0 +1,11 @@ +:intersphinx:py:mod:`module1` +:intersphinx:py:mod:`inv:module2` + +.. py:module:: module1 + +:intersphinx:py:func:`func` +:intersphinx:py:meth:`Foo.bar` + +:intersphinx:c:func:`CFunc` +:intersphinx:doc:`docname` +:intersphinx:option:`ls -l` diff --git a/tests/test_ext_intersphinx.py b/tests/test_ext_intersphinx.py index 62456a3f47d..36aa6a4c71e 100644 --- a/tests/test_ext_intersphinx.py +++ b/tests/test_ext_intersphinx.py @@ -461,3 +461,49 @@ def log_message(*args, **kwargs): stdout, stderr = capsys.readouterr() assert stdout.startswith("c:function\n") assert stderr == "" + + +@pytest.mark.sphinx('html', testroot='ext-intersphinx-role') +def test_intersphinx_role(app): + inv_file = app.srcdir / 'inventory' + inv_file.write_bytes(inventory_v2) + app.config.intersphinx_mapping = { + 'inv': ('http://example.org/', inv_file), + } + app.config.intersphinx_cache_limit = 0 + app.config.nitpicky = True + + # load the inventory and check if it's done correctly + normalize_intersphinx_mapping(app, app.config) + load_mappings(app) + + app.build() + content = (app.outdir / 'index.html').read_text() + + # :intersphinx:py:module:`module1` + assert ('' in content) + + # :intersphinx:py:module:`inv:module2` + assert ('' in content) + + # py:module + :intersphinx:py:function:`func` + assert ('' in content) + + # py:module + :intersphinx:py:method:`Foo.bar` + assert ('' in content) + + # :intersphinx:c:function:`CFunc` + assert ('' in content) + + # :intersphinx:doc:`docname` + assert ('' in content) + + # :intersphinx:option:`ls -l` + assert ('' in content)