diff --git a/CHANGES.rst b/CHANGES.rst index 3e227b53371..6086f7d11c0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -24,6 +24,9 @@ Features added * Flatten ``Union[Literal[T], Literal[U], ...]`` to ``Literal[T, U, ...]`` when turning annotations into strings. Patch by Adam Turner. +* Add detection of ambiguous ``std:label`` and ``std:term`` references during + loading and resolution of Intersphinx targets. + Patch by James Addison. * #12319: ``sphinx.ext.extlinks``: Add ``extlink-{name}`` CSS class to links. Patch by Hugo van Kemenade. diff --git a/sphinx/ext/intersphinx/_load.py b/sphinx/ext/intersphinx/_load.py index 8e951ecd4ae..b458d6a7e18 100644 --- a/sphinx/ext/intersphinx/_load.py +++ b/sphinx/ext/intersphinx/_load.py @@ -117,7 +117,9 @@ def fetch_inventory_group( # files; remote ones only if the cache time is expired if '://' not in inv or uri not in cache or cache[uri][1] < cache_time: safe_inv_url = _get_safe_url(inv) - LOGGER.info(__('loading intersphinx inventory from %s...'), safe_inv_url) + inv_descriptor = name or 'main_inventory' + LOGGER.info(__("loading intersphinx inventory '%s' from %s..."), + inv_descriptor, safe_inv_url) try: invdata = fetch_inventory(app, uri, inv) except Exception as err: diff --git a/sphinx/ext/intersphinx/_resolve.py b/sphinx/ext/intersphinx/_resolve.py index 08961e071b4..0a3cc8949d2 100644 --- a/sphinx/ext/intersphinx/_resolve.py +++ b/sphinx/ext/intersphinx/_resolve.py @@ -80,6 +80,11 @@ def _resolve_reference_in_domain_by_target( target_lower = target.lower() insensitive_matches = list(filter(lambda k: k.lower() == target_lower, inventory[objtype].keys())) + if len(insensitive_matches) > 1: + inv_descriptor = inv_name or 'main_inventory' + LOGGER.warning(__("inventory '%s': multiple matches found for %s:%s"), + inv_descriptor, objtype, target, + type='intersphinx', subtype='external', location=node) if insensitive_matches: data = inventory[objtype][insensitive_matches[0]] else: diff --git a/sphinx/util/inventory.py b/sphinx/util/inventory.py index a43fd0379ea..55d7efd8396 100644 --- a/sphinx/util/inventory.py +++ b/sphinx/util/inventory.py @@ -6,6 +6,7 @@ import zlib from typing import IO, TYPE_CHECKING, Callable +from sphinx.locale import __ from sphinx.util import logging BUFSIZE = 16 * 1024 @@ -125,6 +126,8 @@ def load_v2( invdata: Inventory = {} projname = stream.readline().rstrip()[11:] version = stream.readline().rstrip()[11:] + potential_ambiguities = set() + actual_ambiguities = set() line = stream.readline() if 'zlib' not in line: raise ValueError('invalid inventory header (not compressed): %s' % line) @@ -147,11 +150,23 @@ def load_v2( # for Python modules, and the first # one is correct continue + if type in {'std:label', 'std:term'}: + # Some types require case insensitive matches: + # * 'term': https://github.com/sphinx-doc/sphinx/issues/9291 + # * 'label': https://github.com/sphinx-doc/sphinx/issues/12008 + definition = f"{type}:{name}" + if definition.lower() in potential_ambiguities: + actual_ambiguities.add(definition) + else: + potential_ambiguities.add(definition.lower()) if location.endswith('$'): location = location[:-1] + name location = join(uri, location) inv_item: InventoryItem = projname, version, location, dispname invdata.setdefault(type, {})[name] = inv_item + for ambiguity in actual_ambiguities: + logger.warning(__("inventory <%s> contains multiple definitions for %s"), + uri, ambiguity, type='intersphinx', subtype='external') return invdata @classmethod diff --git a/tests/test_extensions/test_ext_intersphinx.py b/tests/test_extensions/test_ext_intersphinx.py index c9d53787b57..d475c60f9e7 100644 --- a/tests/test_extensions/test_ext_intersphinx.py +++ b/tests/test_extensions/test_ext_intersphinx.py @@ -19,7 +19,11 @@ from sphinx.ext.intersphinx._load import _get_safe_url, _strip_basic_auth from sphinx.util.console import strip_colors -from tests.test_util.intersphinx_data import INVENTORY_V2, INVENTORY_V2_NO_VERSION +from tests.test_util.intersphinx_data import ( + INVENTORY_V2, + INVENTORY_V2_AMBIGUOUS_TERMS, + INVENTORY_V2_NO_VERSION, +) from tests.utils import http_server @@ -247,6 +251,24 @@ def test_missing_reference_stddomain(tmp_path, app, status, warning): assert rn.astext() == 'The Julia Domain' +def test_ambiguous_reference_warning(tmp_path, app, warning): + inv_file = tmp_path / 'inventory' + inv_file.write_bytes(INVENTORY_V2_AMBIGUOUS_TERMS) + set_config(app, { + 'cmd': ('https://docs.python.org/', str(inv_file)), + }) + + # load the inventory + normalize_intersphinx_mapping(app, app.config) + load_mappings(app) + + # term reference (case insensitive) + node, contnode = fake_node('std', 'term', 'A TERM', 'A TERM') + missing_reference(app, app.env, node, contnode) + + assert 'multiple matches found for std:term:A TERM' in warning.getvalue() + + @pytest.mark.sphinx('html', testroot='ext-intersphinx-cppdomain') def test_missing_reference_cppdomain(tmp_path, app, status, warning): inv_file = tmp_path / 'inventory' diff --git a/tests/test_util/intersphinx_data.py b/tests/test_util/intersphinx_data.py index 042ee76d779..889645903dd 100644 --- a/tests/test_util/intersphinx_data.py +++ b/tests/test_util/intersphinx_data.py @@ -50,3 +50,13 @@ ''' + zlib.compress(b'''\ module1 py:module 0 foo.html#module-module1 Long Module desc ''') + +INVENTORY_V2_AMBIGUOUS_TERMS: Final[bytes] = b'''\ +# Sphinx inventory version 2 +# Project: foo +# Version: 2.0 +# The remainder of this file is compressed with zlib. +''' + zlib.compress(b'''\ +a term std:term -1 glossary.html#term-a-term - +A term std:term -1 glossary.html#term-a-term - +''') diff --git a/tests/test_util/test_util_inventory.py b/tests/test_util/test_util_inventory.py index 81d31b0ef44..0bdef9f67d9 100644 --- a/tests/test_util/test_util_inventory.py +++ b/tests/test_util/test_util_inventory.py @@ -10,6 +10,7 @@ from tests.test_util.intersphinx_data import ( INVENTORY_V1, INVENTORY_V2, + INVENTORY_V2_AMBIGUOUS_TERMS, INVENTORY_V2_NO_VERSION, ) @@ -48,6 +49,13 @@ def test_read_inventory_v2_not_having_version(): ('foo', '', '/util/foo.html#module-module1', 'Long Module desc') +def test_ambiguous_definition_warning(warning): + f = BytesIO(INVENTORY_V2_AMBIGUOUS_TERMS) + InventoryFile.load(f, '/util', posixpath.join) + + assert 'contains multiple definitions for std:term:a' in warning.getvalue().lower() + + def _write_appconfig(dir, language, prefix=None): prefix = prefix or language os.makedirs(dir / prefix, exist_ok=True)