From e5131ba1bd83a78cb43c2ff5f8d10ba1ca084014 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+AA-Turner@users.noreply.github.com> Date: Fri, 17 Jan 2025 00:41:11 +0000 Subject: [PATCH] intersphinx: Create an ``_InventoryItem`` type (#13248) --- sphinx/ext/intersphinx/_cli.py | 5 +- sphinx/ext/intersphinx/_resolve.py | 19 +-- sphinx/util/inventory.py | 110 ++++++++++++++++-- sphinx/util/typing.py | 9 +- tests/test_builders/test_build_dirhtml.py | 39 ++++--- tests/test_builders/test_build_html.py | 52 ++++----- tests/test_extensions/test_ext_intersphinx.py | 11 +- .../test_ext_intersphinx_cache.py | 12 +- tests/test_util/test_util_inventory.py | 60 +++++----- 9 files changed, 211 insertions(+), 106 deletions(-) diff --git a/sphinx/ext/intersphinx/_cli.py b/sphinx/ext/intersphinx/_cli.py index c87a53f84cf..0fa86c2298a 100644 --- a/sphinx/ext/intersphinx/_cli.py +++ b/sphinx/ext/intersphinx/_cli.py @@ -37,9 +37,10 @@ def inspect_main(argv: list[str], /) -> int: 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: + for entry, inv_item in inv_entries: + display_name = inv_item.display_name display_name = display_name * (display_name != '-') - print(f' {entry:<40} {display_name:<40}: {url_path}') + print(f' {entry:<40} {display_name:<40}: {inv_item.uri}') except ValueError as exc: print(exc.args[0] % exc.args[1:], file=sys.stderr) return 1 diff --git a/sphinx/ext/intersphinx/_resolve.py b/sphinx/ext/intersphinx/_resolve.py index 071e4d91cc9..8a1bf27bac4 100644 --- a/sphinx/ext/intersphinx/_resolve.py +++ b/sphinx/ext/intersphinx/_resolve.py @@ -30,30 +30,33 @@ from sphinx.domains._domains_container import _DomainsContainer from sphinx.environment import BuildEnvironment from sphinx.ext.intersphinx._shared import InventoryName - from sphinx.util.typing import Inventory, InventoryItem, RoleFunction + from sphinx.util.inventory import _InventoryItem + from sphinx.util.typing import Inventory, RoleFunction def _create_element_from_result( domain_name: str, inv_name: InventoryName | None, - data: InventoryItem, + inv_item: _InventoryItem, node: pending_xref, contnode: TextElement, ) -> nodes.reference: - proj, version, uri, dispname = data + uri = inv_item.uri if '://' not in uri and node.get('refdoc'): # get correct path in case of subdirectories uri = (_relative_path(Path(), Path(node['refdoc']).parent) / uri).as_posix() - if version: - reftitle = _('(in %s v%s)') % (proj, version) + if inv_item.project_version: + reftitle = _('(in %s v%s)') % (inv_item.project_name, inv_item.project_version) else: - reftitle = _('(in %s)') % (proj,) + reftitle = _('(in %s)') % (inv_item.project_name,) newnode = nodes.reference('', '', internal=False, refuri=uri, reftitle=reftitle) if node.get('refexplicit'): # use whatever title was given newnode.append(contnode) - elif dispname == '-' or (domain_name == 'std' and node['reftype'] == 'keyword'): + elif inv_item.display_name == '-' or ( + domain_name == 'std' and node['reftype'] == 'keyword' + ): # use whatever title was given, but strip prefix title = contnode.astext() if inv_name is not None and title.startswith(inv_name + ':'): @@ -66,7 +69,7 @@ def _create_element_from_result( newnode.append(contnode) else: # else use the given display name (used for :ref:) - newnode.append(contnode.__class__(dispname, dispname)) + newnode.append(contnode.__class__(inv_item.display_name, inv_item.display_name)) return newnode diff --git a/sphinx/util/inventory.py b/sphinx/util/inventory.py index 9da94f85d4b..d2f3594100f 100644 --- a/sphinx/util/inventory.py +++ b/sphinx/util/inventory.py @@ -4,9 +4,11 @@ import posixpath import re +import warnings import zlib from typing import TYPE_CHECKING +from sphinx.deprecation import RemovedInSphinx10Warning from sphinx.locale import __ from sphinx.util import logging @@ -15,12 +17,12 @@ if TYPE_CHECKING: import os - from collections.abc import Callable, Sequence - from typing import Protocol + from collections.abc import Callable, Iterator, Sequence + from typing import NoReturn, Protocol from sphinx.builders import Builder from sphinx.environment import BuildEnvironment - from sphinx.util.typing import Inventory, InventoryItem + from sphinx.util.typing import Inventory # Readable file stream for inventory loading class _SupportsRead(Protocol): @@ -82,8 +84,12 @@ def _loads_v1(cls, lines: Sequence[str], *, uri: str) -> Inventory: else: item_type = f'py:{item_type}' location += f'#{name}' - inv_item: InventoryItem = projname, version, location, '-' - invdata.setdefault(item_type, {})[name] = inv_item + invdata.setdefault(item_type, {})[name] = _InventoryItem( + project_name=projname, + project_version=version, + uri=location, + display_name='-', + ) return invdata @classmethod @@ -148,8 +154,12 @@ def _loads_v2(cls, inv_data: bytes, *, uri: str) -> Inventory: if location.endswith('$'): location = location[:-1] + name location = posixpath.join(uri, location) - inv_item: InventoryItem = projname, version, location, dispname - invdata.setdefault(type, {})[name] = inv_item + invdata.setdefault(type, {})[name] = _InventoryItem( + project_name=projname, + project_version=version, + uri=location, + display_name=dispname, + ) for ambiguity in actual_ambiguities: logger.info( __('inventory <%s> contains multiple definitions for %s'), @@ -194,3 +204,89 @@ def escape(string: str) -> str: entry = f'{fullname} {domain.name}:{type} {prio} {uri} {dispname}\n' f.write(compressor.compress(entry.encode())) f.write(compressor.flush()) + + +class _InventoryItem: + __slots__ = 'project_name', 'project_version', 'uri', 'display_name' + + project_name: str + project_version: str + uri: str + display_name: str + + def __init__( + self, + *, + project_name: str, + project_version: str, + uri: str, + display_name: str, + ) -> None: + object.__setattr__(self, 'project_name', project_name) + object.__setattr__(self, 'project_version', project_version) + object.__setattr__(self, 'uri', uri) + object.__setattr__(self, 'display_name', display_name) + + def __repr__(self) -> str: + return ( + '_InventoryItem(' + f'project_name={self.project_name!r}, ' + f'project_version={self.project_version!r}, ' + f'uri={self.uri!r}, ' + f'display_name={self.display_name!r}' + ')' + ) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, _InventoryItem): + return NotImplemented + return ( + self.project_name == other.project_name + and self.project_version == other.project_version + and self.uri == other.uri + and self.display_name == other.display_name + ) + + def __hash__(self) -> int: + return hash(( + self.project_name, + self.project_version, + self.uri, + self.display_name, + )) + + def __setattr__(self, key: str, value: object) -> NoReturn: + msg = '_InventoryItem is immutable' + raise AttributeError(msg) + + def __delattr__(self, key: str) -> NoReturn: + msg = '_InventoryItem is immutable' + raise AttributeError(msg) + + def __getstate__(self) -> tuple[str, str, str, str]: + return self.project_name, self.project_version, self.uri, self.display_name + + def __setstate__(self, state: tuple[str, str, str, str]) -> None: + project_name, project_version, uri, display_name = state + object.__setattr__(self, 'project_name', project_name) + object.__setattr__(self, 'project_version', project_version) + object.__setattr__(self, 'uri', uri) + object.__setattr__(self, 'display_name', display_name) + + def __getitem__(self, key: int | slice) -> str | tuple[str, ...]: + warnings.warn( + 'The tuple interface for _InventoryItem objects is deprecated.', + RemovedInSphinx10Warning, + stacklevel=2, + ) + tpl = self.project_name, self.project_version, self.uri, self.display_name + return tpl[key] + + def __iter__(self) -> Iterator[str]: + warnings.warn( + 'The iter() interface for _InventoryItem objects is deprecated.', + RemovedInSphinx10Warning, + stacklevel=2, + ) + tpl = self.project_name, self.project_version, self.uri, self.display_name + return iter(tpl) diff --git a/sphinx/util/typing.py b/sphinx/util/typing.py index 6efcd17fe0b..51566b28810 100644 --- a/sphinx/util/typing.py +++ b/sphinx/util/typing.py @@ -23,6 +23,7 @@ from typing_extensions import TypeIs from sphinx.application import Sphinx + from sphinx.util.inventory import _InventoryItem _RestifyMode: TypeAlias = Literal[ 'fully-qualified-except-typing', @@ -110,13 +111,7 @@ def __call__( TitleGetter: TypeAlias = Callable[[nodes.Node], str] # inventory data on memory -InventoryItem: TypeAlias = tuple[ - str, # project name - str, # project version - str, # URL - str, # display name -] -Inventory: TypeAlias = dict[str, dict[str, InventoryItem]] +Inventory: TypeAlias = dict[str, dict[str, '_InventoryItem']] class ExtensionMetadata(typing.TypedDict, total=False): diff --git a/tests/test_builders/test_build_dirhtml.py b/tests/test_builders/test_build_dirhtml.py index 566fb1c20e3..fb945f3cda7 100644 --- a/tests/test_builders/test_build_dirhtml.py +++ b/tests/test_builders/test_build_dirhtml.py @@ -6,7 +6,7 @@ import pytest -from sphinx.util.inventory import InventoryFile +from sphinx.util.inventory import InventoryFile, _InventoryItem @pytest.mark.sphinx('dirhtml', testroot='builder-dirhtml') @@ -30,28 +30,33 @@ def test_dirhtml(app): invdata = InventoryFile.load(f, 'path/to', posixpath.join) assert 'index' in invdata.get('std:doc', {}) - assert invdata['std:doc']['index'] == ('Project name not set', '', 'path/to/', '-') + assert invdata['std:doc']['index'] == _InventoryItem( + project_name='Project name not set', + project_version='', + uri='path/to/', + display_name='-', + ) assert 'foo/index' in invdata.get('std:doc', {}) - assert invdata['std:doc']['foo/index'] == ( - 'Project name not set', - '', - 'path/to/foo/', - '-', + assert invdata['std:doc']['foo/index'] == _InventoryItem( + project_name='Project name not set', + project_version='', + uri='path/to/foo/', + display_name='-', ) assert 'index' in invdata.get('std:label', {}) - assert invdata['std:label']['index'] == ( - 'Project name not set', - '', - 'path/to/#index', - '-', + assert invdata['std:label']['index'] == _InventoryItem( + project_name='Project name not set', + project_version='', + uri='path/to/#index', + display_name='-', ) assert 'foo' in invdata.get('std:label', {}) - assert invdata['std:label']['foo'] == ( - 'Project name not set', - '', - 'path/to/foo/#foo', - 'foo/index', + assert invdata['std:label']['foo'] == _InventoryItem( + project_name='Project name not set', + project_version='', + uri='path/to/foo/#foo', + display_name='foo/index', ) diff --git a/tests/test_builders/test_build_html.py b/tests/test_builders/test_build_html.py index b82f561655a..b82db80f1fd 100644 --- a/tests/test_builders/test_build_html.py +++ b/tests/test_builders/test_build_html.py @@ -12,7 +12,7 @@ from sphinx._cli.util.errors import strip_escape_sequences from sphinx.builders.html import validate_html_extra_path, validate_html_static_path from sphinx.errors import ConfigError -from sphinx.util.inventory import InventoryFile +from sphinx.util.inventory import InventoryFile, _InventoryItem from tests.test_builders.xpath_data import FIGURE_CAPTION from tests.test_builders.xpath_util import check_xpath @@ -233,36 +233,36 @@ def test_html_inventory(app): 'genindex', 'search', } - assert invdata['std:label']['modindex'] == ( - 'Project name not set', - '', - 'https://www.google.com/py-modindex.html', - 'Module Index', + assert invdata['std:label']['modindex'] == _InventoryItem( + project_name='Project name not set', + project_version='', + uri='https://www.google.com/py-modindex.html', + display_name='Module Index', ) - assert invdata['std:label']['py-modindex'] == ( - 'Project name not set', - '', - 'https://www.google.com/py-modindex.html', - 'Python Module Index', + assert invdata['std:label']['py-modindex'] == _InventoryItem( + project_name='Project name not set', + project_version='', + uri='https://www.google.com/py-modindex.html', + display_name='Python Module Index', ) - assert invdata['std:label']['genindex'] == ( - 'Project name not set', - '', - 'https://www.google.com/genindex.html', - 'Index', + assert invdata['std:label']['genindex'] == _InventoryItem( + project_name='Project name not set', + project_version='', + uri='https://www.google.com/genindex.html', + display_name='Index', ) - assert invdata['std:label']['search'] == ( - 'Project name not set', - '', - 'https://www.google.com/search.html', - 'Search Page', + assert invdata['std:label']['search'] == _InventoryItem( + project_name='Project name not set', + project_version='', + uri='https://www.google.com/search.html', + display_name='Search Page', ) assert set(invdata['std:doc'].keys()) == {'index'} - assert invdata['std:doc']['index'] == ( - 'Project name not set', - '', - 'https://www.google.com/index.html', - 'The basic Sphinx documentation for testing', + assert invdata['std:doc']['index'] == _InventoryItem( + project_name='Project name not set', + project_version='', + uri='https://www.google.com/index.html', + display_name='The basic Sphinx documentation for testing', ) diff --git a/tests/test_extensions/test_ext_intersphinx.py b/tests/test_extensions/test_ext_intersphinx.py index dd442ffb7f3..8d29d539bdd 100644 --- a/tests/test_extensions/test_ext_intersphinx.py +++ b/tests/test_extensions/test_ext_intersphinx.py @@ -28,6 +28,7 @@ ) from sphinx.ext.intersphinx._resolve import missing_reference from sphinx.ext.intersphinx._shared import _IntersphinxProject +from sphinx.util.inventory import _InventoryItem from tests.test_util.intersphinx_data import ( INVENTORY_V2, @@ -155,11 +156,11 @@ def test_missing_reference(tmp_path, app): load_mappings(app) inv = app.env.intersphinx_inventory - assert inv['py:module']['module2'] == ( - 'foo', - '2.0', - 'https://docs.python.org/foo.html#module-module2', - '-', + assert inv['py:module']['module2'] == _InventoryItem( + project_name='foo', + project_version='2.0', + uri='https://docs.python.org/foo.html#module-module2', + display_name='-', ) # check resolution when a target is found diff --git a/tests/test_extensions/test_ext_intersphinx_cache.py b/tests/test_extensions/test_ext_intersphinx_cache.py index b7694853e6d..91f3cdcc01e 100644 --- a/tests/test_extensions/test_ext_intersphinx_cache.py +++ b/tests/test_extensions/test_ext_intersphinx_cache.py @@ -11,6 +11,7 @@ from sphinx.ext.intersphinx._shared import InventoryAdapter from sphinx.testing.util import SphinxTestApp +from sphinx.util.inventory import _InventoryItem from tests.utils import http_server @@ -18,7 +19,6 @@ from collections.abc import Iterable from typing import BinaryIO - from sphinx.util.typing import InventoryItem BASE_CONFIG = { 'extensions': ['sphinx.ext.intersphinx'], @@ -109,10 +109,14 @@ def record(self) -> dict[str, tuple[str | None, str | None]]: """The :confval:`intersphinx_mapping` record for this project.""" return {self.name: (self.url, self.file)} - def normalise(self, entry: InventoryEntry) -> tuple[str, InventoryItem]: + def normalise(self, entry: InventoryEntry) -> tuple[str, _InventoryItem]: """Format an inventory entry as if it were part of this project.""" - url = posixpath.join(self.url, entry.uri) - return entry.name, (self.safe_name, self.safe_version, url, entry.display_name) + return entry.name, _InventoryItem( + project_name=self.safe_name, + project_version=self.safe_version, + uri=posixpath.join(self.url, entry.uri), + display_name=entry.display_name, + ) class FakeInventory: diff --git a/tests/test_util/test_util_inventory.py b/tests/test_util/test_util_inventory.py index 85f678916cd..3a2069344ed 100644 --- a/tests/test_util/test_util_inventory.py +++ b/tests/test_util/test_util_inventory.py @@ -8,7 +8,7 @@ import sphinx.locale from sphinx.testing.util import SphinxTestApp -from sphinx.util.inventory import InventoryFile +from sphinx.util.inventory import InventoryFile, _InventoryItem from tests.test_util.intersphinx_data import ( INVENTORY_V1, @@ -23,17 +23,17 @@ def test_read_inventory_v1(): invdata = InventoryFile.loads(INVENTORY_V1, uri='/util') - assert invdata['py:module']['module'] == ( - 'foo', - '1.0', - '/util/foo.html#module-module', - '-', + assert invdata['py:module']['module'] == _InventoryItem( + project_name='foo', + project_version='1.0', + uri='/util/foo.html#module-module', + display_name='-', ) - assert invdata['py:class']['module.cls'] == ( - 'foo', - '1.0', - '/util/foo.html#module.cls', - '-', + assert invdata['py:class']['module.cls'] == _InventoryItem( + project_name='foo', + project_version='1.0', + uri='/util/foo.html#module.cls', + display_name='-', ) @@ -41,35 +41,35 @@ def test_read_inventory_v2(): invdata = InventoryFile.loads(INVENTORY_V2, uri='/util') assert len(invdata['py:module']) == 2 - assert invdata['py:module']['module1'] == ( - 'foo', - '2.0', - '/util/foo.html#module-module1', - 'Long Module desc', + assert invdata['py:module']['module1'] == _InventoryItem( + project_name='foo', + project_version='2.0', + uri='/util/foo.html#module-module1', + display_name='Long Module desc', ) - assert invdata['py:module']['module2'] == ( - 'foo', - '2.0', - '/util/foo.html#module-module2', - '-', + assert invdata['py:module']['module2'] == _InventoryItem( + project_name='foo', + project_version='2.0', + uri='/util/foo.html#module-module2', + display_name='-', ) - assert invdata['py:function']['module1.func'][2] == ( + assert invdata['py:function']['module1.func'].uri == ( '/util/sub/foo.html#module1.func' ) - assert invdata['c:function']['CFunc'][2] == '/util/cfunc.html#CFunc' - assert invdata['std:term']['a term'][2] == '/util/glossary.html#term-a-term' - assert invdata['std:term']['a term including:colon'][2] == ( + assert invdata['c:function']['CFunc'].uri == '/util/cfunc.html#CFunc' + assert invdata['std:term']['a term'].uri == '/util/glossary.html#term-a-term' + assert invdata['std:term']['a term including:colon'].uri == ( '/util/glossary.html#term-a-term-including-colon' ) def test_read_inventory_v2_not_having_version(): invdata = InventoryFile.loads(INVENTORY_V2_NO_VERSION, uri='/util') - assert invdata['py:module']['module1'] == ( - 'foo', - '', - '/util/foo.html#module-module1', - 'Long Module desc', + assert invdata['py:module']['module1'] == _InventoryItem( + project_name='foo', + project_version='', + uri='/util/foo.html#module-module1', + display_name='Long Module desc', )