Skip to content

Commit 8c45a8b

Browse files
committed
Intersphinx, refactoring
Also, when a reference is unresolved, don't strip the inventory prefix.
1 parent f42d8f7 commit 8c45a8b

File tree

4 files changed

+184
-98
lines changed

4 files changed

+184
-98
lines changed

CHANGES

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ Bugs fixed
8282
* #9688: Fix :rst:dir:`code`` does not recognize ``:class:`` option
8383
* #9733: Fix for logging handler flushing warnings in the middle of the docs
8484
build
85+
* Intersphinx, for unresolved references with an explicit inventory,
86+
e.g., ``proj:myFunc``, leave the inventory prefix in the unresolved text.
8587

8688
Testing
8789
--------

sphinx/ext/intersphinx.py

Lines changed: 178 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -29,23 +29,24 @@
2929
import sys
3030
import time
3131
from os import path
32-
from typing import IO, Any, Dict, List, Tuple
32+
from typing import IO, Any, Dict, List, Optional, Tuple
3333
from urllib.parse import urlsplit, urlunsplit
3434

3535
from docutils import nodes
36-
from docutils.nodes import TextElement
36+
from docutils.nodes import Element, TextElement
3737
from docutils.utils import relative_path
3838

3939
import sphinx
4040
from sphinx.addnodes import pending_xref
4141
from sphinx.application import Sphinx
4242
from sphinx.builders.html import INVENTORY_FILENAME
4343
from sphinx.config import Config
44+
from sphinx.domains import Domain
4445
from sphinx.environment import BuildEnvironment
4546
from sphinx.locale import _, __
4647
from sphinx.util import logging, requests
4748
from sphinx.util.inventory import InventoryFile
48-
from sphinx.util.typing import Inventory
49+
from sphinx.util.typing import Inventory, InventoryInner
4950

5051
logger = logging.getLogger(__name__)
5152

@@ -258,105 +259,187 @@ def load_mappings(app: Sphinx) -> None:
258259
inventories.main_inventory.setdefault(type, {}).update(objects)
259260

260261

