diff --git a/.ruff.toml b/.ruff.toml index 07c8fc60a8d..acfccb758fe 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -395,7 +395,7 @@ select = [ "sphinx/environment/adapters/toctree.py" = ["B026"] # whitelist ``print`` for stdout messages -"sphinx/ext/intersphinx.py" = ["T201"] +"sphinx/ext/intersphinx/_cli.py" = ["T201"] # whitelist ``print`` for stdout messages "sphinx/testing/fixtures.py" = ["T201"] @@ -503,7 +503,10 @@ exclude = [ "sphinx/ext/mathjax.py", "sphinx/ext/doctest.py", "sphinx/ext/autosectionlabel.py", - "sphinx/ext/intersphinx.py", + "sphinx/ext/intersphinx/__init__.py", + "sphinx/ext/intersphinx/_cli.py", + "sphinx/ext/intersphinx/_load.py", + "sphinx/ext/intersphinx/_resolve.py", "sphinx/ext/duration.py", "sphinx/ext/imgconverter.py", "sphinx/ext/imgmath.py", diff --git a/pyproject.toml b/pyproject.toml index 8e8dac4a4f8..4829f1436bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -263,7 +263,7 @@ module = [ "sphinx.ext.doctest", "sphinx.ext.graphviz", "sphinx.ext.inheritance_diagram", - "sphinx.ext.intersphinx", + "sphinx.ext.intersphinx._load", "sphinx.ext.napoleon.docstring", "sphinx.highlighting", "sphinx.jinja2glue", diff --git a/sphinx/ext/intersphinx/__init__.py b/sphinx/ext/intersphinx/__init__.py new file mode 100644 index 00000000000..bcfcf0e27dc --- /dev/null +++ b/sphinx/ext/intersphinx/__init__.py @@ -0,0 +1,81 @@ +"""Insert links to objects documented in remote Sphinx documentation. + +This works as follows: + +* Each Sphinx HTML build creates a file named "objects.inv" that contains a + mapping from object names to URIs relative to the HTML set's root. + +* Projects using the Intersphinx extension can specify links to such mapping + files in the `intersphinx_mapping` config value. The mapping will then be + used to resolve otherwise missing references to objects into links to the + other documentation. + +* By default, the mapping file is assumed to be at the same location as the + rest of the documentation; however, the location of the mapping file can + also be specified individually, e.g. if the docs should be buildable + without Internet access. +""" + +from __future__ import annotations + +__all__ = ( + 'InventoryAdapter', + 'fetch_inventory', + 'fetch_inventory_group', + 'load_mappings', + 'normalize_intersphinx_mapping', + 'IntersphinxRoleResolver', + 'inventory_exists', + 'install_dispatcher', + 'resolve_reference_in_inventory', + 'resolve_reference_any_inventory', + 'resolve_reference_detect_inventory', + 'missing_reference', + 'IntersphinxDispatcher', + 'IntersphinxRole', + 'inspect_main', +) + +from typing import TYPE_CHECKING + +import sphinx +from sphinx.ext.intersphinx._cli import inspect_main +from sphinx.ext.intersphinx._load import ( + fetch_inventory, + fetch_inventory_group, + load_mappings, + normalize_intersphinx_mapping, +) +from sphinx.ext.intersphinx._resolve import ( + IntersphinxDispatcher, + IntersphinxRole, + IntersphinxRoleResolver, + install_dispatcher, + inventory_exists, + missing_reference, + resolve_reference_any_inventory, + resolve_reference_detect_inventory, + resolve_reference_in_inventory, +) +from sphinx.ext.intersphinx._shared import InventoryAdapter + +if TYPE_CHECKING: + from sphinx.application import Sphinx + from sphinx.util.typing import ExtensionMetadata + + +def setup(app: Sphinx) -> ExtensionMetadata: + app.add_config_value('intersphinx_mapping', {}, 'env') + app.add_config_value('intersphinx_cache_limit', 5, '') + app.add_config_value('intersphinx_timeout', None, '') + app.add_config_value('intersphinx_disabled_reftypes', ['std:doc'], 'env') + app.connect('config-inited', normalize_intersphinx_mapping, priority=800) + app.connect('builder-inited', load_mappings) + app.connect('source-read', install_dispatcher) + app.connect('missing-reference', missing_reference) + app.add_post_transform(IntersphinxRoleResolver) + return { + 'version': sphinx.__display_version__, + 'env_version': 1, + 'parallel_read_safe': True, + } diff --git a/sphinx/ext/intersphinx/__main__.py b/sphinx/ext/intersphinx/__main__.py new file mode 100644 index 00000000000..9b788d2362c --- /dev/null +++ b/sphinx/ext/intersphinx/__main__.py @@ -0,0 +1,10 @@ +"""Command line interface for the intersphinx extension.""" + +import logging as _logging +import sys + +from sphinx.ext.intersphinx import inspect_main + +_logging.basicConfig() + +raise SystemExit(inspect_main(sys.argv[1:])) diff --git a/sphinx/ext/intersphinx/_cli.py b/sphinx/ext/intersphinx/_cli.py new file mode 100644 index 00000000000..8773caa172f --- /dev/null +++ b/sphinx/ext/intersphinx/_cli.py @@ -0,0 +1,44 @@ +"""This module provides contains the code for intersphinx command-line utilities.""" + +from __future__ import annotations + +import sys + +from sphinx.ext.intersphinx._load import fetch_inventory + + +def inspect_main(argv: list[str], /) -> int: + """Debug functionality to print out an inventory""" + if len(argv) < 1: + print("Print out an inventory file.\n" + "Error: must specify local path or URL to an inventory file.", + file=sys.stderr) + return 1 + + class MockConfig: + intersphinx_timeout: int | None = None + tls_verify = False + tls_cacerts: str | dict[str, str] | None = None + user_agent: str = '' + + class MockApp: + srcdir = '' + config = MockConfig() + + try: + filename = argv[0] + inv_data = fetch_inventory(MockApp(), '', filename) # type: ignore[arg-type] + for key in sorted(inv_data or {}): + print(key) + inv_entries = sorted(inv_data[key].items()) + for entry, (_proj, _ver, url_path, display_name) in inv_entries: + display_name = display_name * (display_name != '-') + print(f' {entry:<40} {display_name:<40}: {url_path}') + except ValueError as exc: + print(exc.args[0] % exc.args[1:], file=sys.stderr) + return 1 + except Exception as exc: + print(f'Unknown error: {exc!r}', file=sys.stderr) + return 1 + else: + return 0 diff --git a/sphinx/ext/intersphinx/_load.py b/sphinx/ext/intersphinx/_load.py new file mode 100644 index 00000000000..30375ccb693 --- /dev/null +++ b/sphinx/ext/intersphinx/_load.py @@ -0,0 +1,251 @@ +"""This module contains the code for loading intersphinx inventories.""" + +from __future__ import annotations + +import concurrent.futures +import functools +import posixpath +import time +from os import path +from typing import TYPE_CHECKING +from urllib.parse import urlsplit, urlunsplit + +from sphinx.builders.html import INVENTORY_FILENAME +from sphinx.ext.intersphinx._shared import LOGGER, InventoryAdapter +from sphinx.locale import __ +from sphinx.util import requests +from sphinx.util.inventory import InventoryFile + +if TYPE_CHECKING: + from typing import IO + + from sphinx.application import Sphinx + from sphinx.config import Config + from sphinx.ext.intersphinx._shared import InventoryCacheEntry + from sphinx.util.typing import Inventory + + +def _strip_basic_auth(url: str) -> str: + """Returns *url* with basic auth credentials removed. Also returns the + basic auth username and password if they're present in *url*. + + E.g.: https://user:pass@example.com => https://example.com + + *url* need not include basic auth credentials. + + :param url: url which may or may not contain basic auth credentials + :type url: ``str`` + + :return: *url* with any basic auth creds removed + :rtype: ``str`` + """ + frags = list(urlsplit(url)) + # swap out "user[:pass]@hostname" for "hostname" + if '@' in frags[1]: + frags[1] = frags[1].split('@')[1] + return urlunsplit(frags) + + +def _read_from_url(url: str, *, config: Config) -> IO: + """Reads data from *url* with an HTTP *GET*. + + This function supports fetching from resources which use basic HTTP auth as + laid out by RFC1738 § 3.1. See § 5 for grammar definitions for URLs. + + .. seealso: + + https://www.ietf.org/rfc/rfc1738.txt + + :param url: URL of an HTTP resource + :type url: ``str`` + + :return: data read from resource described by *url* + :rtype: ``file``-like object + """ + r = requests.get(url, stream=True, timeout=config.intersphinx_timeout, + _user_agent=config.user_agent, + _tls_info=(config.tls_verify, config.tls_cacerts)) + r.raise_for_status() + r.raw.url = r.url + # decode content-body based on the header. + # ref: https://github.com/psf/requests/issues/2155 + r.raw.read = functools.partial(r.raw.read, decode_content=True) + return r.raw + + +def _get_safe_url(url: str) -> str: + """Gets version of *url* with basic auth passwords obscured. This function + returns results suitable for printing and logging. + + E.g.: https://user:12345@example.com => https://user@example.com + + :param url: a url + :type url: ``str`` + + :return: *url* with password removed + :rtype: ``str`` + """ + parts = urlsplit(url) + if parts.username is None: + return url + else: + frags = list(parts) + if parts.port: + frags[1] = f'{parts.username}@{parts.hostname}:{parts.port}' + else: + frags[1] = f'{parts.username}@{parts.hostname}' + + return urlunsplit(frags) + + +def fetch_inventory(app: Sphinx, uri: str, 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 + if '://' in uri: + # case: inv URI points to remote resource; strip any existing auth + uri = _strip_basic_auth(uri) + try: + if '://' in inv: + f = _read_from_url(inv, config=app.config) + else: + f = open(path.join(app.srcdir, inv), 'rb') # NoQA: SIM115 + except Exception as err: + err.args = ('intersphinx inventory %r not fetchable due to %s: %s', + inv, err.__class__, str(err)) + raise + try: + if hasattr(f, 'url'): + newinv = f.url + if inv != newinv: + LOGGER.info(__('intersphinx inventory has moved: %s -> %s'), inv, newinv) + + if uri in (inv, path.dirname(inv), path.dirname(inv) + '/'): + uri = path.dirname(newinv) + with f: + try: + invdata = InventoryFile.load(f, uri, posixpath.join) + except ValueError as exc: + raise ValueError('unknown or unsupported inventory version: %r' % exc) from exc + except Exception as err: + err.args = ('intersphinx inventory %r not readable due to %s: %s', + inv, err.__class__.__name__, str(err)) + raise + else: + return invdata + + +def fetch_inventory_group( + name: str | None, + uri: str, + invs: tuple[str | None, ...], + cache: dict[str, InventoryCacheEntry], + app: Sphinx, + now: int, +) -> bool: + cache_time = now - app.config.intersphinx_cache_limit * 86400 + failures = [] + try: + for inv in invs: + if not inv: + inv = posixpath.join(uri, INVENTORY_FILENAME) + # decide whether the inventory must be read: always read local + # 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) + try: + invdata = fetch_inventory(app, uri, inv) + except Exception as err: + failures.append(err.args) + continue + if invdata: + cache[uri] = name, now, invdata + return True + return False + finally: + if failures == []: + pass + elif len(failures) < len(invs): + LOGGER.info(__("encountered some issues with some of the inventories," + " but they had working alternatives:")) + for fail in failures: + LOGGER.info(*fail) + else: + issues = '\n'.join(f[0] % f[1:] for f in failures) + LOGGER.warning(__("failed to reach any of the inventories " + "with the following issues:") + "\n" + issues) + + +def load_mappings(app: Sphinx) -> None: + """Load all intersphinx mappings into the environment.""" + now = int(time.time()) + inventories = InventoryAdapter(app.builder.env) + intersphinx_cache: dict[str, InventoryCacheEntry] = inventories.cache + + 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(): + futures.append(pool.submit( + fetch_inventory_group, name, uri, invs, intersphinx_cache, app, now, + )) + updated = [f.result() for f in concurrent.futures.as_completed(futures)] + + if any(updated): + inventories.clear() + + # Duplicate values in different inventories will shadow each + # other; which one will override which can vary between builds + # since they are specified using an unordered dict. To make + # it more consistent, we sort the named inventories and then + # add the unnamed inventories last. This means that the + # unnamed inventories will shadow the named ones but the named + # ones can still be accessed when the name is specified. + named_vals = [] + unnamed_vals = [] + for name, _expiry, invdata in intersphinx_cache.values(): + if name: + named_vals.append((name, invdata)) + else: + unnamed_vals.append((name, invdata)) + for name, invdata in sorted(named_vals) + unnamed_vals: + if name: + inventories.named_inventory[name] = invdata + for type, objects in invdata.items(): + inventories.main_inventory.setdefault(type, {}).update(objects) + + +def normalize_intersphinx_mapping(app: Sphinx, config: Config) -> None: + for key, value in config.intersphinx_mapping.copy().items(): + 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 + 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 + ) + LOGGER.warning(msg) + + 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) diff --git a/sphinx/ext/intersphinx.py b/sphinx/ext/intersphinx/_resolve.py similarity index 59% rename from sphinx/ext/intersphinx.py rename to sphinx/ext/intersphinx/_resolve.py index a8a2cf13161..bf2ea9e9e21 100644 --- a/sphinx/ext/intersphinx.py +++ b/sphinx/ext/intersphinx/_resolve.py @@ -1,295 +1,34 @@ -"""Insert links to objects documented in remote Sphinx documentation. - -This works as follows: - -* Each Sphinx HTML build creates a file named "objects.inv" that contains a - mapping from object names to URIs relative to the HTML set's root. - -* Projects using the Intersphinx extension can specify links to such mapping - files in the `intersphinx_mapping` config value. The mapping will then be - used to resolve otherwise missing references to objects into links to the - other documentation. - -* By default, the mapping file is assumed to be at the same location as the - rest of the documentation; however, the location of the mapping file can - also be specified individually, e.g. if the docs should be buildable - without Internet access. -""" +"""This module provides logic for resolving references to intersphinx targets.""" from __future__ import annotations -import concurrent.futures -import functools import posixpath import re -import sys -import time -from os import path from typing import TYPE_CHECKING, cast -from urllib.parse import urlsplit, urlunsplit from docutils import nodes from docutils.utils import relative_path -import sphinx from sphinx.addnodes import pending_xref -from sphinx.builders.html import INVENTORY_FILENAME from sphinx.deprecation import _deprecation_warning from sphinx.errors import ExtensionError +from sphinx.ext.intersphinx._shared import LOGGER, InventoryAdapter from sphinx.locale import _, __ from sphinx.transforms.post_transforms import ReferencesResolver -from sphinx.util import logging, requests from sphinx.util.docutils import CustomReSTDispatcher, SphinxRole -from sphinx.util.inventory import InventoryFile if TYPE_CHECKING: from collections.abc import Iterable from types import ModuleType - from typing import IO, Any, Union + from typing import Any from docutils.nodes import Node, TextElement, system_message from docutils.utils import Reporter from sphinx.application import Sphinx - from sphinx.config import Config from sphinx.domains import Domain from sphinx.environment import BuildEnvironment - from sphinx.util.typing import ExtensionMetadata, Inventory, InventoryItem, RoleFunction - - InventoryCacheEntry = tuple[Union[str, None], int, Inventory] - -logger = logging.getLogger(__name__) - - -class InventoryAdapter: - """Inventory adapter for environment""" - - def __init__(self, env: BuildEnvironment) -> None: - self.env = env - - if not hasattr(env, 'intersphinx_cache'): - # initial storage when fetching inventories before processing - self.env.intersphinx_cache = {} # type: ignore[attr-defined] - - self.env.intersphinx_inventory = {} # type: ignore[attr-defined] - self.env.intersphinx_named_inventory = {} # type: ignore[attr-defined] - - @property - def cache(self) -> dict[str, 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 - """ - return self.env.intersphinx_cache # type: ignore[attr-defined] - - @property - def main_inventory(self) -> Inventory: - return self.env.intersphinx_inventory # type: ignore[attr-defined] - - @property - def named_inventory(self) -> dict[str, Inventory]: - return self.env.intersphinx_named_inventory # type: ignore[attr-defined] - - def clear(self) -> None: - self.env.intersphinx_inventory.clear() # type: ignore[attr-defined] - self.env.intersphinx_named_inventory.clear() # type: ignore[attr-defined] - - -def _strip_basic_auth(url: str) -> str: - """Returns *url* with basic auth credentials removed. Also returns the - basic auth username and password if they're present in *url*. - - E.g.: https://user:pass@example.com => https://example.com - - *url* need not include basic auth credentials. - - :param url: url which may or may not contain basic auth credentials - :type url: ``str`` - - :return: *url* with any basic auth creds removed - :rtype: ``str`` - """ - frags = list(urlsplit(url)) - # swap out "user[:pass]@hostname" for "hostname" - if '@' in frags[1]: - frags[1] = frags[1].split('@')[1] - return urlunsplit(frags) - - -def _read_from_url(url: str, *, config: Config) -> IO: - """Reads data from *url* with an HTTP *GET*. - - This function supports fetching from resources which use basic HTTP auth as - laid out by RFC1738 § 3.1. See § 5 for grammar definitions for URLs. - - .. seealso: - - https://www.ietf.org/rfc/rfc1738.txt - - :param url: URL of an HTTP resource - :type url: ``str`` - - :return: data read from resource described by *url* - :rtype: ``file``-like object - """ - r = requests.get(url, stream=True, timeout=config.intersphinx_timeout, - _user_agent=config.user_agent, - _tls_info=(config.tls_verify, config.tls_cacerts)) - r.raise_for_status() - r.raw.url = r.url - # decode content-body based on the header. - # ref: https://github.com/psf/requests/issues/2155 - r.raw.read = functools.partial(r.raw.read, decode_content=True) - return r.raw - - -def _get_safe_url(url: str) -> str: - """Gets version of *url* with basic auth passwords obscured. This function - returns results suitable for printing and logging. - - E.g.: https://user:12345@example.com => https://user@example.com - - :param url: a url - :type url: ``str`` - - :return: *url* with password removed - :rtype: ``str`` - """ - parts = urlsplit(url) - if parts.username is None: - return url - else: - frags = list(parts) - if parts.port: - frags[1] = f'{parts.username}@{parts.hostname}:{parts.port}' - else: - frags[1] = f'{parts.username}@{parts.hostname}' - - return urlunsplit(frags) - - -def fetch_inventory(app: Sphinx, uri: str, 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 - if '://' in uri: - # case: inv URI points to remote resource; strip any existing auth - uri = _strip_basic_auth(uri) - try: - if '://' in inv: - f = _read_from_url(inv, config=app.config) - else: - f = open(path.join(app.srcdir, inv), 'rb') # NoQA: SIM115 - except Exception as err: - err.args = ('intersphinx inventory %r not fetchable due to %s: %s', - inv, err.__class__, str(err)) - raise - try: - if hasattr(f, 'url'): - newinv = f.url - if inv != newinv: - logger.info(__('intersphinx inventory has moved: %s -> %s'), inv, newinv) - - if uri in (inv, path.dirname(inv), path.dirname(inv) + '/'): - uri = path.dirname(newinv) - with f: - try: - invdata = InventoryFile.load(f, uri, posixpath.join) - except ValueError as exc: - raise ValueError('unknown or unsupported inventory version: %r' % exc) from exc - except Exception as err: - err.args = ('intersphinx inventory %r not readable due to %s: %s', - inv, err.__class__.__name__, str(err)) - raise - else: - return invdata - - -def fetch_inventory_group( - name: str | None, - uri: str, - invs: tuple[str | None, ...], - cache: dict[str, InventoryCacheEntry], - app: Sphinx, - now: int, -) -> bool: - cache_time = now - app.config.intersphinx_cache_limit * 86400 - failures = [] - try: - for inv in invs: - if not inv: - inv = posixpath.join(uri, INVENTORY_FILENAME) - # decide whether the inventory must be read: always read local - # 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) - try: - invdata = fetch_inventory(app, uri, inv) - except Exception as err: - failures.append(err.args) - continue - if invdata: - cache[uri] = name, now, invdata - return True - return False - finally: - if failures == []: - pass - elif len(failures) < len(invs): - logger.info(__("encountered some issues with some of the inventories," - " but they had working alternatives:")) - for fail in failures: - logger.info(*fail) - else: - issues = '\n'.join(f[0] % f[1:] for f in failures) - logger.warning(__("failed to reach any of the inventories " - "with the following issues:") + "\n" + issues) - - -def load_mappings(app: Sphinx) -> None: - """Load all intersphinx mappings into the environment.""" - now = int(time.time()) - inventories = InventoryAdapter(app.builder.env) - intersphinx_cache: dict[str, InventoryCacheEntry] = inventories.cache - - 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(): - futures.append(pool.submit( - fetch_inventory_group, name, uri, invs, intersphinx_cache, app, now, - )) - updated = [f.result() for f in concurrent.futures.as_completed(futures)] - - if any(updated): - inventories.clear() - - # Duplicate values in different inventories will shadow each - # other; which one will override which can vary between builds - # since they are specified using an unordered dict. To make - # it more consistent, we sort the named inventories and then - # add the unnamed inventories last. This means that the - # unnamed inventories will shadow the named ones but the named - # ones can still be accessed when the name is specified. - named_vals = [] - unnamed_vals = [] - for name, _expiry, invdata in intersphinx_cache.values(): - if name: - named_vals.append((name, invdata)) - else: - unnamed_vals.append((name, invdata)) - for name, invdata in sorted(named_vals) + unnamed_vals: - if name: - inventories.named_inventory[name] = invdata - for type, objects in invdata.items(): - inventories.main_inventory.setdefault(type, {}).update(objects) + from sphinx.util.typing import Inventory, InventoryItem, RoleFunction def _create_element_from_result(domain: Domain, inv_name: str | None, @@ -663,7 +402,7 @@ def _get_domain_role(self, name: str) -> tuple[str | None, str | None]: return None, None def _emit_warning(self, msg: str, /, *args: Any) -> None: - logger.warning( + LOGGER.warning( msg, *args, type='intersphinx', @@ -749,7 +488,7 @@ def run(self, **kwargs: Any) -> None: typ = node['reftype'] msg = (__('external %s:%s reference target not found: %s') % (node['refdomain'], typ, node['reftarget'])) - logger.warning(msg, location=node, type='ref', subtype=typ) + LOGGER.warning(msg, location=node, type='ref', subtype=typ) node.replace_self(contnode) else: node.replace_self(newnode) @@ -763,97 +502,3 @@ def install_dispatcher(app: Sphinx, docname: str, source: list[str]) -> None: """ dispatcher = IntersphinxDispatcher() dispatcher.enable() - - -def normalize_intersphinx_mapping(app: Sphinx, config: Config) -> None: - for key, value in config.intersphinx_mapping.copy().items(): - 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 - 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 - ) - logger.warning(msg) - - 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) - - -def setup(app: Sphinx) -> ExtensionMetadata: - app.add_config_value('intersphinx_mapping', {}, 'env') - app.add_config_value('intersphinx_cache_limit', 5, '') - app.add_config_value('intersphinx_timeout', None, '') - app.add_config_value('intersphinx_disabled_reftypes', ['std:doc'], 'env') - app.connect('config-inited', normalize_intersphinx_mapping, priority=800) - app.connect('builder-inited', load_mappings) - app.connect('source-read', install_dispatcher) - app.connect('missing-reference', missing_reference) - app.add_post_transform(IntersphinxRoleResolver) - return { - 'version': sphinx.__display_version__, - 'env_version': 1, - 'parallel_read_safe': True, - } - - -def inspect_main(argv: list[str], /) -> int: - """Debug functionality to print out an inventory""" - if len(argv) < 1: - print("Print out an inventory file.\n" - "Error: must specify local path or URL to an inventory file.", - file=sys.stderr) - return 1 - - class MockConfig: - intersphinx_timeout: int | None = None - tls_verify = False - tls_cacerts: str | dict[str, str] | None = None - user_agent: str = '' - - class MockApp: - srcdir = '' - config = MockConfig() - - try: - filename = argv[0] - inv_data = fetch_inventory(MockApp(), '', filename) # type: ignore[arg-type] - for key in sorted(inv_data or {}): - print(key) - inv_entries = sorted(inv_data[key].items()) - for entry, (_proj, _ver, url_path, display_name) in inv_entries: - display_name = display_name * (display_name != '-') - print(f' {entry:<40} {display_name:<40}: {url_path}') - except ValueError as exc: - print(exc.args[0] % exc.args[1:], file=sys.stderr) - return 1 - except Exception as exc: - print(f'Unknown error: {exc!r}', file=sys.stderr) - return 1 - else: - return 0 - - -if __name__ == '__main__': - import logging as _logging - _logging.basicConfig() - - raise SystemExit(inspect_main(sys.argv[1:])) diff --git a/sphinx/ext/intersphinx/_shared.py b/sphinx/ext/intersphinx/_shared.py new file mode 100644 index 00000000000..f2f52444b99 --- /dev/null +++ b/sphinx/ext/intersphinx/_shared.py @@ -0,0 +1,53 @@ +"""This module contains code shared between intersphinx modules.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final, Union + +from sphinx.util import logging + +if TYPE_CHECKING: + from sphinx.environment import BuildEnvironment + from sphinx.util.typing import Inventory + + InventoryCacheEntry = tuple[Union[str, None], int, Inventory] + +LOGGER: Final[logging.SphinxLoggerAdapter] = logging.getLogger('sphinx.ext.intersphinx') + + +class InventoryAdapter: + """Inventory adapter for environment""" + + def __init__(self, env: BuildEnvironment) -> None: + self.env = env + + if not hasattr(env, 'intersphinx_cache'): + # initial storage when fetching inventories before processing + self.env.intersphinx_cache = {} # type: ignore[attr-defined] + + self.env.intersphinx_inventory = {} # type: ignore[attr-defined] + self.env.intersphinx_named_inventory = {} # type: ignore[attr-defined] + + @property + def cache(self) -> dict[str, 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 + """ + return self.env.intersphinx_cache # type: ignore[attr-defined] + + @property + def main_inventory(self) -> Inventory: + return self.env.intersphinx_inventory # type: ignore[attr-defined] + + @property + def named_inventory(self) -> dict[str, Inventory]: + return self.env.intersphinx_named_inventory # type: ignore[attr-defined] + + def clear(self) -> None: + self.env.intersphinx_inventory.clear() # type: ignore[attr-defined] + self.env.intersphinx_named_inventory.clear() # type: ignore[attr-defined] diff --git a/tests/test_extensions/test_ext_intersphinx.py b/tests/test_extensions/test_ext_intersphinx.py index 53795183dd2..c9d53787b57 100644 --- a/tests/test_extensions/test_ext_intersphinx.py +++ b/tests/test_extensions/test_ext_intersphinx.py @@ -9,8 +9,6 @@ from sphinx import addnodes from sphinx.builders.html import INVENTORY_FILENAME from sphinx.ext.intersphinx import ( - _get_safe_url, - _strip_basic_auth, fetch_inventory, inspect_main, load_mappings, @@ -18,6 +16,7 @@ normalize_intersphinx_mapping, ) from sphinx.ext.intersphinx import setup as intersphinx_setup +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 @@ -46,8 +45,8 @@ def set_config(app, mapping): app.config.intersphinx_disabled_reftypes = [] -@mock.patch('sphinx.ext.intersphinx.InventoryFile') -@mock.patch('sphinx.ext.intersphinx._read_from_url') +@mock.patch('sphinx.ext.intersphinx._load.InventoryFile') +@mock.patch('sphinx.ext.intersphinx._load._read_from_url') def test_fetch_inventory_redirection(_read_from_url, InventoryFile, app, status, warning): # NoQA: PT019 intersphinx_setup(app) _read_from_url().readline.return_value = b'# Sphinx inventory version 2'