Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add intersphinx role #9062

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
123 changes: 119 additions & 4 deletions sphinx/ext/intersphinx.py
Expand Up @@ -29,24 +29,28 @@
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
from sphinx.application import Sphinx
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__)

Expand Down Expand Up @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a way it seems a bit strange to call the role when it is not supposed to resolve anything. But it is needed for correct formatting, and I guess almost no domain role at the moment performs any real work when it is run, so this works.
We should remember this spot when we at some point overhaul intersphinx.

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:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think :external:***: is also good naming for this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think both are ok, but I lean towards changing to external as it seems to convey the semantics of what it is in a more direct manner.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

given that refs are often quite comment, I think there is value in compactness here. For this reason I think external is better than intersphinx, but I'd also propose that eref is used instead of external (short for "external ref"). What do folks think about that?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment below regarding eref.

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'],
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warning will shown twice because ReferencesResolver will process this again!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we just skip the warning here? I guess missing_reference will take care of it then?

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:
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 37 additions & 14 deletions sphinx/util/docutils.py
Expand Up @@ -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, [])

Expand All @@ -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).
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions tests/roots/test-ext-intersphinx-role/conf.py
@@ -0,0 +1 @@
extensions = ['sphinx.ext.intersphinx']
11 changes: 11 additions & 0 deletions 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`
46 changes: 46 additions & 0 deletions tests/test_ext_intersphinx.py
Expand Up @@ -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 ('<a class="reference external" href="http://example.org/foo.html#module-module1"'
' title="(in foo v2.0)">' in content)

# :intersphinx:py:module:`inv:module2`
assert ('<a class="reference external" href="http://example.org/foo.html#module-module2"'
' title="(in foo v2.0)">' in content)

# py:module + :intersphinx:py:function:`func`
assert ('<a class="reference external" href="http://example.org/sub/foo.html#module1.func"'
' title="(in foo v2.0)">' in content)

# py:module + :intersphinx:py:method:`Foo.bar`
assert ('<a class="reference external" href="http://example.org/index.html#foo.Bar.baz"'
' title="(in foo v2.0)">' in content)

# :intersphinx:c:function:`CFunc`
assert ('<a class="reference external" href="http://example.org/cfunc.html#CFunc"'
' title="(in foo v2.0)">' in content)

# :intersphinx:doc:`docname`
assert ('<a class="reference external" href="http://example.org/docname.html"'
' title="(in foo v2.0)">' in content)

# :intersphinx:option:`ls -l`
assert ('<a class="reference external" href="http://example.org/index.html#cmdoption-ls-l"'
' title="(in foo v2.0)">' in content)