261-
def missing_reference(app: Sphinx, env: BuildEnvironment, node: pending_xref,
262-
contnode: TextElement) -> nodes.reference:
263-
"""Attempt to resolve a missing reference via intersphinx references."""
264-
target = node['reftarget']
265-
inventories = InventoryAdapter(env)
266-
objtypes: List[str] = None
267-
if node['reftype'] == 'any':
268-
# we search anything!
269-
objtypes = ['%s:%s' % (domain.name, objtype)
270-
for domain in env.domains.values()
271-
for objtype in domain.object_types]
272-
domain = None
262+
def _create_element_from_result(domain: Domain, inv_name: Optional[str],
263+
data: InventoryInner,
264+
node: pending_xref, contnode: TextElement) -> Element:
265+
proj, version, uri, dispname = data
266+
if '://' not in uri and node.get('refdoc'):
267+
# get correct path in case of subdirectories
268+
uri = path.join(relative_path(node['refdoc'], '.'), uri)
269+
if version:
270+
reftitle = _('(in %s v%s)') % (proj, version)
271+
else:
272+
reftitle = _('(in %s)') % (proj,)
273+
newnode = nodes.reference('', '', internal=False, refuri=uri, reftitle=reftitle)
274+
if node.get('refexplicit'):
275+
# use whatever title was given
276+
newnode.append(contnode)
277+
elif dispname == '-' or \
278+
(domain.name == 'std' and node['reftype'] == 'keyword'):
279+
# use whatever title was given, but strip prefix
280+
title = contnode.astext()
281+
if inv_name is not None and title.startswith(inv_name + ':'):
282+
newnode.append(contnode.__class__(title[len(inv_name) + 1:],
283+
title[len(inv_name) + 1:]))
284+
else:
285+
newnode.append(contnode)
286+
else:
287+
# else use the given display name (used for :ref:)
288+
newnode.append(contnode.__class__(dispname, dispname))
289+
return newnode
290+
291+
292+
def _resolve_reference_in_domain_by_target(
293+
inv_name: Optional[str], inventory: Inventory,
294+
domain: Domain, objtypes: List[str],
295+
target: str,
296+
node: pending_xref, contnode: TextElement) -> Optional[Element]:
297+
for objtype in objtypes:
298+
if objtype not in inventory:
299+
# Continue if there's nothing of this kind in the inventory
300+
continue
301+
302+
if target in inventory[objtype]:
303+
# Case sensitive match, use it
304+
data = inventory[objtype][target]
305+
elif objtype == 'std:term':
306+
# Check for potential case insensitive matches for terms only
307+
target_lower = target.lower()
308+
insensitive_matches = list(filter(lambda k: k.lower() == target_lower,
309+
inventory[objtype].keys()))
310+
if insensitive_matches:
311+
data = inventory[objtype][insensitive_matches[0]]
312+
else:
313+
# No case insensitive match either, continue to the next candidate
314+
continue
315+
else:
316+
# Could reach here if we're not a term but have a case insensitive match.
317+
# This is a fix for terms specifically, but potentially should apply to
318+
# other types.
319+
continue
320+
return _create_element_from_result(domain, inv_name, data, node, contnode)
321+
return None
322+
323+
324+
def _resolve_reference_in_domain(inv_name: Optional[str], inventory: Inventory,
325+
domain: Domain, objtypes: List[str],
326+
node: pending_xref, contnode: TextElement
327+
) -> Optional[Element]:
328+
# we adjust the object types for backwards compatibility
329+
if domain.name == 'std' and 'cmdoption' in objtypes:
330+
# until Sphinx-1.6, cmdoptions are stored as std:option
331+
objtypes.append('option')
332+
if domain.name == 'py' and 'attribute' in objtypes:
333+
# Since Sphinx-2.1, properties are stored as py:method
334+
objtypes.append('method')
335+
336+
# the inventory contains domain:type as objtype
337+
objtypes = ["{}:{}".format(domain.name, t) for t in objtypes]
338+
339+
# without qualification
340+
res = _resolve_reference_in_domain_by_target(inv_name, inventory, domain, objtypes,
341+
node['reftarget'], node, contnode)
342+
if res is not None:
343+
return res
344+
345+
# try with qualification of the current scope instead
346+
full_qualified_name = domain.get_full_qualified_name(node)
347+
if full_qualified_name is None:
348+
return None
349+
return _resolve_reference_in_domain_by_target(inv_name, inventory, domain, objtypes,
350+
full_qualified_name, node, contnode)
351+
352+
353+
def _resolve_reference(env: BuildEnvironment, inv_name: Optional[str], inventory: Inventory,
354+
node: pending_xref, contnode: TextElement) -> Optional[Element]:
355+
# figure out which object types we should look for
356+
typ = node['reftype']
357+
if typ == 'any':
358+
for domain_name, domain in env.domains.items():
359+
objtypes = list(domain.object_types)
360+
res = _resolve_reference_in_domain(inv_name, inventory,
361+
domain, objtypes,
362+
node, contnode)
363+
if res is not None:
364+
return res
365+
return None
273366
else:
274-
domain = node.get('refdomain')
275-
if not domain:
367+
domain_name = node.get('refdomain')
368+
if not domain_name:
276369
# only objects in domains are in the inventory
277370
return None
278-
objtypes = env.get_domain(domain).objtypes_for_role(node['reftype'])
371+
domain = env.get_domain(domain_name)
372+
objtypes = domain.objtypes_for_role(typ)
279373
if not objtypes:
280374
return None
281-
objtypes = ['%s:%s' % (domain, objtype) for objtype in objtypes]
282-
if 'std:cmdoption' in objtypes:
283-
# until Sphinx-1.6, cmdoptions are stored as std:option
284-
objtypes.append('std:option')
285-
if 'py:attribute' in objtypes:
286-
# Since Sphinx-2.1, properties are stored as py:method
287-
objtypes.append('py:method')
288-
289-
to_try = [(inventories.main_inventory, target)]
290-
if domain:
291-
full_qualified_name = env.get_domain(domain).get_full_qualified_name(node)
292-
if full_qualified_name:
293-
to_try.append((inventories.main_inventory, full_qualified_name))
294-
in_set = None
295-
if ':' in target:
296-
# first part may be the foreign doc set name
297-
setname, newtarget = target.split(':', 1)
298-
if setname in inventories.named_inventory:
299-
in_set = setname
300-
to_try.append((inventories.named_inventory[setname], newtarget))
301-
if domain:
302-
node['reftarget'] = newtarget
303-
full_qualified_name = env.get_domain(domain).get_full_qualified_name(node)
304-
if full_qualified_name:
305-
to_try.append((inventories.named_inventory[setname], full_qualified_name))
306-
for inventory, target in to_try:
307-
for objtype in objtypes:
308-
if objtype not in inventory:
309-
# Continue if there's nothing of this kind in the inventory
310-
continue
311-
if target in inventory[objtype]:
312-
# Case sensitive match, use it
313-
proj, version, uri, dispname = inventory[objtype][target]
314-
elif objtype == 'std:term':
315-
# Check for potential case insensitive matches for terms only
316-
target_lower = target.lower()
317-
insensitive_matches = list(filter(lambda k: k.lower() == target_lower,
318-
inventory[objtype].keys()))
319-
if insensitive_matches:
320-
proj, version, uri, dispname = inventory[objtype][insensitive_matches[0]]
321-
else:
322-
# No case insensitive match either, continue to the next candidate
323-
continue
324-
else:
325-
# Could reach here if we're not a term but have a case insensitive match.
326-
# This is a fix for terms specifically, but potentially should apply to
327-
# other types.
328-
continue
375+
return _resolve_reference_in_domain(inv_name, inventory,
376+
domain, objtypes,
377+
node, contnode)
329378

