diff --git a/CHANGES.rst b/CHANGES.rst index e5279bcdb58..03fb6d57d4f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -60,6 +60,9 @@ Incompatible changes * #12597: Change the default of :confval:`show_warning_types` from ``False`` to ``True``. Patch by Chris Sewell. +* #12083: Remove support for the old (2008--2010) Sphinx 0.5 and Sphinx 0.6 + :confval:`intersphinx_mapping` format. + Patch by Bénédikt Tran and Adam Turner. Deprecated ---------- diff --git a/doc/usage/extensions/intersphinx.rst b/doc/usage/extensions/intersphinx.rst index f64b5968029..3212075ebfc 100644 --- a/doc/usage/extensions/intersphinx.rst +++ b/doc/usage/extensions/intersphinx.rst @@ -128,28 +128,6 @@ linking: ('../../otherbook/build/html/objects.inv', None)), } - **Old format for this config value** - - .. deprecated:: 6.2 - - .. RemovedInSphinx80Warning - - .. caution:: This is the format used before Sphinx 1.0. - It is deprecated and will be removed in Sphinx 8.0. - - A dictionary mapping URIs to either ``None`` or an URI. The keys are the - base URI of the foreign Sphinx documentation sets and can be local paths or - HTTP URIs. The values indicate where the inventory file can be found: they - can be ``None`` (at the same location as the base URI) or another local or - HTTP URI. - - Example: - - .. code-block:: python - - intersphinx_mapping = {'https://docs.python.org/': None} - - .. confval:: intersphinx_cache_limit The maximum number of days to cache remote inventories. The default is diff --git a/sphinx/environment/__init__.py b/sphinx/environment/__init__.py index deb6af7975a..4539c1eab6a 100644 --- a/sphinx/environment/__init__.py +++ b/sphinx/environment/__init__.py @@ -59,7 +59,7 @@ # This is increased every time an environment attribute is added # or changed to properly invalidate pickle files. -ENV_VERSION = 62 +ENV_VERSION = 63 # config status CONFIG_UNSET = -1 diff --git a/sphinx/ext/intersphinx/_load.py b/sphinx/ext/intersphinx/_load.py index b458d6a7e18..7e1c982cde9 100644 --- a/sphinx/ext/intersphinx/_load.py +++ b/sphinx/ext/intersphinx/_load.py @@ -11,6 +11,7 @@ from urllib.parse import urlsplit, urlunsplit from sphinx.builders.html import INVENTORY_FILENAME +from sphinx.errors import ConfigError from sphinx.ext.intersphinx._shared import LOGGER, InventoryAdapter from sphinx.locale import __ from sphinx.util import requests @@ -21,55 +22,123 @@ from sphinx.application import Sphinx from sphinx.config import Config - from sphinx.ext.intersphinx._shared import InventoryCacheEntry + from sphinx.ext.intersphinx._shared import ( + IntersphinxMapping, + InventoryCacheEntry, + InventoryLocation, + InventoryName, + InventoryURI, + ) from sphinx.util.typing import Inventory def normalize_intersphinx_mapping(app: Sphinx, config: Config) -> None: - for key, value in config.intersphinx_mapping.copy().items(): + # URIs should NOT be duplicated, otherwise different builds may use + # different project names (and thus, the build are no more reproducible) + # depending on which one is inserted last in the cache. + seen: dict[InventoryURI, InventoryName] = {} + + errors = 0 + for name, value in config.intersphinx_mapping.copy().items(): + # ensure that intersphinx projects are always named + if not isinstance(name, str): + errors += 1 + msg = __( + 'Invalid intersphinx project identifier `%r` in intersphinx_mapping. ' + 'Project identifiers must be non-empty strings.' + ) + LOGGER.error(msg % name) + del config.intersphinx_mapping[name] + continue + if not name: + errors += 1 + msg = __( + 'Invalid intersphinx project identifier `%r` in intersphinx_mapping. ' + 'Project identifiers must be non-empty strings.' + ) + LOGGER.error(msg % name) + del config.intersphinx_mapping[name] + continue + + # ensure values are properly formatted + if not isinstance(value, (tuple, list)): + errors += 1 + msg = __( + 'Invalid value `%r` in intersphinx_mapping[%r]. ' + 'Expected a two-element tuple or list.' + ) + LOGGER.error(msg % (value, name)) + del config.intersphinx_mapping[name] + continue try: - if isinstance(value, (list, tuple)): - # new format - name, (uri, inv) = key, value - if not isinstance(name, str): - LOGGER.warning(__('intersphinx identifier %r is not string. Ignored'), - name) - config.intersphinx_mapping.pop(key) - continue + uri, inv = value + except (TypeError, ValueError, Exception): + errors += 1 + msg = __( + 'Invalid value `%r` in intersphinx_mapping[%r]. ' + 'Values must be a (target URI, inventory locations) pair.' + ) + LOGGER.error(msg % (value, name)) + del config.intersphinx_mapping[name] + continue + + # ensure target URIs are non-empty and unique + if not uri or not isinstance(uri, str): + errors += 1 + msg = __('Invalid target URI value `%r` in intersphinx_mapping[%r][0]. ' + 'Target URIs must be unique non-empty strings.') + LOGGER.error(msg % (uri, name)) + del config.intersphinx_mapping[name] + continue + if uri in seen: + errors += 1 + msg = __( + 'Invalid target URI value `%r` in intersphinx_mapping[%r][0]. ' + 'Target URIs must be unique (other instance in intersphinx_mapping[%r]).' + ) + LOGGER.error(msg % (uri, name, seen[uri])) + del config.intersphinx_mapping[name] + continue + seen[uri] = name + + # ensure inventory locations are None or non-empty + targets: list[InventoryLocation] = [] + for target in (inv if isinstance(inv, (tuple, list)) else (inv,)): + if target is None or target and isinstance(target, str): + targets.append(target) else: - # old format, no name - # xref RemovedInSphinx80Warning - name, uri, inv = None, key, value - msg = ( - "The pre-Sphinx 1.0 'intersphinx_mapping' format is " - 'deprecated and will be removed in Sphinx 8. Update to the ' - 'current format as described in the documentation. ' - f"Hint: `intersphinx_mapping = {{'': {(uri, inv)!r}}}`." - 'https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#confval-intersphinx_mapping' # NoQA: E501 + errors += 1 + msg = __( + 'Invalid inventory location value `%r` in intersphinx_mapping[%r][1]. ' + 'Inventory locations must be non-empty strings or None.' ) - LOGGER.warning(msg) + LOGGER.error(msg % (target, name)) + del config.intersphinx_mapping[name] + continue - if not isinstance(inv, tuple): - config.intersphinx_mapping[key] = (name, (uri, (inv,))) - else: - config.intersphinx_mapping[key] = (name, (uri, inv)) - except Exception as exc: - LOGGER.warning(__('Failed to read intersphinx_mapping[%s], ignored: %r'), key, exc) - config.intersphinx_mapping.pop(key) + config.intersphinx_mapping[name] = (name, (uri, tuple(targets))) + + if errors == 1: + msg = __('Invalid `intersphinx_mapping` configuration (1 error).') + raise ConfigError(msg) + if errors > 1: + msg = __('Invalid `intersphinx_mapping` configuration (%s errors).') + raise ConfigError(msg % errors) def load_mappings(app: Sphinx) -> None: - """Load all intersphinx mappings into the environment.""" + """Load all intersphinx mappings into the environment. + + The intersphinx mappings are expected to be normalized. + """ now = int(time.time()) inventories = InventoryAdapter(app.builder.env) - intersphinx_cache: dict[str, InventoryCacheEntry] = inventories.cache + intersphinx_cache: dict[InventoryURI, InventoryCacheEntry] = inventories.cache + intersphinx_mapping: IntersphinxMapping = app.config.intersphinx_mapping with concurrent.futures.ThreadPoolExecutor() as pool: futures = [] - name: str | None - uri: str - invs: tuple[str | None, ...] - for name, (uri, invs) in app.config.intersphinx_mapping.values(): + for name, (uri, invs) in intersphinx_mapping.values(): futures.append(pool.submit( fetch_inventory_group, name, uri, invs, intersphinx_cache, app, now, )) @@ -100,10 +169,10 @@ def load_mappings(app: Sphinx) -> None: def fetch_inventory_group( - name: str | None, - uri: str, - invs: tuple[str | None, ...], - cache: dict[str, InventoryCacheEntry], + name: InventoryName, + uri: InventoryURI, + invs: tuple[InventoryLocation, ...], + cache: dict[InventoryURI, InventoryCacheEntry], app: Sphinx, now: int, ) -> bool: @@ -130,7 +199,7 @@ def fetch_inventory_group( return True return False finally: - if failures == []: + if not failures: pass elif len(failures) < len(invs): LOGGER.info(__('encountered some issues with some of the inventories,' @@ -143,7 +212,7 @@ def fetch_inventory_group( 'with the following issues:') + '\n' + issues) -def fetch_inventory(app: Sphinx, uri: str, inv: str) -> Inventory: +def fetch_inventory(app: Sphinx, uri: InventoryURI, inv: str) -> Inventory: """Fetch, parse and return an intersphinx inventory file.""" # both *uri* (base URI of the links to generate) and *inv* (actual # location of the inventory file) can be local or remote URIs diff --git a/sphinx/ext/intersphinx/_resolve.py b/sphinx/ext/intersphinx/_resolve.py index 0a3cc8949d2..730385a7359 100644 --- a/sphinx/ext/intersphinx/_resolve.py +++ b/sphinx/ext/intersphinx/_resolve.py @@ -28,10 +28,11 @@ from sphinx.application import Sphinx from sphinx.domains import Domain from sphinx.environment import BuildEnvironment + from sphinx.ext.intersphinx._shared import InventoryName from sphinx.util.typing import Inventory, InventoryItem, RoleFunction -def _create_element_from_result(domain: Domain, inv_name: str | None, +def _create_element_from_result(domain: Domain, inv_name: InventoryName | None, data: InventoryItem, node: pending_xref, contnode: TextElement) -> nodes.reference: proj, version, uri, dispname = data @@ -61,7 +62,7 @@ def _create_element_from_result(domain: Domain, inv_name: str | None, def _resolve_reference_in_domain_by_target( - inv_name: str | None, inventory: Inventory, + inv_name: InventoryName | None, inventory: Inventory, domain: Domain, objtypes: Iterable[str], target: str, node: pending_xref, contnode: TextElement) -> nodes.reference | None: @@ -100,7 +101,7 @@ def _resolve_reference_in_domain_by_target( def _resolve_reference_in_domain(env: BuildEnvironment, - inv_name: str | None, inventory: Inventory, + inv_name: InventoryName | None, inventory: Inventory, honor_disabled_refs: bool, domain: Domain, objtypes: Iterable[str], node: pending_xref, contnode: TextElement, @@ -142,20 +143,21 @@ def _resolve_reference_in_domain(env: BuildEnvironment, full_qualified_name, node, contnode) -def _resolve_reference(env: BuildEnvironment, inv_name: str | None, inventory: Inventory, +def _resolve_reference(env: BuildEnvironment, + inv_name: InventoryName | None, inventory: Inventory, honor_disabled_refs: bool, node: pending_xref, contnode: TextElement) -> nodes.reference | None: # disabling should only be done if no inventory is given honor_disabled_refs = honor_disabled_refs and inv_name is None + intersphinx_disabled_reftypes = env.config.intersphinx_disabled_reftypes - if honor_disabled_refs and '*' in env.config.intersphinx_disabled_reftypes: + if honor_disabled_refs and '*' in intersphinx_disabled_reftypes: return None typ = node['reftype'] if typ == 'any': for domain_name, domain in env.domains.items(): - if (honor_disabled_refs - and (domain_name + ':*') in env.config.intersphinx_disabled_reftypes): + if honor_disabled_refs and f'{domain_name}:*' in intersphinx_disabled_reftypes: continue objtypes: Iterable[str] = domain.object_types.keys() res = _resolve_reference_in_domain(env, inv_name, inventory, @@ -170,8 +172,7 @@ def _resolve_reference(env: BuildEnvironment, inv_name: str | None, inventory: I if not domain_name: # only objects in domains are in the inventory return None - if (honor_disabled_refs - and (domain_name + ':*') in env.config.intersphinx_disabled_reftypes): + if honor_disabled_refs and f'{domain_name}:*' in intersphinx_disabled_reftypes: return None domain = env.get_domain(domain_name) objtypes = domain.objtypes_for_role(typ) or () @@ -183,12 +184,12 @@ def _resolve_reference(env: BuildEnvironment, inv_name: str | None, inventory: I node, contnode) -def inventory_exists(env: BuildEnvironment, inv_name: str) -> bool: +def inventory_exists(env: BuildEnvironment, inv_name: InventoryName) -> bool: return inv_name in InventoryAdapter(env).named_inventory def resolve_reference_in_inventory(env: BuildEnvironment, - inv_name: str, + inv_name: InventoryName, node: pending_xref, contnode: TextElement, ) -> nodes.reference | None: """Attempt to resolve a missing reference via intersphinx references. diff --git a/sphinx/ext/intersphinx/_shared.py b/sphinx/ext/intersphinx/_shared.py index f2f52444b99..fed5844947d 100644 --- a/sphinx/ext/intersphinx/_shared.py +++ b/sphinx/ext/intersphinx/_shared.py @@ -2,15 +2,40 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Final, Union +from typing import TYPE_CHECKING, Final from sphinx.util import logging if TYPE_CHECKING: + from typing import Optional + from sphinx.environment import BuildEnvironment from sphinx.util.typing import Inventory - InventoryCacheEntry = tuple[Union[str, None], int, Inventory] + #: The inventory project URL to which links are resolved. + #: + #: This value is unique in :confval:`intersphinx_mapping`. + InventoryURI = str + + #: The inventory (non-empty) name. + #: + #: It is unique and in bijection with an inventory remote URL. + InventoryName = str + + #: A target (local or remote) containing the inventory data to fetch. + #: + #: Empty strings are not expected and ``None`` indicates the default + #: inventory file name :data:`~sphinx.builder.html.INVENTORY_FILENAME`. + InventoryLocation = Optional[str] + + #: Inventory cache entry. The integer field is the cache expiration time. + InventoryCacheEntry = tuple[InventoryName, int, Inventory] + + #: The type of :confval:`intersphinx_mapping` *after* normalization. + IntersphinxMapping = dict[ + InventoryName, + tuple[InventoryName, tuple[InventoryURI, tuple[InventoryLocation, ...]]], + ] LOGGER: Final[logging.SphinxLoggerAdapter] = logging.getLogger('sphinx.ext.intersphinx') @@ -29,14 +54,13 @@ def __init__(self, env: BuildEnvironment) -> None: self.env.intersphinx_named_inventory = {} # type: ignore[attr-defined] @property - def cache(self) -> dict[str, InventoryCacheEntry]: + def cache(self) -> dict[InventoryURI, InventoryCacheEntry]: """Intersphinx cache. - - Key is the URI of the remote inventory - - Element one is the key given in the Sphinx intersphinx_mapping - configuration value - - Element two is a time value for cache invalidation, a float - - Element three is the loaded remote inventory, type Inventory + - Key is the URI of the remote inventory. + - Element one is the key given in the Sphinx :confval:`intersphinx_mapping`. + - Element two is a time value for cache invalidation, an integer. + - Element three is the loaded remote inventory of type :class:`!Inventory`. """ return self.env.intersphinx_cache # type: ignore[attr-defined] @@ -45,7 +69,7 @@ def main_inventory(self) -> Inventory: return self.env.intersphinx_inventory # type: ignore[attr-defined] @property - def named_inventory(self) -> dict[str, Inventory]: + def named_inventory(self) -> dict[InventoryName, Inventory]: return self.env.intersphinx_named_inventory # type: ignore[attr-defined] def clear(self) -> None: diff --git a/sphinx/util/typing.py b/sphinx/util/typing.py index 20ecb6e6c4d..49d071d9c0f 100644 --- a/sphinx/util/typing.py +++ b/sphinx/util/typing.py @@ -127,6 +127,8 @@ def __call__( str, # URL str, # display name ] + +# referencable role -> (reference name -> inventory item) Inventory = dict[str, dict[str, InventoryItem]] diff --git a/tests/js/fixtures/cpp/searchindex.js b/tests/js/fixtures/cpp/searchindex.js index 46f48244741..78e5f761485 100644 --- a/tests/js/fixtures/cpp/searchindex.js +++ b/tests/js/fixtures/cpp/searchindex.js @@ -1 +1 @@ -Search.setIndex({"alltitles": {}, "docnames": ["index"], "envversion": {"sphinx": 62, "sphinx.domains.c": 3, "sphinx.domains.changeset": 1, "sphinx.domains.citation": 1, "sphinx.domains.cpp": 9, "sphinx.domains.index": 1, "sphinx.domains.javascript": 3, "sphinx.domains.math": 2, "sphinx.domains.python": 4, "sphinx.domains.rst": 2, "sphinx.domains.std": 2}, "filenames": ["index.rst"], "indexentries": {"sphinx (c++ class)": [[0, "_CPPv46Sphinx", false]]}, "objects": {"": [[0, 0, 1, "_CPPv46Sphinx", "Sphinx"]]}, "objnames": {"0": ["cpp", "class", "C++ class"]}, "objtypes": {"0": "cpp:class"}, "terms": {"The": 0, "becaus": 0, "c": 0, "can": 0, "cardin": 0, "challeng": 0, "charact": 0, "class": 0, "descript": 0, "drop": 0, "engin": 0, "fixtur": 0, "frequent": 0, "gener": 0, "i": 0, "index": 0, "inflat": 0, "mathemat": 0, "occur": 0, "often": 0, "project": 0, "punctuat": 0, "queri": 0, "relat": 0, "sampl": 0, "search": 0, "size": 0, "sphinx": 0, "term": 0, "thei": 0, "thi": 0, "token": 0, "us": 0, "web": 0, "would": 0}, "titles": ["<no title>"], "titleterms": {}}) \ No newline at end of file +Search.setIndex({"alltitles": {}, "docnames": ["index"], "envversion": {"sphinx": 63, "sphinx.domains.c": 3, "sphinx.domains.changeset": 1, "sphinx.domains.citation": 1, "sphinx.domains.cpp": 9, "sphinx.domains.index": 1, "sphinx.domains.javascript": 3, "sphinx.domains.math": 2, "sphinx.domains.python": 4, "sphinx.domains.rst": 2, "sphinx.domains.std": 2}, "filenames": ["index.rst"], "indexentries": {"sphinx (c++ class)": [[0, "_CPPv46Sphinx", false]]}, "objects": {"": [[0, 0, 1, "_CPPv46Sphinx", "Sphinx"]]}, "objnames": {"0": ["cpp", "class", "C++ class"]}, "objtypes": {"0": "cpp:class"}, "terms": {"The": 0, "becaus": 0, "c": 0, "can": 0, "cardin": 0, "challeng": 0, "charact": 0, "class": 0, "descript": 0, "drop": 0, "engin": 0, "fixtur": 0, "frequent": 0, "gener": 0, "i": 0, "index": 0, "inflat": 0, "mathemat": 0, "occur": 0, "often": 0, "project": 0, "punctuat": 0, "queri": 0, "relat": 0, "sampl": 0, "search": 0, "size": 0, "sphinx": 0, "term": 0, "thei": 0, "thi": 0, "token": 0, "us": 0, "web": 0, "would": 0}, "titles": ["<no title>"], "titleterms": {}}) \ No newline at end of file diff --git a/tests/js/fixtures/multiterm/searchindex.js b/tests/js/fixtures/multiterm/searchindex.js index a868eb6bdcb..96b093c5fda 100644 --- a/tests/js/fixtures/multiterm/searchindex.js +++ b/tests/js/fixtures/multiterm/searchindex.js @@ -1 +1 @@ -Search.setIndex({"alltitles": {"Main Page": [[0, null]]}, "docnames": ["index"], "envversion": {"sphinx": 62, "sphinx.domains.c": 3, "sphinx.domains.changeset": 1, "sphinx.domains.citation": 1, "sphinx.domains.cpp": 9, "sphinx.domains.index": 1, "sphinx.domains.javascript": 3, "sphinx.domains.math": 2, "sphinx.domains.python": 4, "sphinx.domains.rst": 2, "sphinx.domains.std": 2}, "filenames": ["index.rst"], "indexentries": {}, "objects": {}, "objnames": {}, "objtypes": {}, "terms": {"At": 0, "adjac": 0, "all": 0, "an": 0, "appear": 0, "applic": 0, "ar": 0, "built": 0, "can": 0, "check": 0, "contain": 0, "do": 0, "document": 0, "doesn": 0, "each": 0, "fixtur": 0, "format": 0, "function": 0, "futur": 0, "html": 0, "i": 0, "includ": 0, "match": 0, "messag": 0, "multipl": 0, "multiterm": 0, "order": 0, "other": 0, "output": 0, "perform": 0, "perhap": 0, "phrase": 0, "project": 0, "queri": 0, "requir": 0, "same": 0, "search": 0, "successfulli": 0, "support": 0, "t": 0, "term": 0, "test": 0, "thi": 0, "time": 0, "us": 0, "when": 0, "write": 0}, "titles": ["Main Page"], "titleterms": {"main": 0, "page": 0}}) \ No newline at end of file +Search.setIndex({"alltitles": {"Main Page": [[0, null]]}, "docnames": ["index"], "envversion": {"sphinx": 63, "sphinx.domains.c": 3, "sphinx.domains.changeset": 1, "sphinx.domains.citation": 1, "sphinx.domains.cpp": 9, "sphinx.domains.index": 1, "sphinx.domains.javascript": 3, "sphinx.domains.math": 2, "sphinx.domains.python": 4, "sphinx.domains.rst": 2, "sphinx.domains.std": 2}, "filenames": ["index.rst"], "indexentries": {}, "objects": {}, "objnames": {}, "objtypes": {}, "terms": {"At": 0, "adjac": 0, "all": 0, "an": 0, "appear": 0, "applic": 0, "ar": 0, "built": 0, "can": 0, "check": 0, "contain": 0, "do": 0, "document": 0, "doesn": 0, "each": 0, "fixtur": 0, "format": 0, "function": 0, "futur": 0, "html": 0, "i": 0, "includ": 0, "match": 0, "messag": 0, "multipl": 0, "multiterm": 0, "order": 0, "other": 0, "output": 0, "perform": 0, "perhap": 0, "phrase": 0, "project": 0, "queri": 0, "requir": 0, "same": 0, "search": 0, "successfulli": 0, "support": 0, "t": 0, "term": 0, "test": 0, "thi": 0, "time": 0, "us": 0, "when": 0, "write": 0}, "titles": ["Main Page"], "titleterms": {"main": 0, "page": 0}}) \ No newline at end of file diff --git a/tests/js/fixtures/partial/searchindex.js b/tests/js/fixtures/partial/searchindex.js index 356386af8dd..dc6bc1cddbb 100644 --- a/tests/js/fixtures/partial/searchindex.js +++ b/tests/js/fixtures/partial/searchindex.js @@ -1 +1 @@ -Search.setIndex({"alltitles": {"sphinx_utils module": [[0, null]]}, "docnames": ["index"], "envversion": {"sphinx": 62, "sphinx.domains.c": 3, "sphinx.domains.changeset": 1, "sphinx.domains.citation": 1, "sphinx.domains.cpp": 9, "sphinx.domains.index": 1, "sphinx.domains.javascript": 3, "sphinx.domains.math": 2, "sphinx.domains.python": 4, "sphinx.domains.rst": 2, "sphinx.domains.std": 2}, "filenames": ["index.rst"], "indexentries": {}, "objects": {}, "objnames": {}, "objtypes": {}, "terms": {"also": 0, "ar": 0, "built": 0, "confirm": 0, "document": 0, "function": 0, "html": 0, "i": 0, "includ": 0, "input": 0, "javascript": 0, "known": 0, "match": 0, "partial": 0, "possibl": 0, "prefix": 0, "project": 0, "provid": 0, "restructuredtext": 0, "sampl": 0, "search": 0, "should": 0, "thi": 0, "titl": 0, "us": 0, "when": 0}, "titles": ["sphinx_utils module"], "titleterms": {"modul": 0, "sphinx_util": 0}}) \ No newline at end of file +Search.setIndex({"alltitles": {"sphinx_utils module": [[0, null]]}, "docnames": ["index"], "envversion": {"sphinx": 63, "sphinx.domains.c": 3, "sphinx.domains.changeset": 1, "sphinx.domains.citation": 1, "sphinx.domains.cpp": 9, "sphinx.domains.index": 1, "sphinx.domains.javascript": 3, "sphinx.domains.math": 2, "sphinx.domains.python": 4, "sphinx.domains.rst": 2, "sphinx.domains.std": 2}, "filenames": ["index.rst"], "indexentries": {}, "objects": {}, "objnames": {}, "objtypes": {}, "terms": {"also": 0, "ar": 0, "built": 0, "confirm": 0, "document": 0, "function": 0, "html": 0, "i": 0, "includ": 0, "input": 0, "javascript": 0, "known": 0, "match": 0, "partial": 0, "possibl": 0, "prefix": 0, "project": 0, "provid": 0, "restructuredtext": 0, "sampl": 0, "search": 0, "should": 0, "thi": 0, "titl": 0, "us": 0, "when": 0}, "titles": ["sphinx_utils module"], "titleterms": {"modul": 0, "sphinx_util": 0}}) \ No newline at end of file diff --git a/tests/js/fixtures/titles/searchindex.js b/tests/js/fixtures/titles/searchindex.js index 9a229d060bf..f503bf35df9 100644 --- a/tests/js/fixtures/titles/searchindex.js +++ b/tests/js/fixtures/titles/searchindex.js @@ -1 +1 @@ -Search.setIndex({"alltitles": {"Main Page": [[0, null]], "Relevance": [[0, "relevance"], [1, null]]}, "docnames": ["index", "relevance"], "envversion": {"sphinx": 62, "sphinx.domains.c": 3, "sphinx.domains.changeset": 1, "sphinx.domains.citation": 1, "sphinx.domains.cpp": 9, "sphinx.domains.index": 1, "sphinx.domains.javascript": 3, "sphinx.domains.math": 2, "sphinx.domains.python": 4, "sphinx.domains.rst": 2, "sphinx.domains.std": 2}, "filenames": ["index.rst", "relevance.rst"], "indexentries": {"example (class in relevance)": [[0, "relevance.Example", false]], "module": [[0, "module-relevance", false]], "relevance": [[0, "module-relevance", false]], "relevance (relevance.example attribute)": [[0, "relevance.Example.relevance", false]]}, "objects": {"": [[0, 0, 0, "-", "relevance"]], "relevance": [[0, 1, 1, "", "Example"]], "relevance.Example": [[0, 2, 1, "", "relevance"]]}, "objnames": {"0": ["py", "module", "Python module"], "1": ["py", "class", "Python class"], "2": ["py", "attribute", "Python attribute"]}, "objtypes": {"0": "py:module", "1": "py:class", "2": "py:attribute"}, "terms": {"": [0, 1], "A": 1, "For": 1, "In": [0, 1], "against": 0, "also": 1, "an": 0, "answer": 0, "appear": 1, "ar": 1, "area": 0, "ask": 0, "attribut": 0, "built": 1, "can": [0, 1], "class": 0, "code": [0, 1], "consid": 1, "contain": 0, "context": 0, "corpu": 1, "could": 1, "demonstr": 0, "describ": 1, "detail": 1, "determin": 1, "docstr": 0, "document": [0, 1], "domain": 1, "engin": 0, "exampl": [0, 1], "extract": 0, "find": 0, "found": 0, "from": 0, "function": 1, "ha": 1, "handl": 0, "happen": 1, "head": 0, "help": 0, "highli": 1, "how": 0, "i": [0, 1], "improv": 0, "inform": 0, "intend": 0, "issu": 1, "itself": 1, "knowledg": 0, "languag": 1, "less": 1, "like": [0, 1], "match": 0, "mention": 1, "name": [0, 1], "object": 0, "one": 1, "onli": 1, "other": 0, "page": 1, "part": 1, "particular": 0, "printf": 1, "program": 1, "project": 0, "queri": [0, 1], "question": 0, "re": 0, "rel": 0, "research": 0, "result": 1, "sai": 0, "same": 1, "score": 0, "search": [0, 1], "seem": 0, "softwar": 1, "some": 1, "sphinx": 0, "straightforward": 1, "subject": 0, "subsect": 0, "term": [0, 1], "test": 0, "text": 0, "than": 1, "thei": 0, "them": 0, "thi": 0, "titl": 0, "user": [0, 1], "we": [0, 1], "when": 0, "whether": 1, "within": 0, "would": 1}, "titles": ["Main Page", "Relevance"], "titleterms": {"main": 0, "page": 0, "relev": [0, 1]}}) \ No newline at end of file +Search.setIndex({"alltitles": {"Main Page": [[0, null]], "Relevance": [[0, "relevance"], [1, null]]}, "docnames": ["index", "relevance"], "envversion": {"sphinx": 63, "sphinx.domains.c": 3, "sphinx.domains.changeset": 1, "sphinx.domains.citation": 1, "sphinx.domains.cpp": 9, "sphinx.domains.index": 1, "sphinx.domains.javascript": 3, "sphinx.domains.math": 2, "sphinx.domains.python": 4, "sphinx.domains.rst": 2, "sphinx.domains.std": 2}, "filenames": ["index.rst", "relevance.rst"], "indexentries": {"example (class in relevance)": [[0, "relevance.Example", false]], "module": [[0, "module-relevance", false]], "relevance": [[0, "module-relevance", false]], "relevance (relevance.example attribute)": [[0, "relevance.Example.relevance", false]]}, "objects": {"": [[0, 0, 0, "-", "relevance"]], "relevance": [[0, 1, 1, "", "Example"]], "relevance.Example": [[0, 2, 1, "", "relevance"]]}, "objnames": {"0": ["py", "module", "Python module"], "1": ["py", "class", "Python class"], "2": ["py", "attribute", "Python attribute"]}, "objtypes": {"0": "py:module", "1": "py:class", "2": "py:attribute"}, "terms": {"": [0, 1], "A": 1, "For": 1, "In": [0, 1], "against": 0, "also": 1, "an": 0, "answer": 0, "appear": 1, "ar": 1, "area": 0, "ask": 0, "attribut": 0, "built": 1, "can": [0, 1], "class": 0, "code": [0, 1], "consid": 1, "contain": 0, "context": 0, "corpu": 1, "could": 1, "demonstr": 0, "describ": 1, "detail": 1, "determin": 1, "docstr": 0, "document": [0, 1], "domain": 1, "engin": 0, "exampl": [0, 1], "extract": 0, "find": 0, "found": 0, "from": 0, "function": 1, "ha": 1, "handl": 0, "happen": 1, "head": 0, "help": 0, "highli": 1, "how": 0, "i": [0, 1], "improv": 0, "inform": 0, "intend": 0, "issu": 1, "itself": 1, "knowledg": 0, "languag": 1, "less": 1, "like": [0, 1], "match": 0, "mention": 1, "name": [0, 1], "object": 0, "one": 1, "onli": 1, "other": 0, "page": 1, "part": 1, "particular": 0, "printf": 1, "program": 1, "project": 0, "queri": [0, 1], "question": 0, "re": 0, "rel": 0, "research": 0, "result": 1, "sai": 0, "same": 1, "score": 0, "search": [0, 1], "seem": 0, "softwar": 1, "some": 1, "sphinx": 0, "straightforward": 1, "subject": 0, "subsect": 0, "term": [0, 1], "test": 0, "text": 0, "than": 1, "thei": 0, "them": 0, "thi": 0, "titl": 0, "user": [0, 1], "we": [0, 1], "when": 0, "whether": 1, "within": 0, "would": 1}, "titles": ["Main Page", "Relevance"], "titleterms": {"main": 0, "page": 0, "relev": [0, 1]}}) \ No newline at end of file diff --git a/tests/test_domains/test_domain_c.py b/tests/test_domains/test_domain_c.py index a8a92cb82ae..43a3e44cc68 100644 --- a/tests/test_domains/test_domain_c.py +++ b/tests/test_domains/test_domain_c.py @@ -771,7 +771,7 @@ def test_domain_c_build_intersphinx(tmp_path, app, status, warning): _var c:member 1 index.html#c.$ - ''')) # NoQA: W291 app.config.intersphinx_mapping = { - 'https://localhost/intersphinx/c/': str(inv_file), + 'local': ('https://localhost/intersphinx/c/', str(inv_file)), } app.config.intersphinx_cache_limit = 0 # load the inventory and check if it's done correctly diff --git a/tests/test_domains/test_domain_cpp.py b/tests/test_domains/test_domain_cpp.py index d8e612e28fa..0d8f81c656d 100644 --- a/tests/test_domains/test_domain_cpp.py +++ b/tests/test_domains/test_domain_cpp.py @@ -1424,7 +1424,7 @@ def test_domain_cpp_build_intersphinx(tmp_path, app, status, warning): _var cpp:member 1 index.html#_CPPv44$ - ''')) # NoQA: W291 app.config.intersphinx_mapping = { - 'https://localhost/intersphinx/cpp/': str(inv_file), + 'test': ('https://localhost/intersphinx/cpp/', str(inv_file)), } app.config.intersphinx_cache_limit = 0 # load the inventory and check if it's done correctly diff --git a/tests/test_extensions/test_ext_inheritance_diagram.py b/tests/test_extensions/test_ext_inheritance_diagram.py index 45a5ff0dda9..19423c2c5c1 100644 --- a/tests/test_extensions/test_ext_inheritance_diagram.py +++ b/tests/test_extensions/test_ext_inheritance_diagram.py @@ -154,7 +154,7 @@ def test_inheritance_diagram_png_html(tmp_path, app): inv_file = tmp_path / 'inventory' inv_file.write_bytes(external_inventory) app.config.intersphinx_mapping = { - 'https://example.org': str(inv_file), + 'example': ('https://example.org', str(inv_file)), } app.config.intersphinx_cache_limit = 0 normalize_intersphinx_mapping(app, app.config) diff --git a/tests/test_extensions/test_ext_intersphinx.py b/tests/test_extensions/test_ext_intersphinx.py index 599f7c7d1c2..8fd848a184d 100644 --- a/tests/test_extensions/test_ext_intersphinx.py +++ b/tests/test_extensions/test_ext_intersphinx.py @@ -1,6 +1,9 @@ """Test the intersphinx extension.""" +from __future__ import annotations + import http.server +from typing import TYPE_CHECKING from unittest import mock import pytest @@ -8,6 +11,7 @@ from sphinx import addnodes from sphinx.builders.html import INVENTORY_FILENAME +from sphinx.errors import ConfigError from sphinx.ext.intersphinx import ( fetch_inventory, inspect_main, @@ -26,6 +30,14 @@ ) from tests.utils import http_server +if TYPE_CHECKING: + from typing import NoReturn + + +class FakeList(list): + def __iter__(self) -> NoReturn: + raise NotImplementedError + def fake_node(domain, type, target, content, **attrs): contnode = nodes.emphasis(content, content) @@ -44,7 +56,8 @@ def reference_check(app, *args, **kwds): def set_config(app, mapping): - app.config.intersphinx_mapping = mapping + # copy *mapping* so that normalization does not alter it + app.config.intersphinx_mapping = mapping.copy() app.config.intersphinx_cache_limit = 0 app.config.intersphinx_disabled_reftypes = [] @@ -97,7 +110,7 @@ def test_missing_reference(tmp_path, app, status, warning): inv_file = tmp_path / 'inventory' inv_file.write_bytes(INVENTORY_V2) set_config(app, { - 'https://docs.python.org/': str(inv_file), + 'python': ('https://docs.python.org/', str(inv_file)), 'py3k': ('https://docs.python.org/py3k/', str(inv_file)), 'py3krel': ('py3k', str(inv_file)), # relative path 'py3krelparent': ('../../py3k', str(inv_file)), # relative path, parent dir @@ -175,7 +188,7 @@ def test_missing_reference_pydomain(tmp_path, app, status, warning): inv_file = tmp_path / 'inventory' inv_file.write_bytes(INVENTORY_V2) set_config(app, { - 'https://docs.python.org/': str(inv_file), + 'python': ('https://docs.python.org/', str(inv_file)), }) # load the inventory and check if it's done correctly @@ -274,7 +287,7 @@ def test_missing_reference_cppdomain(tmp_path, app, status, warning): inv_file = tmp_path / 'inventory' inv_file.write_bytes(INVENTORY_V2) set_config(app, { - 'https://docs.python.org/': str(inv_file), + 'python': ('https://docs.python.org/', str(inv_file)), }) # load the inventory and check if it's done correctly @@ -300,7 +313,7 @@ def test_missing_reference_jsdomain(tmp_path, app, status, warning): inv_file = tmp_path / 'inventory' inv_file.write_bytes(INVENTORY_V2) set_config(app, { - 'https://docs.python.org/': str(inv_file), + 'python': ('https://docs.python.org/', str(inv_file)), }) # load the inventory and check if it's done correctly @@ -386,7 +399,7 @@ def test_inventory_not_having_version(tmp_path, app, status, warning): inv_file = tmp_path / 'inventory' inv_file.write_bytes(INVENTORY_V2_NO_VERSION) set_config(app, { - 'https://docs.python.org/': str(inv_file), + 'python': ('https://docs.python.org/', str(inv_file)), }) # load the inventory and check if it's done correctly @@ -400,29 +413,59 @@ def test_inventory_not_having_version(tmp_path, app, status, warning): assert rn[0].astext() == 'Long Module desc' -def test_load_mappings_warnings(tmp_path, app, status, warning): - """ - load_mappings issues a warning if new-style mapping - identifiers are not string - """ - inv_file = tmp_path / 'inventory' - inv_file.write_bytes(INVENTORY_V2) - set_config(app, { - 'https://docs.python.org/': str(inv_file), - 'py3k': ('https://docs.python.org/py3k/', str(inv_file)), - 'repoze.workflow': ('https://docs.repoze.org/workflow/', str(inv_file)), - 'django-taggit': ('https://django-taggit.readthedocs.org/en/latest/', - str(inv_file)), - 12345: ('https://www.sphinx-doc.org/en/stable/', str(inv_file)), - }) - - # load the inventory and check if it's done correctly - normalize_intersphinx_mapping(app, app.config) - load_mappings(app) - warnings = warning.getvalue().splitlines() - assert len(warnings) == 2 - assert "The pre-Sphinx 1.0 'intersphinx_mapping' format is " in warnings[0] - assert 'intersphinx identifier 12345 is not string. Ignored' in warnings[1] +def test_normalize_intersphinx_mapping_warnings(app, warning): + """Check warnings in :func:`sphinx.ext.intersphinx.normalize_intersphinx_mapping`.""" + bad_intersphinx_mapping = { + # fmt: off + '': ('789.example', None), # invalid project name (value) + 12345: ('456.example', None), # invalid project name (type) + None: ('123.example', None), # invalid project name (type) + 'https://example/': None, # Sphinx 0.5 style value (None) + 'https://server/': 'inventory', # Sphinx 0.5 style value (str) + 'bad-dict-item': 0, # invalid dict item type + 'unpack-except-1': [0], # invalid dict item size (native ValueError) + 'unpack-except-2': FakeList(), # invalid dict item size (custom exception) + 'bad-uri-type-1': (123456789, None), # invalid target URI type + 'bad-uri-type-2': (None, None), # invalid target URI type + 'bad-uri-value': ('', None), # invalid target URI value + 'good': ('example.org', None), # duplicated target URI (good entry) + 'dedup-good': ('example.org', None), # duplicated target URI + 'bad-location-1': ('a.example', 1), # invalid inventory location (single input, bad type) + 'bad-location-2': ('b.example', ''), # invalid inventory location (single input, bad string) + 'bad-location-3': ('c.example', [2, 'x']), # invalid inventory location (sequence input, bad type) + 'bad-location-4': ('d.example', ['y', '']), # invalid inventory location (sequence input, bad string) + 'good-target-1': ('e.example', None), # valid inventory location (None) + 'good-target-2': ('f.example', ('x',)), # valid inventory location (sequence input) + # fmt: on + } + set_config(app, bad_intersphinx_mapping) + + # normalise the inventory and check if it's done correctly + with pytest.raises( + ConfigError, + match=r'^Invalid `intersphinx_mapping` configuration \(16 errors\).$', + ): + normalize_intersphinx_mapping(app, app.config) + warnings = strip_colors(warning.getvalue()).splitlines() + assert len(warnings) == len(bad_intersphinx_mapping) - 3 + assert warnings == [ + "ERROR: Invalid intersphinx project identifier `''` in intersphinx_mapping. Project identifiers must be non-empty strings.", + "ERROR: Invalid intersphinx project identifier `12345` in intersphinx_mapping. Project identifiers must be non-empty strings.", + "ERROR: Invalid intersphinx project identifier `None` in intersphinx_mapping. Project identifiers must be non-empty strings.", + "ERROR: Invalid value `None` in intersphinx_mapping['https://example/']. Expected a two-element tuple or list.", + "ERROR: Invalid value `'inventory'` in intersphinx_mapping['https://server/']. Expected a two-element tuple or list.", + "ERROR: Invalid value `0` in intersphinx_mapping['bad-dict-item']. Expected a two-element tuple or list.", + "ERROR: Invalid value `[0]` in intersphinx_mapping['unpack-except-1']. Values must be a (target URI, inventory locations) pair.", + "ERROR: Invalid value `[]` in intersphinx_mapping['unpack-except-2']. Values must be a (target URI, inventory locations) pair.", + "ERROR: Invalid target URI value `123456789` in intersphinx_mapping['bad-uri-type-1'][0]. Target URIs must be unique non-empty strings.", + "ERROR: Invalid target URI value `None` in intersphinx_mapping['bad-uri-type-2'][0]. Target URIs must be unique non-empty strings.", + "ERROR: Invalid target URI value `''` in intersphinx_mapping['bad-uri-value'][0]. Target URIs must be unique non-empty strings.", + "ERROR: Invalid target URI value `'example.org'` in intersphinx_mapping['dedup-good'][0]. Target URIs must be unique (other instance in intersphinx_mapping['good']).", + "ERROR: Invalid inventory location value `1` in intersphinx_mapping['bad-location-1'][1]. Inventory locations must be non-empty strings or None.", + "ERROR: Invalid inventory location value `''` in intersphinx_mapping['bad-location-2'][1]. Inventory locations must be non-empty strings or None.", + "ERROR: Invalid inventory location value `2` in intersphinx_mapping['bad-location-3'][1]. Inventory locations must be non-empty strings or None.", + "ERROR: Invalid inventory location value `''` in intersphinx_mapping['bad-location-4'][1]. Inventory locations must be non-empty strings or None." + ] def test_load_mappings_fallback(tmp_path, app, status, warning):