Skip to content

Commit 82e9182

Browse files
authored
Initial implementation of an _Inventory type (#13275)
1 parent 5871ce2 commit 82e9182

File tree

6 files changed

+83
-50
lines changed

6 files changed

+83
-50
lines changed

sphinx/ext/intersphinx/_cli.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,15 @@ def inspect_main(argv: list[str], /) -> int:
2828
)
2929

3030
try:
31-
inv_data = _fetch_inventory(
31+
inv = _fetch_inventory(
3232
target_uri='',
3333
inv_location=filename,
3434
config=config,
3535
srcdir=Path(),
3636
)
37-
for key in sorted(inv_data or {}):
37+
for key in sorted(inv.data):
3838
print(key)
39-
inv_entries = sorted(inv_data[key].items())
39+
inv_entries = sorted(inv.data[key].items())
4040
for entry, inv_item in inv_entries:
4141
display_name = inv_item.display_name
4242
display_name = display_name * (display_name != '-')

sphinx/ext/intersphinx/_load.py

+13-13
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
InventoryName,
3131
InventoryURI,
3232
)
33-
from sphinx.util.typing import Inventory
33+
from sphinx.util.typing import Inventory, _Inventory
3434

3535

3636
def validate_intersphinx_mapping(app: Sphinx, config: Config) -> None:
@@ -245,36 +245,36 @@ def _fetch_inventory_group(
245245
for location in project.locations:
246246
# location is either None or a non-empty string
247247
if location is None:
248-
inv = posixpath.join(project.target_uri, INVENTORY_FILENAME)
248+
inv_location = posixpath.join(project.target_uri, INVENTORY_FILENAME)
249249
else:
250-
inv = location
250+
inv_location = location
251251

252252
# decide whether the inventory must be read: always read local
253253
# files; remote ones only if the cache time is expired
254254
if (
255-
'://' not in inv
255+
'://' not in inv_location
256256
or project.target_uri not in cache
257257
or cache[project.target_uri][1] < cache_time
258258
):
259259
LOGGER.info(
260260
__("loading intersphinx inventory '%s' from %s ..."),
261261
project.name,
262-
_get_safe_url(inv),
262+
_get_safe_url(inv_location),
263263
)
264264

265265
try:
266-
invdata = _fetch_inventory(
266+
inv = _fetch_inventory(
267267
target_uri=project.target_uri,
268-
inv_location=inv,
268+
inv_location=inv_location,
269269
config=config,
270270
srcdir=srcdir,
271271
)
272272
except Exception as err:
273273
failures.append(err.args)
274274
continue
275275

276-
if invdata:
277-
cache[project.target_uri] = project.name, now, invdata
276+
if inv:
277+
cache[project.target_uri] = project.name, now, inv.data
278278
updated = True
279279
break
280280

@@ -306,12 +306,12 @@ def fetch_inventory(app: Sphinx, uri: InventoryURI, inv: str) -> Inventory:
306306
inv_location=inv,
307307
config=_InvConfig.from_config(app.config),
308308
srcdir=app.srcdir,
309-
)
309+
).data
310310

311311