330-
if '://' not in uri and node.get('refdoc'):
331-
# get correct path in case of subdirectories
332-
uri = path.join(relative_path(node['refdoc'], '.'), uri)
333-
if version:
334-
reftitle = _('(in %s v%s)') % (proj, version)
335-
else:
336-
reftitle = _('(in %s)') % (proj,)
337-
newnode = nodes.reference('', '', internal=False, refuri=uri, reftitle=reftitle)
338-
if node.get('refexplicit'):
339-
# use whatever title was given
340-
newnode.append(contnode)
341-
elif dispname == '-' or \
342-
(domain == 'std' and node['reftype'] == 'keyword'):
343-
# use whatever title was given, but strip prefix
344-
title = contnode.astext()
345-
if in_set and title.startswith(in_set + ':'):
346-
newnode.append(contnode.__class__(title[len(in_set) + 1:],
347-
title[len(in_set) + 1:]))
348-
else:
349-
newnode.append(contnode)
350-
else:
351-
# else use the given display name (used for :ref:)
352-
newnode.append(contnode.__class__(dispname, dispname))
353-
return newnode
354-
# at least get rid of the ':' in the target if no explicit title given
355-
if in_set is not None and not node.get('refexplicit', True):
356-
if len(contnode) and isinstance(contnode[0], nodes.Text):
357-
contnode[0] = nodes.Text(newtarget, contnode[0].rawsource)
358379

