Skip to content

Commit fc428ad

Browse files
authored
Merge pull request #9822 from jakobandersen/intersphinx_role
Intersphinx role (2)
2 parents 26a4f5d + 5d595ec commit fc428ad

File tree

7 files changed

+337
-41
lines changed

7 files changed

+337
-41
lines changed

CHANGES

+2
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ Features added
4747
* #9391: texinfo: improve variable in ``samp`` role
4848
* #9578: texinfo: Add :confval:`texinfo_cross_references` to disable cross
4949
references for readability with standalone readers
50+
* #9822 (and #9062), add new Intersphinx role :rst:role:`external` for explict
51+
lookup in the external projects, without resolving to the local project.
5052

5153
Bugs fixed
5254
----------

doc/usage/extensions/intersphinx.rst

+57-23
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,25 @@
88

99
.. versionadded:: 0.5
1010

11-
This extension can generate automatic links to the documentation of objects in
12-
other projects.
13-
14-
Usage is simple: whenever Sphinx encounters a cross-reference that has no
15-
matching target in the current documentation set, it looks for targets in the
16-
documentation sets configured in :confval:`intersphinx_mapping`. A reference
17-
like ``:py:class:`zipfile.ZipFile``` can then link to the Python documentation
11+
This extension can generate links to the documentation of objects in external
12+
projects, either explicitly through the :rst:role:`external` role, or as a
13+
fallback resolution for any other cross-reference.
14+
15+
Usage for fallback resolution is simple: whenever Sphinx encounters a
16+
cross-reference that has no matching target in the current documentation set,
17+
it looks for targets in the external documentation sets configured in
18+
:confval:`intersphinx_mapping`. A reference like
19+
``:py:class:`zipfile.ZipFile``` can then link to the Python documentation
1820
for the ZipFile class, without you having to specify where it is located
1921
exactly.
2022

21-
When using the "new" format (see below), you can even force lookup in a foreign
22-
set by prefixing the link target appropriately. A link like ``:ref:`comparison
23-
manual <python:comparisons>``` will then link to the label "comparisons" in the
24-
doc set "python", if it exists.
23+
When using the :rst:role:`external` role, you can force lookup to any external
24+
projects, and optionally to a specific external project.
25+
A link like ``:external:ref:`comparison manual <comparisons>``` will then link
26+
to the label "comparisons" in whichever configured external project, if it
27+
exists,
28+
and a link like ``:external+python:ref:`comparison manual <comparisons>``` will
29+
link to the label "comparisons" only in the doc set "python", if it exists.
2530

2631
Behind the scenes, this works as follows:
2732

@@ -30,8 +35,8 @@ Behind the scenes, this works as follows:
3035

3136
* Projects using the Intersphinx extension can specify the location of such
3237
mapping files in the :confval:`intersphinx_mapping` config value. The mapping
33-
will then be used to resolve otherwise missing references to objects into
34-
links to the other documentation.
38+
will then be used to resolve both :rst:role:`external` references, and also
39+
otherwise missing references to objects into links to the other documentation.
3540

3641
* By default, the mapping file is assumed to be at the same location as the rest
3742
of the documentation; however, the location of the mapping file can also be
@@ -79,10 +84,10 @@ linking:
7984
at the same location as the base URI) or another local file path or a full
8085
HTTP URI to an inventory file.
8186

82-
The unique identifier can be used to prefix cross-reference targets, so that
87+
The unique identifier can be used in the :rst:role:`external` role, so that
8388
it is clear which intersphinx set the target belongs to. A link like
84-
``:ref:`comparison manual <python:comparisons>``` will link to the label
85-
"comparisons" in the doc set "python", if it exists.
89+
``external:python+ref:`comparison manual <comparisons>``` will link to the
90+
label "comparisons" in the doc set "python", if it exists.
8691

8792
**Example**
8893

@@ -162,21 +167,50 @@ linking:
162167

163168
The default value is an empty list.
164169

165-
When a cross-reference without an explicit inventory specification is being
166-
resolved by intersphinx, skip resolution if it matches one of the
167-
specifications in this list.
170+
When a non-:rst:role:`external` cross-reference is being resolved by
171+
intersphinx, skip resolution if it matches one of the specifications in this
172+
list.
168173

169174
For example, with ``intersphinx_disabled_reftypes = ['std:doc']``
170175
a cross-reference ``:doc:`installation``` will not be attempted to be
171-
resolved by intersphinx, but ``:doc:`otherbook:installation``` will be
172-
attempted to be resolved in the inventory named ``otherbook`` in
176+
resolved by intersphinx, but ``:external+otherbook:doc:`installation``` will
177+
be attempted to be resolved in the inventory named ``otherbook`` in
173178
:confval:`intersphinx_mapping`.
174179
At the same time, all cross-references generated in, e.g., Python,
175180
declarations will still be attempted to be resolved by intersphinx.
176181