312312
def _fetch_inventory(
313313
*, target_uri: InventoryURI, inv_location: str, config: _InvConfig, srcdir: Path
314-
) -> Inventory:
314+
) -> _Inventory:
315315
"""Fetch, parse and return an intersphinx inventory file."""
316316
# both *target_uri* (base URI of the links to generate)
317317
# and *inv_location* (actual location of the inventory file)
@@ -327,11 +327,11 @@ def _fetch_inventory(
327327
raw_data = _fetch_inventory_file(inv_location=inv_location, srcdir=srcdir)
328328

329329
try:
330-
invdata = InventoryFile.loads(raw_data, uri=target_uri)
330+
inv = InventoryFile.loads(raw_data, uri=target_uri)
331331
except ValueError as exc:
332332
msg = f'unknown or unsupported inventory version: {exc!r}'
333333
raise ValueError(msg) from exc
334-
return invdata
334+
return inv
335335

336336

337337
def _fetch_inventory_url(

sphinx/ext/intersphinx/_resolve.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from sphinx.environment import BuildEnvironment
3232
from sphinx.ext.intersphinx._shared import InventoryName
3333
from sphinx.util.inventory import _InventoryItem
34-
from sphinx.util.typing import Inventory, RoleFunction
34+
from sphinx.util.typing import RoleFunction, _Inventory
3535

3636

3737
def _create_element_from_result(
@@ -75,7 +75,7 @@ def _create_element_from_result(
7575

7676
def _resolve_reference_in_domain_by_target(
7777
inv_name: InventoryName | None,
78-
inventory: Inventory,
78+
inventory: _Inventory,
7979
domain_name: str,
8080
objtypes: Iterable[str],
8181
target: str,
@@ -139,7 +139,7 @@ def _resolve_reference_in_domain_by_target(
139139

140140
def _resolve_reference_in_domain(
141141
inv_name: InventoryName | None,
142-
inventory: Inventory,
142+
inventory: _Inventory,
143143
honor_disabled_refs: bool,
144144
disabled_reftypes: Set[str],
145145
domain: Domain,
@@ -190,7 +190,7 @@ def _resolve_reference_in_domain(
190190
def _resolve_reference(
191191
inv_name: InventoryName | None,
192192
domains: _DomainsContainer,
193-
inventory: Inventory,
193+
inventory: _Inventory,
194194
honor_disabled_refs: bool,
195195
disabled_reftypes: Set[str],
196196
node: pending_xref,

sphinx/util/inventory.py

+47-12
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def loads(
4747
content: bytes,
4848
*,
4949
uri: str,
50-
) -> Inventory:
50+
) -> _Inventory:
5151
format_line, _, content = content.partition(b'\n')
5252
format_line = format_line.rstrip() # remove trailing \r or spaces
5353
if format_line == b'# Sphinx inventory version 2':
@@ -64,14 +64,14 @@ def loads(
6464

6565
@classmethod
6666
def load(cls, stream: _SupportsRead, uri: str, joinfunc: _JoinFunc) -> Inventory:
67-
return cls.loads(stream.read(), uri=uri)
67+
return cls.loads(stream.read(), uri=uri).data
6868

6969
@classmethod
70-
def _loads_v1(cls, lines: Sequence[str], *, uri: str) -> Inventory:
70+
def _loads_v1(cls, lines: Sequence[str], *, uri: str) -> _Inventory:
7171
if len(lines) < 2:
7272
msg = 'invalid inventory header: missing project name or version'
7373
raise ValueError(msg)
74-
invdata: Inventory = {}
74+
inv = _Inventory({})
7575
projname = lines[0].rstrip()[11:] # Project name
7676
version = lines[1].rstrip()[11:] # Project version
7777
for line in lines[2:]:
@@ -84,25 +84,25 @@ def _loads_v1(cls, lines: Sequence[str], *, uri: str) -> Inventory:
8484
else:
8585
item_type = f'py:{item_type}'
8686
location += f'#{name}'
87-
invdata.setdefault(item_type, {})[name] = _InventoryItem(
87+
inv[item_type, name] = _InventoryItem(
8888
project_name=projname,
8989
project_version=version,
9090
uri=location,
9191
display_name='-',
9292
)
93-
return invdata
93+
return inv
9494

9595
@classmethod
96-
def _loads_v2(cls, inv_data: bytes, *, uri: str) -> Inventory:
96+
def _loads_v2(cls, inv_data: bytes, *, uri: str) -> _Inventory:
9797
try:
9898
line_1, line_2, check_line, compressed = inv_data.split(b'\n', maxsplit=3)
9999
except ValueError:
100100
msg = 'invalid inventory header: missing project name or version'
101101
raise ValueError(msg) from None
102-
invdata: Inventory = {}
102+
inv = _Inventory({})
103103
projname = line_1.rstrip()[11:].decode() # Project name
104104
version = line_2.rstrip()[11:].decode() # Project version
105-
# definition -> priority, location, display name
105+
# definition -> (priority, location, display name)
106106
potential_ambiguities: dict[str, tuple[str, str, str]] = {}
107107
actual_ambiguities = set()
108108
if b'zlib' not in check_line: # '... compressed using zlib'
@@ -125,7 +125,7 @@ def _loads_v2(cls, inv_data: bytes, *, uri: str) -> Inventory:
125125
#
126126
# Note: To avoid the regex DoS, this is implemented in python (refs: #8175)
127127
continue
128-
if type == 'py:module' and type in invdata and name in invdata[type]:
128+
if type == 'py:module' and (type, name) in inv:
129129
# due to a bug in 1.1 and below,
130130
# two inventory entries are created
131131
# for Python modules, and the first
@@ -154,7 +154,7 @@ def _loads_v2(cls, inv_data: bytes, *, uri: str) -> Inventory:
154154
if location.endswith('$'):
155155
location = location[:-1] + name
156156
location = posixpath.join(uri, location)
157-
invdata.setdefault(type, {})[name] = _InventoryItem(
157+
inv[type, name] = _InventoryItem(
158158
project_name=projname,
159159
project_version=version,
160160
uri=location,
@@ -168,7 +168,7 @@ def _loads_v2(cls, inv_data: bytes, *, uri: str) -> Inventory:
168168
type='intersphinx',
169169
subtype='external',
170170
)
171-
return invdata
171+
return inv
172172

173173
@classmethod
174174
def dump(
@@ -206,6 +206,41 @@ def escape(string: str) -> str:
206206
f.write(compressor.flush())
207207

208208

209+
class _Inventory:
210+
"""Inventory data in memory."""
211+
212+
__slots__ = ('data',)
213+
214+
data: dict[str, dict[str, _InventoryItem]]
215+
216+
def __init__(self, data: dict[str, dict[str, _InventoryItem]], /) -> None:
217+
# type -> name -> _InventoryItem
218+
self.data: dict[str, dict[str, _InventoryItem]] = data
219+
220+
def __repr__(self) -> str:
221+
return f'_Inventory({self.data!r})'
222+
223+
def __eq__(self, other: object) -> bool:
224+
if not isinstance(other, _Inventory):
225+
return NotImplemented
226+
return self.data == other.data
227+
228+
def __hash__(self) -> int:
229+
return hash(self.data)
230+
231+
def __getitem__(self, item: tuple[str, str]) -> _InventoryItem:
232+
obj_type, name = item
233+
return self.data.setdefault(obj_type, {})[name]
234+
235+
def __setitem__(self, item: tuple[str, str], value: _InventoryItem) -> None:
236+
obj_type, name = item
237+
self.data.setdefault(obj_type, {})[name] = value
238+
239+
def __contains__(self, item: tuple[str, str]) -> bool:
240+
obj_type, name = item
241+
return obj_type in self.data and name in self.data[obj_type]
242+
243+
209244
class _InventoryItem:
210245
__slots__ = 'project_name', 'project_version', 'uri', 'display_name'
211246

tests/test_extensions/test_ext_intersphinx.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
)
2929
from sphinx.ext.intersphinx._resolve import missing_reference
3030
from sphinx.ext.intersphinx._shared import _IntersphinxProject
31-
from sphinx.util.inventory import _InventoryItem
31+
from sphinx.util.inventory import _Inventory, _InventoryItem
3232

3333
from tests.test_util.intersphinx_data import (
3434
INVENTORY_V2,
@@ -160,7 +160,7 @@ def test_missing_reference(tmp_path, app):
160160
# load the inventory and check if it's done correctly
161161
validate_intersphinx_mapping(app, app.config)
162162
load_mappings(app)
163-
inv = app.env.intersphinx_inventory
163+
inv: Inventory = app.env.intersphinx_inventory
164164

165165
assert inv['py:module']['module2'] == _InventoryItem(
166166
project_name='foo',
@@ -775,7 +775,7 @@ def test_intersphinx_cache_limit(app, monkeypatch, cache_limit, expected_expired
775775
# `_fetch_inventory_group` calls `_fetch_inventory`.
776776
# We replace it with a mock to test whether it has been called.
777777
# If it has been called, it means the cache had expired.
778-
mock_fake_inventory: Inventory = {'std:label': {}} # must be truthy
778+
mock_fake_inventory = _Inventory({}) # must be truthy
779779
mock_fetch_inventory = mock.Mock(return_value=mock_fake_inventory)
780780
monkeypatch.setattr(
781781
'sphinx.ext.intersphinx._load._fetch_inventory', mock_fetch_inventory

tests/test_util/test_util_inventory.py

+13-15
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@
2222

2323

2424
def test_read_inventory_v1():
25-
invdata = InventoryFile.loads(INVENTORY_V1, uri='/util')
26-
assert invdata['py:module']['module'] == _InventoryItem(
25+
inv = InventoryFile.loads(INVENTORY_V1, uri='/util')
26+
assert inv['py:module', 'module'] == _InventoryItem(
2727
project_name='foo',
2828
project_version='1.0',
2929
uri='/util/foo.html#module-module',
3030
display_name='-',
3131
)
32-
assert invdata['py:class']['module.cls'] == _InventoryItem(
32+
assert inv['py:class', 'module.cls'] == _InventoryItem(
3333
project_name='foo',
3434
project_version='1.0',
3535
uri='/util/foo.html#module.cls',
@@ -38,34 +38,32 @@ def test_read_inventory_v1():
3838

3939

4040
def test_read_inventory_v2():
41-
invdata = InventoryFile.loads(INVENTORY_V2, uri='/util')
41+
inv = InventoryFile.loads(INVENTORY_V2, uri='/util')
4242

43-
assert len(invdata['py:module']) == 2
44-
assert invdata['py:module']['module1'] == _InventoryItem(
43+
assert len(inv.data['py:module']) == 2
44+
assert inv['py:module', 'module1'] == _InventoryItem(
4545
project_name='foo',
4646
project_version='2.0',
4747
uri='/util/foo.html#module-module1',
4848
display_name='Long Module desc',
4949
)
50-
assert invdata['py:module']['module2'] == _InventoryItem(
50+
assert inv['py:module', 'module2'] == _InventoryItem(
5151
project_name='foo',
5252
project_version='2.0',
5353
uri='/util/foo.html#module-module2',
5454
display_name='-',
5555
)
56-
assert invdata['py:function']['module1.func'].uri == (
57-
'/util/sub/foo.html#module1.func'
58-
)
59-
assert invdata['c:function']['CFunc'].uri == '/util/cfunc.html#CFunc'
60-
assert invdata['std:term']['a term'].uri == '/util/glossary.html#term-a-term'
61-
assert invdata['std:term']['a term including:colon'].uri == (
56+
assert inv['py:function', 'module1.func'].uri == ('/util/sub/foo.html#module1.func')
57+
assert inv['c:function', 'CFunc'].uri == '/util/cfunc.html#CFunc'
58+
assert inv['std:term', 'a term'].uri == '/util/glossary.html#term-a-term'
59+
assert inv['std:term', 'a term including:colon'].uri == (
6260
'/util/glossary.html#term-a-term-including-colon'
6361
)
6462

6563

6664
def test_read_inventory_v2_not_having_version():
67-
invdata = InventoryFile.loads(INVENTORY_V2_NO_VERSION, uri='/util')
68-
assert invdata['py:module']['module1'] == _InventoryItem(
65+
inv = InventoryFile.loads(INVENTORY_V2_NO_VERSION, uri='/util')
66+
assert inv['py:module', 'module1'] == _InventoryItem(
6967
project_name='foo',
7068
project_version='',
7169
uri='/util/foo.html#module-module1',

0 commit comments

Comments
 (0)