Skip to content

Commit 49726c2

Browse files
Split sphinx.ext.intersphinx._load
Co-authored-by: Adam Turner <[email protected]>
1 parent b9a3f7e commit 49726c2

File tree

6 files changed

+265
-243
lines changed

6 files changed

+265
-243
lines changed

.ruff.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,7 @@ exclude = [
505505
"sphinx/ext/autosectionlabel.py",
506506
"sphinx/ext/intersphinx/__init__.py",
507507
"sphinx/ext/intersphinx/_cli.py",
508+
"sphinx/ext/intersphinx/_load.py",
508509
"sphinx/ext/duration.py",
509510
"sphinx/ext/imgconverter.py",
510511
"sphinx/ext/imgmath.py",

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ module = [
263263
"sphinx.ext.doctest",
264264
"sphinx.ext.graphviz",
265265
"sphinx.ext.inheritance_diagram",
266-
"sphinx.ext.intersphinx",
266+
"sphinx.ext.intersphinx._load",
267267
"sphinx.ext.napoleon.docstring",
268268
"sphinx.highlighting",
269269
"sphinx.jinja2glue",

sphinx/ext/intersphinx/__init__.py

Lines changed: 7 additions & 237 deletions
Original file line numberDiff line numberDiff line change
@@ -36,241 +36,44 @@
3636
'inspect_main',
3737
)
3838

39-
import concurrent.futures
40-
import functools
4139
import posixpath
4240
import re
43-
import time
44-
from os import path
4541
from typing import TYPE_CHECKING, cast
46-
from urllib.parse import urlsplit, urlunsplit
4742

4843
from docutils import nodes
4944
from docutils.utils import relative_path
5045

5146
import sphinx
5247
from sphinx.addnodes import pending_xref
53-
from sphinx.builders.html import INVENTORY_FILENAME
5448
from sphinx.deprecation import _deprecation_warning
5549
from sphinx.errors import ExtensionError
5650
from sphinx.ext.intersphinx._cli import inspect_main
51+
from sphinx.ext.intersphinx._load import (
52+
fetch_inventory,
53+
fetch_inventory_group,
54+
load_mappings,
55+
normalize_intersphinx_mapping,
56+
)
5757
from sphinx.ext.intersphinx._shared import LOGGER as logger
5858
from sphinx.ext.intersphinx._shared import InventoryAdapter
5959
from sphinx.locale import _, __
6060
from sphinx.transforms.post_transforms import ReferencesResolver
61-
from sphinx.util import requests
6261
from sphinx.util.docutils import CustomReSTDispatcher, SphinxRole
63-
from sphinx.util.inventory import InventoryFile
6462

6563
if TYPE_CHECKING:
6664
from collections.abc import Iterable
6765
from types import ModuleType
68-
from typing import IO, Any
66+
from typing import Any
6967

7068
from docutils.nodes import Node, TextElement, system_message
7169
from docutils.utils import Reporter
7270

7371
from sphinx.application import Sphinx
74-
from sphinx.config import Config
7572
from sphinx.domains import Domain
7673
from sphinx.environment import BuildEnvironment
77-
from sphinx.ext.intersphinx._shared import InventoryCacheEntry
7874
from sphinx.util.typing import ExtensionMetadata, Inventory, InventoryItem, RoleFunction
7975

8076

81-
def _strip_basic_auth(url: str) -> str:
82-
"""Returns *url* with basic auth credentials removed. Also returns the
83-
basic auth username and password if they're present in *url*.
84-
85-
E.g.: https://user:[email protected] => https://example.com
86-
87-
*url* need not include basic auth credentials.
88-
89-
:param url: url which may or may not contain basic auth credentials
90-
:type url: ``str``
91-
92-
:return: *url* with any basic auth creds removed
93-
:rtype: ``str``
94-
"""
95-
frags = list(urlsplit(url))
96-
# swap out "user[:pass]@hostname" for "hostname"
97-
if '@' in frags[1]:
98-
frags[1] = frags[1].split('@')[1]
99-
return urlunsplit(frags)
100-
101-
102-
def _read_from_url(url: str, *, config: Config) -> IO:
103-
"""Reads data from *url* with an HTTP *GET*.
104-
105-
This function supports fetching from resources which use basic HTTP auth as
106-
laid out by RFC1738 § 3.1. See § 5 for grammar definitions for URLs.
107-
108-
.. seealso:
109-
110-
https://www.ietf.org/rfc/rfc1738.txt
111-
112-
:param url: URL of an HTTP resource
113-
:type url: ``str``
114-
115-
:return: data read from resource described by *url*
116-
:rtype: ``file``-like object
117-
"""
118-
r = requests.get(url, stream=True, timeout=config.intersphinx_timeout,
119-
_user_agent=config.user_agent,
120-
_tls_info=(config.tls_verify, config.tls_cacerts))
121-
r.raise_for_status()
122-
r.raw.url = r.url
123-
# decode content-body based on the header.
124-
# ref: https://github.com/psf/requests/issues/2155
125-
r.raw.read = functools.partial(r.raw.read, decode_content=True)
126-
return r.raw
127-
128-
129-
def _get_safe_url(url: str) -> str:
130-
"""Gets version of *url* with basic auth passwords obscured. This function
131-
returns results suitable for printing and logging.
132-
133-
E.g.: https://user:[email protected] => https://[email protected]
134-
135-
:param url: a url
136-
:type url: ``str``
137-
138-
:return: *url* with password removed
139-
:rtype: ``str``
140-
"""
141-
parts = urlsplit(url)
142-
if parts.username is None:
143-
return url
144-
else:
145-
frags = list(parts)
146-
if parts.port:
147-
frags[1] = f'{parts.username}@{parts.hostname}:{parts.port}'
148-
else:
149-
frags[1] = f'{parts.username}@{parts.hostname}'
150-
151-
return urlunsplit(frags)
152-
153-
154-
def fetch_inventory(app: Sphinx, uri: str, inv: str) -> Inventory:
155-
"""Fetch, parse and return an intersphinx inventory file."""
156-
# both *uri* (base URI of the links to generate) and *inv* (actual
157-
# location of the inventory file) can be local or remote URIs
158-
if '://' in uri:
159-
# case: inv URI points to remote resource; strip any existing auth
160-
uri = _strip_basic_auth(uri)
161-
try:
162-
if '://' in inv:
163-
f = _read_from_url(inv, config=app.config)
164-
else:
165-
f = open(path.join(app.srcdir, inv), 'rb') # NoQA: SIM115
166-
except Exception as err:
167-
err.args = ('intersphinx inventory %r not fetchable due to %s: %s',
168-
inv, err.__class__, str(err))
169-
raise
170-
try:
171-
if hasattr(f, 'url'):
172-
newinv = f.url
173-
if inv != newinv:
174-
logger.info(__('intersphinx inventory has moved: %s -> %s'), inv, newinv)
175-
176-
if uri in (inv, path.dirname(inv), path.dirname(inv) + '/'):
177-
uri = path.dirname(newinv)
178-
with f:
179-
try:
180-
invdata = InventoryFile.load(f, uri, posixpath.join)
181-
except ValueError as exc:
182-
raise ValueError('unknown or unsupported inventory version: %r' % exc) from exc
183-
except Exception as err:
184-
err.args = ('intersphinx inventory %r not readable due to %s: %s',
185-
inv, err.__class__.__name__, str(err))
186-
raise
187-
else:
188-
return invdata
189-
190-
191-
def fetch_inventory_group(
192-
name: str | None,
193-
uri: str,
194-
invs: tuple[str | None, ...],
195-
cache: dict[str, InventoryCacheEntry],
196-
app: Sphinx,
197-
now: int,
198-
) -> bool:
199-
cache_time = now - app.config.intersphinx_cache_limit * 86400
200-
failures = []
201-
try:
202-
for inv in invs:
203-
if not inv:
204-
inv = posixpath.join(uri, INVENTORY_FILENAME)
205-
# decide whether the inventory must be read: always read local
206-
# files; remote ones only if the cache time is expired
207-
if '://' not in inv or uri not in cache or cache[uri][1] < cache_time:
208-
safe_inv_url = _get_safe_url(inv)
209-
logger.info(__('loading intersphinx inventory from %s...'), safe_inv_url)
210-
try:
211-
invdata = fetch_inventory(app, uri, inv)
212-
except Exception as err:
213-
failures.append(err.args)
214-
continue
215-
if invdata:
216-
cache[uri] = name, now, invdata
217-
return True
218-
return False
219-
finally:
220-
if failures == []:
221-
pass
222-
elif len(failures) < len(invs):
223-
logger.info(__("encountered some issues with some of the inventories,"
224-
" but they had working alternatives:"))
225-
for fail in failures:
226-
logger.info(*fail)
227-
else:
228-
issues = '\n'.join(f[0] % f[1:] for f in failures)
229-
logger.warning(__("failed to reach any of the inventories "
230-
"with the following issues:") + "\n" + issues)
231-
232-
233-
def load_mappings(app: Sphinx) -> None:
234-
"""Load all intersphinx mappings into the environment."""
235-
now = int(time.time())
236-
inventories = InventoryAdapter(app.builder.env)
237-
intersphinx_cache: dict[str, InventoryCacheEntry] = inventories.cache
238-
239-
with concurrent.futures.ThreadPoolExecutor() as pool:
240-
futures = []
241-
name: str | None
242-
uri: str
243-
invs: tuple[str | None, ...]
244-
for name, (uri, invs) in app.config.intersphinx_mapping.values():
245-
futures.append(pool.submit(
246-
fetch_inventory_group, name, uri, invs, intersphinx_cache, app, now,
247-
))
248-
updated = [f.result() for f in concurrent.futures.as_completed(futures)]
249-
250-
if any(updated):
251-
inventories.clear()
252-
253-
# Duplicate values in different inventories will shadow each
254-
# other; which one will override which can vary between builds
255-
# since they are specified using an unordered dict. To make
256-
# it more consistent, we sort the named inventories and then
257-
# add the unnamed inventories last. This means that the
258-
# unnamed inventories will shadow the named ones but the named
259-
# ones can still be accessed when the name is specified.
260-
named_vals = []
261-
unnamed_vals = []
262-
for name, _expiry, invdata in intersphinx_cache.values():
263-
if name:
264-
named_vals.append((name, invdata))
265-
else:
266-
unnamed_vals.append((name, invdata))
267-
for name, invdata in sorted(named_vals) + unnamed_vals:
268-
if name:
269-
inventories.named_inventory[name] = invdata
270-
for type, objects in invdata.items():
271-
inventories.main_inventory.setdefault(type, {}).update(objects)
272-
273-
27477
def _create_element_from_result(domain: Domain, inv_name: str | None,
27578
data: InventoryItem,
27679
node: pending_xref, contnode: TextElement) -> nodes.reference:
@@ -744,39 +547,6 @@ def install_dispatcher(app: Sphinx, docname: str, source: list[str]) -> None:
744547
dispatcher.enable()
745548

746549

747-
def normalize_intersphinx_mapping(app: Sphinx, config: Config) -> None:
748-
for key, value in config.intersphinx_mapping.copy().items():
749-
try:
750-
if isinstance(value, (list, tuple)):
751-
# new format
752-
name, (uri, inv) = key, value
753-
if not isinstance(name, str):
754-
logger.warning(__('intersphinx identifier %r is not string. Ignored'),
755-
name)
756-
config.intersphinx_mapping.pop(key)
757-
continue
758-
else:
759-
# old format, no name
760-
# xref RemovedInSphinx80Warning
761-
name, uri, inv = None, key, value
762-
msg = (
763-
"The pre-Sphinx 1.0 'intersphinx_mapping' format is "
764-
"deprecated and will be removed in Sphinx 8. Update to the "
765-
"current format as described in the documentation. "
766-
f"Hint: \"intersphinx_mapping = {{'<name>': {(uri, inv)!r}}}\"."
767-
"https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#confval-intersphinx_mapping" # NoQA: E501
768-
)
769-
logger.warning(msg)
770-
771-
if not isinstance(inv, tuple):
772-
config.intersphinx_mapping[key] = (name, (uri, (inv,)))
773-
else:
774-
config.intersphinx_mapping[key] = (name, (uri, inv))
775-
except Exception as exc:
776-
logger.warning(__('Failed to read intersphinx_mapping[%s], ignored: %r'), key, exc)
777-
config.intersphinx_mapping.pop(key)
778-
779-
780550
def setup(app: Sphinx) -> ExtensionMetadata:
781551
app.add_config_value('intersphinx_mapping', {}, 'env')
782552
app.add_config_value('intersphinx_cache_limit', 5, '')

sphinx/ext/intersphinx/_cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import sys
66

7-
from sphinx.ext.intersphinx import fetch_inventory
7+
from sphinx.ext.intersphinx._load import fetch_inventory
88

99

1010
def inspect_main(argv: list[str], /) -> int:

0 commit comments

Comments
 (0)