359-
return None
380+
def inventory_exists(env: BuildEnvironment, inv_name: str) -> bool:
381+
return inv_name in InventoryAdapter(env).named_inventory
382+
383+
384+
def resolve_reference_in_inventory(env: BuildEnvironment,
385+
inv_name: str,
386+
node: pending_xref,
387+
contnode: TextElement) -> Optional[Element]:
388+
"""Attempt to resolve a missing reference via intersphinx references.
389+
390+
Resolution is tried in the given inventory with the target as is.
391+
392+
Requires ``inventory_exists(env, inv_name)``.
393+
"""
394+
assert inventory_exists(env, inv_name)
395+
return _resolve_reference(env, inv_name, InventoryAdapter(env).named_inventory[inv_name],
396+
node, contnode)
397+
398+
399+
def resolve_reference_any_inventory(env: BuildEnvironment,
400+
node: pending_xref,
401+
contnode: TextElement) -> Optional[Element]:
402+
"""Attempt to resolve a missing reference via intersphinx references.
403+
404+
Resolution is tried with the target as is in any inventory.
405+
"""
406+
return _resolve_reference(env, None, InventoryAdapter(env).main_inventory, node, contnode)
407+
408+
409+
def resolve_reference_detect_inventory(env: BuildEnvironment,
410+
node: pending_xref,
411+
contnode: TextElement) -> Optional[Element]:
412+
"""Attempt to resolve a missing reference via intersphinx references.
413+
414+
Resolution is tried first with the target as is in any inventory.
415+
If this does not succeed, then the target is split by the first ``:``,
416+
to form ``inv_name:newtarget``. If ``inv_name`` is a named inventory, then resolution
417+
is tried in that inventory with the new target.
418+
"""
419+
420+
# ordinary direct lookup, use data as is
421+
res = resolve_reference_any_inventory(env, node, contnode)
422+
if res is not None:
423+
return res
424+
425+
# try splitting the target into 'inv_name:target'
426+
target = node['reftarget']
427+
if ':' not in target:
428+
return None
429+
inv_name, newtarget = target.split(':', 1)
430+
if not inventory_exists(env, inv_name):
431+
return None
432+
node['reftarget'] = newtarget
433+
res_inv = resolve_reference_in_inventory(env, inv_name, node, contnode)
434+
node['reftarget'] = target
435+
return res_inv
436+
437+
438+
def missing_reference(app: Sphinx, env: BuildEnvironment, node: pending_xref,
439+
contnode: TextElement) -> Optional[Element]:
440+
"""Attempt to resolve a missing reference via intersphinx references."""
441+
442+
return resolve_reference_detect_inventory(env, node, contnode)
360443

361444

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

sphinx/util/typing.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ def _evaluate(self, globalns: Dict, localns: Dict) -> Any:
7070
TitleGetter = Callable[[nodes.Node], str]
7171

7272
# inventory data on memory
73-
Inventory = Dict[str, Dict[str, Tuple[str, str, str, str]]]
73+
InventoryInner = Tuple[str, str, str, str]
74+
Inventory = Dict[str, Dict[str, InventoryInner]]
7475

7576

7677
def get_type_hints(obj: Any, globalns: Dict = None, localns: Dict = None) -> Dict[str, Any]:

tests/test_ext_intersphinx.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,12 @@ def test_missing_reference(tempdir, app, status, warning):
133133
refexplicit=True)
134134
assert rn[0].astext() == 'py3k:module2'
135135

136-
# prefix given, target not found and nonexplicit title: prefix is stripped
136+
# prefix given, target not found and nonexplicit title: prefix is not stripped
137137
node, contnode = fake_node('py', 'mod', 'py3k:unknown', 'py3k:unknown',
138138
refexplicit=False)
139139
rn = missing_reference(app, app.env, node, contnode)
140140
assert rn is None
141-
assert contnode[0].astext() == 'unknown'
141+
assert contnode[0].astext() == 'py3k:unknown'
142142

143143
# prefix given, target not found and explicit title: nothing is changed
144144
node, contnode = fake_node('py', 'mod', 'py3k:unknown', 'py3k:unknown',

0 commit comments

Comments
 (0)