177-
If ``*`` is in the list of domains, then no references without an explicit
178-
inventory will be resolved by intersphinx.
182+
If ``*`` is in the list of domains, then no non-:rst:role:`external`
183+
references will be resolved by intersphinx.
184+
185+
Explicitly Reference External Objects
186+
-------------------------------------
187+
188+
The Intersphinx extension provides the following role.
189+
190+
.. rst:role:: external
191+
192+
.. versionadded:: 4.4
193+
194+
Use Intersphinx to perform lookup only in external projects, and not the
195+
current project. Intersphinx still needs to know the type of object you
196+
would like to find, so the general form of this role is to write the
197+
cross-refererence as if the object is in the current project, but then prefix
198+
it with ``:external``.
199+
The two forms are then
200+
201+
- ``:external:domain:reftype:`target```,
202+
e.g., ``:external:py:class:`zipfile.ZipFile```, or
203+
- ``:external:reftype:`target```,
204+
e.g., ``:external:doc:`installation```.
205+
206+
If you would like to constrain the lookup to a specific external project,
207+
then the key of the project, as specified in :confval:`intersphinx_mapping`,
208+
is added as well to get the two forms
179209

210+
- ``:external+invname:domain:reftype:`target```,
211+
e.g., ``:external+python:py:class:`zipfile.ZipFile```, or
212+
- ``:external+invname:reftype:`target```,
213+
e.g., ``:external+python:doc:`installation```.
180214

181215
Showing all links of an Intersphinx mapping file
182216
------------------------------------------------

sphinx/ext/intersphinx.py

+149-4
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,17 @@
2626
import concurrent.futures
2727
import functools
2828
import posixpath
29+
import re
2930
import sys
3031
import time
3132
from os import path
32-
from typing import IO, Any, Dict, List, Optional, Tuple
33+
from types import ModuleType
34+
from typing import IO, Any, Dict, List, Optional, Tuple, cast
3335
from urllib.parse import urlsplit, urlunsplit
3436

3537
from docutils import nodes
36-
from docutils.nodes import Element, TextElement
37-
from docutils.utils import relative_path
38+
from docutils.nodes import Element, Node, TextElement, system_message
39+
from docutils.utils import Reporter, relative_path
3840

3941
import sphinx
4042
from sphinx.addnodes import pending_xref
@@ -43,10 +45,13 @@
4345
from sphinx.config import Config
4446
from sphinx.domains import Domain
4547
from sphinx.environment import BuildEnvironment
48+
from sphinx.errors import ExtensionError
4649
from sphinx.locale import _, __
50+
from sphinx.transforms.post_transforms import ReferencesResolver
4751
from sphinx.util import logging, requests
52+
from sphinx.util.docutils import CustomReSTDispatcher, SphinxRole
4853
from sphinx.util.inventory import InventoryFile
49-
from sphinx.util.typing import Inventory, InventoryItem
54+
from sphinx.util.typing import Inventory, InventoryItem, RoleFunction
5055

5156
logger = logging.getLogger(__name__)
5257

