Skip to content

Commit c232c74

Browse files
committed
Intersphinx, delegate to domains
1 parent 0192d99 commit c232c74

File tree

5 files changed

+262
-113
lines changed

5 files changed

+262
-113
lines changed

sphinx/domains/__init__.py

Lines changed: 127 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111

1212
import copy
1313
from abc import ABC, abstractmethod
14-
from typing import (TYPE_CHECKING, Any, Callable, Dict, Iterable, List, NamedTuple, Tuple,
15-
Type, Union, cast)
14+
from typing import (TYPE_CHECKING, Any, Callable, Dict, Iterable, List, NamedTuple,
15+
Optional, Tuple, Type, Union, cast)
1616

1717
from docutils import nodes
1818
from docutils.nodes import Element, Node, TextElement, system_message
@@ -22,6 +22,7 @@
2222
from sphinx.errors import SphinxError
2323
from sphinx.locale import _
2424
from sphinx.roles import XRefRole
25+
from sphinx.util.inventory import InventoryItemSet
2526
from sphinx.util.typing import RoleFunction
2627

2728
if TYPE_CHECKING:
@@ -200,6 +201,12 @@ class Domain:
200201
#: data version, bump this when the format of `self.data` changes
201202
data_version = 0
202203

204+
#: intersphinx inventory value for an empty inventory
205+
#: must be copy.deepcopy-able
206+
#: if overridden, then the intersphinx methods in the domain should
207+
#: probably also be overridden
208+
initial_intersphinx_inventory = {} # type: Dict
209+
203210
def __init__(self, env: "BuildEnvironment") -> None:
204211
self.env = env # type: BuildEnvironment
205212
self._role_cache = {} # type: Dict[str, Callable]
@@ -318,7 +325,7 @@ def process_field_xref(self, pnode: pending_xref) -> None:
318325

