Initial implementation of an `_Inventory` type (#13275)

This commit is contained in:
Adam Turner 2025-01-29 00:46:08 +00:00 committed by GitHub
parent 5871ce266a
commit 82e9182d43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 83 additions and 50 deletions

View File

@ -28,15 +28,15 @@ def inspect_main(argv: list[str], /) -> int:
)
try:
inv_data = _fetch_inventory(
inv = _fetch_inventory(
target_uri='',
inv_location=filename,
config=config,
srcdir=Path(),
)
for key in sorted(inv_data or {}):
for key in sorted(inv.data):
print(key)
inv_entries = sorted(inv_data[key].items())
inv_entries = sorted(inv.data[key].items())
for entry, inv_item in inv_entries:
display_name = inv_item.display_name
display_name = display_name * (display_name != '-')

View File

@ -30,7 +30,7 @@ if TYPE_CHECKING:
InventoryName,
InventoryURI,
)
from sphinx.util.typing import Inventory
from sphinx.util.typing import Inventory, _Inventory
def validate_intersphinx_mapping(app: Sphinx, config: Config) -> None:
@ -245,27 +245,27 @@ def _fetch_inventory_group(
for location in project.locations:
# location is either None or a non-empty string
if location is None:
inv = posixpath.join(project.target_uri, INVENTORY_FILENAME)
inv_location = posixpath.join(project.target_uri, INVENTORY_FILENAME)
else:
inv = location
inv_location = location
# decide whether the inventory must be read: always read local
# files; remote ones only if the cache time is expired
if (
'://' not in inv
'://' not in inv_location
or project.target_uri not in cache
or cache[project.target_uri][1] < cache_time
):
LOGGER.info(
__("loading intersphinx inventory '%s' from %s ..."),
project.name,
_get_safe_url(inv),
_get_safe_url(inv_location),
)
try:
invdata = _fetch_inventory(
inv = _fetch_inventory(
target_uri=project.target_uri,
inv_location=inv,
inv_location=inv_location,
config=config,
srcdir=srcdir,
)
@ -273,8 +273,8 @@ def _fetch_inventory_group(
failures.append(err.args)
continue
if invdata:
cache[project.target_uri] = project.name, now, invdata
if inv:
cache[project.target_uri] = project.name, now, inv.data
updated = True
break
@ -306,12 +306,12 @@ def fetch_inventory(app: Sphinx, uri: InventoryURI, inv: str) -> Inventory:
inv_location=inv,
config=_InvConfig.from_config(app.config),
srcdir=app.srcdir,
)
).data
def _fetch_inventory(
*, target_uri: InventoryURI, inv_location: str, config: _InvConfig, srcdir: Path
) -> Inventory:
) -> _Inventory:
"""Fetch, parse and return an intersphinx inventory file."""
# both *target_uri* (base URI of the links to generate)
# and *inv_location* (actual location of the inventory file)
@ -327,11 +327,11 @@ def _fetch_inventory(
raw_data = _fetch_inventory_file(inv_location=inv_location, srcdir=srcdir)
try:
invdata = InventoryFile.loads(raw_data, uri=target_uri)
inv = InventoryFile.loads(raw_data, uri=target_uri)
except ValueError as exc:
msg = f'unknown or unsupported inventory version: {exc!r}'
raise ValueError(msg) from exc
return invdata
return inv
def _fetch_inventory_url(

View File

@ -31,7 +31,7 @@ if TYPE_CHECKING:
from sphinx.environment import BuildEnvironment
from sphinx.ext.intersphinx._shared import InventoryName
from sphinx.util.inventory import _InventoryItem
from sphinx.util.typing import Inventory, RoleFunction
from sphinx.util.typing import RoleFunction, _Inventory
def _create_element_from_result(
@ -75,7 +75,7 @@ def _create_element_from_result(
def _resolve_reference_in_domain_by_target(
inv_name: InventoryName | None,
inventory: Inventory,
inventory: _Inventory,
domain_name: str,
objtypes: Iterable[str],
target: str,
@ -139,7 +139,7 @@ def _resolve_reference_in_domain_by_target(
def _resolve_reference_in_domain(
inv_name: InventoryName | None,
inventory: Inventory,
inventory: _Inventory,
honor_disabled_refs: bool,
disabled_reftypes: Set[str],
domain: Domain,
@ -190,7 +190,7 @@ def _resolve_reference_in_domain(
def _resolve_reference(
inv_name: InventoryName | None,
domains: _DomainsContainer,
inventory: Inventory,
inventory: _Inventory,
honor_disabled_refs: bool,
disabled_reftypes: Set[str],
node: pending_xref,

View File

@ -47,7 +47,7 @@ class InventoryFile:
content: bytes,
*,
uri: str,
) -> Inventory:
) -> _Inventory:
format_line, _, content = content.partition(b'\n')
format_line = format_line.rstrip() # remove trailing \r or spaces
if format_line == b'# Sphinx inventory version 2':
@ -64,14 +64,14 @@ class InventoryFile:
@classmethod
def load(cls, stream: _SupportsRead, uri: str, joinfunc: _JoinFunc) -> Inventory:
return cls.loads(stream.read(), uri=uri)
return cls.loads(stream.read(), uri=uri).data
@classmethod
def _loads_v1(cls, lines: Sequence[str], *, uri: str) -> Inventory:
def _loads_v1(cls, lines: Sequence[str], *, uri: str) -> _Inventory:
if len(lines) < 2:
msg = 'invalid inventory header: missing project name or version'
raise ValueError(msg)
invdata: Inventory = {}
inv = _Inventory({})
projname = lines[0].rstrip()[11:] # Project name
version = lines[1].rstrip()[11:] # Project version
for line in lines[2:]:
@ -84,25 +84,25 @@ class InventoryFile:
else:
item_type = f'py:{item_type}'
location += f'#{name}'
invdata.setdefault(item_type, {})[name] = _InventoryItem(
inv[item_type, name] = _InventoryItem(
project_name=projname,
project_version=version,
uri=location,
display_name='-',
)
return invdata
return inv
@classmethod
def _loads_v2(cls, inv_data: bytes, *, uri: str) -> Inventory:
def _loads_v2(cls, inv_data: bytes, *, uri: str) -> _Inventory:
try:
line_1, line_2, check_line, compressed = inv_data.split(b'\n', maxsplit=3)
except ValueError:
msg = 'invalid inventory header: missing project name or version'
raise ValueError(msg) from None
invdata: Inventory = {}
inv = _Inventory({})
projname = line_1.rstrip()[11:].decode() # Project name
version = line_2.rstrip()[11:].decode() # Project version
# definition -> priority, location, display name
# definition -> (priority, location, display name)
potential_ambiguities: dict[str, tuple[str, str, str]] = {}
actual_ambiguities = set()
if b'zlib' not in check_line: # '... compressed using zlib'
@ -125,7 +125,7 @@ class InventoryFile:
#
# Note: To avoid the regex DoS, this is implemented in python (refs: #8175)
continue
if type == 'py:module' and type in invdata and name in invdata[type]:
if type == 'py:module' and (type, name) in inv:
# due to a bug in 1.1 and below,
# two inventory entries are created
# for Python modules, and the first
@ -154,7 +154,7 @@ class InventoryFile:
if location.endswith('$'):
location = location[:-1] + name
location = posixpath.join(uri, location)
invdata.setdefault(type, {})[name] = _InventoryItem(
inv[type, name] = _InventoryItem(
project_name=projname,
project_version=version,
uri=location,
@ -168,7 +168,7 @@ class InventoryFile:
type='intersphinx',
subtype='external',
)
return invdata
return inv
@classmethod
def dump(
@ -206,6 +206,41 @@ class InventoryFile:
f.write(compressor.flush())
class _Inventory:
"""Inventory data in memory."""
__slots__ = ('data',)
data: dict[str, dict[str, _InventoryItem]]
def __init__(self, data: dict[str, dict[str, _InventoryItem]], /) -> None:
# type -> name -> _InventoryItem
self.data: dict[str, dict[str, _InventoryItem]] = data
def __repr__(self) -> str:
return f'_Inventory({self.data!r})'
def __eq__(self, other: object) -> bool:
if not isinstance(other, _Inventory):
return NotImplemented
return self.data == other.data
def __hash__(self) -> int:
return hash(self.data)
def __getitem__(self, item: tuple[str, str]) -> _InventoryItem:
obj_type, name = item
return self.data.setdefault(obj_type, {})[name]
def __setitem__(self, item: tuple[str, str], value: _InventoryItem) -> None:
obj_type, name = item
self.data.setdefault(obj_type, {})[name] = value
def __contains__(self, item: tuple[str, str]) -> bool:
obj_type, name = item
return obj_type in self.data and name in self.data[obj_type]
class _InventoryItem:
__slots__ = 'project_name', 'project_version', 'uri', 'display_name'

View File

@ -28,7 +28,7 @@ from sphinx.ext.intersphinx._load import (
)
from sphinx.ext.intersphinx._resolve import missing_reference
from sphinx.ext.intersphinx._shared import _IntersphinxProject
from sphinx.util.inventory import _InventoryItem
from sphinx.util.inventory import _Inventory, _InventoryItem
from tests.test_util.intersphinx_data import (
INVENTORY_V2,
@ -160,7 +160,7 @@ def test_missing_reference(tmp_path, app):
# load the inventory and check if it's done correctly
validate_intersphinx_mapping(app, app.config)
load_mappings(app)
inv = app.env.intersphinx_inventory
inv: Inventory = app.env.intersphinx_inventory
assert inv['py:module']['module2'] == _InventoryItem(
project_name='foo',
@ -775,7 +775,7 @@ def test_intersphinx_cache_limit(app, monkeypatch, cache_limit, expected_expired
# `_fetch_inventory_group` calls `_fetch_inventory`.
# We replace it with a mock to test whether it has been called.
# If it has been called, it means the cache had expired.
mock_fake_inventory: Inventory = {'std:label': {}} # must be truthy
mock_fake_inventory = _Inventory({}) # must be truthy
mock_fetch_inventory = mock.Mock(return_value=mock_fake_inventory)
monkeypatch.setattr(
'sphinx.ext.intersphinx._load._fetch_inventory', mock_fetch_inventory

View File

@ -22,14 +22,14 @@ if TYPE_CHECKING:
def test_read_inventory_v1():
invdata = InventoryFile.loads(INVENTORY_V1, uri='/util')
assert invdata['py:module']['module'] == _InventoryItem(
inv = InventoryFile.loads(INVENTORY_V1, uri='/util')
assert inv['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'] == _InventoryItem(
assert inv['py:class', 'module.cls'] == _InventoryItem(
project_name='foo',
project_version='1.0',
uri='/util/foo.html#module.cls',
@ -38,34 +38,32 @@ def test_read_inventory_v1():
def test_read_inventory_v2():
invdata = InventoryFile.loads(INVENTORY_V2, uri='/util')
inv = InventoryFile.loads(INVENTORY_V2, uri='/util')
assert len(invdata['py:module']) == 2
assert invdata['py:module']['module1'] == _InventoryItem(
assert len(inv.data['py:module']) == 2
assert inv['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'] == _InventoryItem(
assert inv['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'].uri == (
'/util/sub/foo.html#module1.func'
)
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 == (
assert inv['py:function', 'module1.func'].uri == ('/util/sub/foo.html#module1.func')
assert inv['c:function', 'CFunc'].uri == '/util/cfunc.html#CFunc'
assert inv['std:term', 'a term'].uri == '/util/glossary.html#term-a-term'
assert inv['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'] == _InventoryItem(
inv = InventoryFile.loads(INVENTORY_V2_NO_VERSION, uri='/util')
assert inv['py:module', 'module1'] == _InventoryItem(
project_name='foo',
project_version='',
uri='/util/foo.html#module-module1',