@@ -466,6 +471,144 @@ def missing_reference(app: Sphinx, env: BuildEnvironment, node: pending_xref,
466471
return resolve_reference_detect_inventory(env, node, contnode)
467472

468473

474+
class IntersphinxDispatcher(CustomReSTDispatcher):
475+
"""Custom dispatcher for external role.
476+
477+
This enables :external:***:/:external+***: roles on parsing reST document.
478+
"""
479+
480+
def role(self, role_name: str, language_module: ModuleType, lineno: int, reporter: Reporter
481+
) -> Tuple[RoleFunction, List[system_message]]:
482+
if len(role_name) > 9 and role_name.startswith(('external:', 'external+')):
483+
return IntersphinxRole(role_name), []
484+
else:
485+
return super().role(role_name, language_module, lineno, reporter)
486+
487+
488+
class IntersphinxRole(SphinxRole):
489+
# group 1: just for the optionality of the inventory name
490+
# group 2: the inventory name (optional)
491+
# group 3: the domain:role or role part
492+
_re_inv_ref = re.compile(r"(\+([^:]+))?:(.*)")
493+
494+
def __init__(self, orig_name: str) -> None:
495+
self.orig_name = orig_name
496+
497+
def run(self) -> Tuple[List[Node], List[system_message]]:
498+
assert self.name == self.orig_name.lower()
499+
inventory, name_suffix = self.get_inventory_and_name_suffix(self.orig_name)
500+
if inventory and not inventory_exists(self.env, inventory):
501+
logger.warning(__('inventory for external cross-reference not found: %s'),
502+
inventory, location=(self.env.docname, self.lineno))
503+
return [], []
504+
505+
role_name = self.get_role_name(name_suffix)
506+
if role_name is None:
507+
logger.warning(__('role for external cross-reference not found: %s'), name_suffix,
508+
location=(self.env.docname, self.lineno))
509+
return [], []
510+
511+
result, messages = self.invoke_role(role_name)
512+
for node in result:
513+
if isinstance(node, pending_xref):
514+
node['intersphinx'] = True
515+
node['inventory'] = inventory
516+
517+
return result, messages
518+
519+
def get_inventory_and_name_suffix(self, name: str) -> Tuple[Optional[str], str]:
520+
assert name.startswith('external'), name
521+
assert name[8] in ':+', name
522+
# either we have an explicit inventory name, i.e,
523+
# :external+inv:role: or
524+
# :external+inv:domain:role:
525+
# or we look in all inventories, i.e.,
526+
# :external:role: or
527+
# :external:domain:role:
528+
inv, suffix = IntersphinxRole._re_inv_ref.fullmatch(name, 8).group(2, 3)
529+
return inv, suffix
530+
531+
def get_role_name(self, name: str) -> Optional[Tuple[str, str]]:
532+
names = name.split(':')
533+
if len(names) == 1:
534+
# role
535+
default_domain = self.env.temp_data.get('default_domain')
536+
domain = default_domain.name if default_domain else None
537+
role = names[0]
538+
elif len(names) == 2:
539+
# domain:role:
540+
domain = names[0]
541+
role = names[1]
542+
else:
543+
return None
544+
545+
if domain and self.is_existent_role(domain, role):
546+
return (domain, role)
547+
elif self.is_existent_role('std', role):
548+
return ('std', role)
549+
else:
550+
return None
551+
552+
def is_existent_role(self, domain_name: str, role_name: str) -> bool:
553+
try:
554+
domain = self.env.get_domain(domain_name)
555+
if role_name in domain.roles:
556+
return True
557+
else:
558+
return False
559+
except ExtensionError:
560+
return False
561+
562+
def invoke_role(self, role: Tuple[str, str]) -> Tuple[List[Node], List[system_message]]:
563+
domain = self.env.get_domain(role[0])
564+
if domain:
565+
role_func = domain.role(role[1])
566+
567+
return role_func(':'.join(role), self.rawtext, self.text, self.lineno,
568+
self.inliner, self.options, self.content)
569+
else:
570+
return [], []
571+
572+
573+
class IntersphinxRoleResolver(ReferencesResolver):
574+
"""pending_xref node resolver for intersphinx role.
575+
576+
This resolves pending_xref nodes generated by :intersphinx:***: role.
577+
"""
578+
579+
default_priority = ReferencesResolver.default_priority - 1
580+
581+
def run(self, **kwargs: Any) -> None:
582+
for node in self.document.traverse(pending_xref):
583+
if 'intersphinx' not in node:
584+
continue
585+
contnode = cast(nodes.TextElement, node[0].deepcopy())
586+
inv_name = node['inventory']
587+
if inv_name is not None:
588+
assert inventory_exists(self.env, inv_name)
589+
newnode = resolve_reference_in_inventory(self.env, inv_name, node, contnode)
590+
else:
591+
newnode = resolve_reference_any_inventory(self.env, False, node, contnode)
592+
if newnode is None:
593+
typ = node['reftype']
594+
msg = (__('external %s:%s reference target not found: %s') %
595+
(node['refdomain'], typ, node['reftarget']))
596+
logger.warning(msg, location=node, type='ref', subtype=typ)
597+
node.replace_self(contnode)
598+
else:
599+
node.replace_self(newnode)
600+
601+
602+
def install_dispatcher(app: Sphinx, docname: str, source: List[str]) -> None:
603+
"""Enable IntersphinxDispatcher.
604+
605+
.. note:: The installed dispatcher will uninstalled on disabling sphinx_domain
606+
automatically.
607+
"""
608+
dispatcher = IntersphinxDispatcher()
609+
dispatcher.enable()
610+
611+
469612
def normalize_intersphinx_mapping(app: Sphinx, config: Config) -> None:
470613
for key, value in config.intersphinx_mapping.copy().items():
471614
try:
@@ -497,7 +640,9 @@ def setup(app: Sphinx) -> Dict[str, Any]:
497640
app.add_config_value('intersphinx_disabled_reftypes', [], True)
498641
app.connect('config-inited', normalize_intersphinx_mapping, priority=800)
499642
app.connect('builder-inited', load_mappings)
643+
app.connect('source-read', install_dispatcher)
500644
app.connect('missing-reference', missing_reference)
645+
app.add_post_transform(IntersphinxRoleResolver)
501646
return {
502647
'version': sphinx.__display_version__,
503648
'env_version': 1,

0 commit comments

Comments
 (0)