319326
def resolve_xref(self, env: "BuildEnvironment", fromdocname: str, builder: "Builder",
320327
typ: str, target: str, node: pending_xref, contnode: TextElement
321-
) -> Element:
328+
) -> Optional[Element]:
322329
"""Resolve the pending_xref *node* with the given *typ* and *target*.
323330
324331
This method should return a new node, to replace the xref node,
@@ -401,3 +408,120 @@ def get_enumerable_node_type(self, node: Node) -> str:
401408
def get_full_qualified_name(self, node: Element) -> str:
402409
"""Return full qualified name for given node."""
403410
return None
411+
412+
def intersphinx_add_entries_v2(self, store: Any,
413+
data: Dict[Tuple[str, str], InventoryItemSet]) -> None:
414+
"""Store the given *data* for later intersphinx reference resolution.
415+
416+
This method is called at most once with all data loaded from inventories in
417+
v1 and v2 format.
418+
419+
The *data* is a dictionary indexed by (object name, object type)
420+
and must be stored in whichever way makes sense for the domain in the given *store*.
421+
This *store* was initially a copy of :attr:`initial_intersphinx_inventory`.
422+
423+
The stored data is later given in :math:`intersphinx_resolve_xref` and
424+
:meth:`intersphinx_resolve_xref_any` for reference resolution.
425+
426+
.. versionadded:: 4.0
427+
"""
428+
store = cast(Dict[Tuple[str, str], InventoryItemSet], store)
429+
store.update(data)
430+
431+
def _intersphinx_resolve_xref_lookup(self, store: Dict[Tuple[str, str], InventoryItemSet],
432+
target: str, objtypes: List[str]
433+
) -> InventoryItemSet:
434+
for objtype in objtypes:
435+
if (target, objtype) not in store:
436+
continue
437+
return store[(target, objtype)]
438+
return None
439+
440+
def _intersphinx_resolve_xref_2(self, store: Dict[Tuple[str, str], InventoryItemSet],
441+
target: str, node: pending_xref,
442+
objtypes: List[str]) -> InventoryItemSet:
443+
# we try the target either as is, or with full qualification based on the scope of node
444+
res = self._intersphinx_resolve_xref_lookup(store, target, objtypes)
445+
if res is not None:
446+
return res
447+
# try with qualification of the current scope instead
448+
# (we may have modified the target due to the splitting, so assign to the node)
449+
old_target = node['reftarget']
450+
node['reftarget'] = target
451+
full_qualified_name = self.get_full_qualified_name(node)
452+
node['reftarget'] = old_target
453+
if full_qualified_name:
454+
return self._intersphinx_resolve_xref_lookup(store, full_qualified_name, objtypes)
455+
else:
456+
return None
457+
458+
def _intersphinx_resolve_xref_1(self, store: Dict[Tuple[str, str], InventoryItemSet],
459+
target: str, node: pending_xref,
460+
contnode: TextElement, objtypes: List[str]
461+
) -> Optional[Element]:
462+
# we adjust the object types for backwards compatibility
463+
if self.name == 'std' and 'cmdoption' in objtypes:
464+
# until Sphinx-1.6, cmdoptions are stored as std:option
465+
objtypes.append('option')
466+
if self.name == 'py' and 'attribute' in objtypes:
467+
# Since Sphinx-2.1, properties are stored as py:method
468+
objtypes.append('method')
469+
470+
# ordinary direct lookup
471+
res = self._intersphinx_resolve_xref_2(store, target, node, objtypes)
472+
if res is not None:
473+
return res.make_refnode(self.name, target, node, contnode)
474+
475+
# try splitting the target into 'inv_name:target'
476+
if ':' not in target:
477+
return None
478+
# first part may be the foreign doc set name
479+
inv_name, newtarget = target.split(':', 1)
480+
rawRes = self._intersphinx_resolve_xref_2(store, newtarget, node, objtypes)
481+
if rawRes is None:
482+
return None
483+
rawRes = rawRes.select_inventory(inv_name)
484+
if rawRes is None:
485+
return None
486+
# set the new target in the node so intersphinx can use it
487+
node['reftarget'] = newtarget
488+
return rawRes.make_refnode(self.name, target, node, contnode)
489+
490+
def intersphinx_resolve_xref(self, env: "BuildEnvironment", store: Any,
491+
typ: str, target: str, node: pending_xref,
492+
contnode: TextElement) -> Optional[Element]:
493+
"""Resolve the pending_xref *node* with the given *typ* and *target* via intersphinx.
494+
495+
This method should perform lookup in the given *store* which was created
496+
through previous calls to :meth:`intersphinx_add_object`,
497+
but otherwise behave very similarly to :meth:`resolve_xref`.
498+
499+
If a candidate is found in the store, a :class:`InventoryItemSet` is then a associated.
500+
This item set should be used to create a new node, to replace the xref node,
501+
containing the *contnode* which is the markup content of the cross-reference.
502+
503+
If no resolution can be found, None can be returned; and subsequent event handlers
504+
will be given a chance to resolve the reference.
505+
The method can also raise :exc:`sphinx.environment.NoUri` to suppress
506+
any subsequent resolution of this reference.
507+
508+
.. versionadded:: 4.0
509+
"""
510+
objtypes = self.objtypes_for_role(typ)
511+
if not objtypes:
512+
return None
513+
return self._intersphinx_resolve_xref_1(store, target, node, contnode, objtypes)
514+
515+
def intersphinx_resolve_any_xref(self, env: "BuildEnvironment", store: Any,
516+
target: str, node: pending_xref,
517+
contnode: TextElement) -> Optional[Element]:
518+
"""Resolve the pending_xref *node* with the given *typ* and *target* via intersphinx.
519+
520+
The reference comes from an "any" or similar role, which means that we
521+
don't know the type.
522+
Otherwise, the arguments are the same as for :meth:`intersphinx_resolve_xref`.
523+
524+
.. versionadded:: 4.0
525+
"""
526+
objtypes = list(self.object_types)
527+
return self._intersphinx_resolve_xref_1(store, target, node, contnode, objtypes)

sphinx/ext/intersphinx.py

Lines changed: 56 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -24,28 +24,26 @@
2424
"""
2525

2626
import concurrent.futures
27+
import copy
2728
import functools
2829
import posixpath
2930
import sys
3031
import time
3132
from os import path
32-
from typing import IO, Any, Dict, List, Tuple
33+
from typing import IO, Any, Dict, List, Optional, Tuple
3334
from urllib.parse import urlsplit, urlunsplit
3435

35-
from docutils import nodes
36-
from docutils.nodes import TextElement
37-
from docutils.utils import relative_path
36+
from docutils.nodes import Element, TextElement
3837

3938
import sphinx
4039
from sphinx.addnodes import pending_xref
4140
from sphinx.application import Sphinx
4241
from sphinx.builders.html import INVENTORY_FILENAME
4342
from sphinx.config import Config
4443
from sphinx.environment import BuildEnvironment
45-
from sphinx.locale import _, __
44+
from sphinx.locale import __
4645
from sphinx.util import logging, requests
47-
from sphinx.util.inventory import InventoryFile
48-
from sphinx.util.nodes import find_pending_xref_condition
46+
from sphinx.util.inventory import InventoryFile, InventoryItemSet
4947
from sphinx.util.typing import Inventory
5048

5149
logger = logging.getLogger(__name__)
@@ -60,7 +58,13 @@ def __init__(self, env: BuildEnvironment) -> None:
6058
if not hasattr(env, 'intersphinx_cache'):
6159
self.env.intersphinx_cache = {} # type: ignore
6260
self.env.intersphinx_inventory = {} # type: ignore
63-
self.env.intersphinx_named_inventory = {} # type: ignore
61+
self.env.intersphinx_by_domain_inventory = {} # type: ignore
62+
self._clear_by_domain_inventory()
63+
64+
def _clear_by_domain_inventory(self) -> None:
65+
for domain in self.env.domains.values():
66+
inv = copy.deepcopy(domain.initial_intersphinx_inventory)
67+
self.env.intersphinx_by_domain_inventory[domain.name] = inv # type: ignore
6468

6569
@property
6670
def cache(self) -> Dict[str, Tuple[str, int, Inventory]]:
@@ -71,12 +75,13 @@ def main_inventory(self) -> Inventory:
7175
return self.env.intersphinx_inventory # type: ignore
7276

7377
@property
74-
def named_inventory(self) -> Dict[str, Inventory]:
75-
return self.env.intersphinx_named_inventory # type: ignore
78+
def by_domain_inventory(self) -> Dict[str, Any]:
79+
return self.env.intersphinx_by_domain_inventory # type: ignore
7680

7781
def clear(self) -> None:
7882
self.env.intersphinx_inventory.clear() # type: ignore
79-
self.env.intersphinx_named_inventory.clear() # type: ignore
83+
self.env.intersphinx_by_domain_inventory.clear() # type: ignore
84+
self._clear_by_domain_inventory()
8085

8186

8287
def _strip_basic_auth(url: str) -> str:
@@ -241,114 +246,60 @@ def load_mappings(app: Sphinx) -> None:
241246

242247
if any(updated):
243248
inventories.clear()
244-
245-
# Duplicate values in different inventories will shadow each
246-
# other; which one will override which can vary between builds
247-
# since they are specified using an unordered dict. To make
248-
# it more consistent, we sort the named inventories and then
249-
# add the unnamed inventories last. This means that the
250-
# unnamed inventories will shadow the named ones but the named
251-
# ones can still be accessed when the name is specified.
249+
# old stuff, still used in the tests
252250
cached_vals = list(inventories.cache.values())
253251
named_vals = sorted(v for v in cached_vals if v[0])
254252
unnamed_vals = [v for v in cached_vals if not v[0]]
255253
for name, _x, invdata in named_vals + unnamed_vals:
256-
if name:
257-
inventories.named_inventory[name] = invdata
258254
for type, objects in invdata.items():
259255
inventories.main_inventory.setdefault(type, {}).update(objects)
256+
# end of old stuff
257+
258+
# first collect all entries indexed by domain, object name, and object type
259+
# domain -> (object_name, object_type) -> InventoryItemSet([(inv_name, inner_data)])
260+
entries = {} # type: Dict[str, Dict[Tuple[str, str], InventoryItemSet]]
261+
for inv_name, _x, inv_data in inventories.cache.values():
262+
# inv_data: Inventory = Dict[str, Dict[str, InventoryInner]]
263+
for inv_type, inv_objects in inv_data.items():
264+
# inv_objects: Dict[str, InventoryInner]
265+
assert ':' in inv_type
266+
domain_name, object_type = inv_type.split(':')
267+
if domain_name not in app.env.domains:
268+
continue
269+
domain_entries = entries.setdefault(domain_name, {})
270+
for object_name, inner_data in inv_objects.items():
271+
itemSet = domain_entries.setdefault(
272+
(object_name, object_type), InventoryItemSet())
273+
itemSet.append((inv_name, inner_data))
274+
# and then give the data to each domain
275+
for domain_name, domain_entries in entries.items():
276+
domain = app.env.domains[domain_name]
277+
domain_store = inventories.by_domain_inventory[domain_name]
278+
domain.intersphinx_add_entries_v2(domain_store, domain_entries)
260279

261280

262281
def missing_reference(app: Sphinx, env: BuildEnvironment, node: pending_xref,
263-
contnode: TextElement) -> nodes.reference:
282+
contnode: TextElement) -> Optional[Element]:
264283
"""Attempt to resolve a missing reference via intersphinx references."""
284+
typ = node['reftype']
265285
target = node['reftarget']
266-
inventories = InventoryAdapter(env)
267-
objtypes = None # type: List[str]
268-
if node['reftype'] == 'any':
269-
# we search anything!
270-
objtypes = ['%s:%s' % (domain.name, objtype)
271-
for domain in env.domains.values()
272-
for objtype in domain.object_types]
273-
domain = None
286+
inventories = InventoryAdapter(app.builder.env)
287+
if typ == 'any':
288+
for domain in env.domains.values():
289+
domain_store = inventories.by_domain_inventory[domain.name]
290+
res = domain.intersphinx_resolve_any_xref(
291+
env, domain_store, target, node, contnode)
292+
if res is not None:
293+
return res
294+
return None
274295
else:
275-
domain = node.get('refdomain')
276-
if not domain:
296+
domain_name = node.get('refdomain')
297+
if not domain_name:
277298
# only objects in domains are in the inventory
278299
return None
279-
objtypes = env.get_domain(domain).objtypes_for_role(node['reftype'])
280-
if not objtypes:
281-
return None
282-
objtypes = ['%s:%s' % (domain, objtype) for objtype in objtypes]
283-
if 'std:cmdoption' in objtypes:
284-
# until Sphinx-1.6, cmdoptions are stored as std:option
285-
objtypes.append('std:option')
286-
if 'py:attribute' in objtypes:
287-
# Since Sphinx-2.1, properties are stored as py:method
288-
objtypes.append('py:method')
289-
290-
# determine the contnode by pending_xref_condition
291-
content = find_pending_xref_condition(node, 'resolved')
292-
if content:
293-
# resolved condition found.
294-
contnodes = content.children
295-
contnode = content.children[0] # type: ignore
296-
else:
297-
# not resolved. Use the given contnode
298-
contnodes = [contnode]
299-
300-
to_try = [(inventories.main_inventory, target)]
301-
if domain:
302-
full_qualified_name = env.get_domain(domain).get_full_qualified_name(node)
303-
if full_qualified_name:
304-
to_try.append((inventories.main_inventory, full_qualified_name))
305-
in_set = None
306-
if ':' in target:
307-
# first part may be the foreign doc set name
308-
setname, newtarget = target.split(':', 1)
309-
if setname in inventories.named_inventory:
310-
in_set = setname
311-
to_try.append((inventories.named_inventory[setname], newtarget))
312-
if domain:
313-
node['reftarget'] = newtarget
314-
full_qualified_name = env.get_domain(domain).get_full_qualified_name(node)
315-
if full_qualified_name:
316-
to_try.append((inventories.named_inventory[setname], full_qualified_name))
317-
for inventory, target in to_try:
318-
for objtype in objtypes:
319-
if objtype not in inventory or target not in inventory[objtype]:
320-
continue
321-
proj, version, uri, dispname = inventory[objtype][target]
322-
if '://' not in uri and node.get('refdoc'):
323-
# get correct path in case of subdirectories
324-
uri = path.join(relative_path(node['refdoc'], '.'), uri)
325-
if version:
326-
reftitle = _('(in %s v%s)') % (proj, version)
327-
else:
328-
reftitle = _('(in %s)') % (proj,)
329-
newnode = nodes.reference('', '', internal=False, refuri=uri, reftitle=reftitle)
330-
if node.get('refexplicit'):
331-
# use whatever title was given
332-
newnode.extend(contnodes)
333-
elif dispname == '-' or \
334-
(domain == 'std' and node['reftype'] == 'keyword'):
335-
# use whatever title was given, but strip prefix
336-
title = contnode.astext()
337-
if in_set and title.startswith(in_set + ':'):
338-
newnode.append(contnode.__class__(title[len(in_set) + 1:],
339-
title[len(in_set) + 1:]))
340-
else:
341-
newnode.extend(contnodes)
342-
else:
343-
# else use the given display name (used for :ref:)
344-
newnode.append(contnode.__class__(dispname, dispname))
345-
return newnode
346-
# at least get rid of the ':' in the target if no explicit title given
347-
if in_set is not None and not node.get('refexplicit', True):
348-
if len(contnode) and isinstance(contnode[0], nodes.Text):
349-
contnode[0] = nodes.Text(newtarget, contnode[0].rawsource)
350-
351-
return None
300+
domain_store = inventories.by_domain_inventory[domain_name]
301+
return env.domains[domain_name].intersphinx_resolve_xref(
302+
env, domain_store, typ, target, node, contnode)
352303

353304

354305
def normalize_intersphinx_mapping(app: Sphinx, config: Config) -> None:

0 commit comments

Comments
 (0)