|
36 | 36 | 'inspect_main', |
37 | 37 | ) |
38 | 38 |
|
39 | | -import concurrent.futures |
40 | | -import functools |
41 | 39 | import posixpath |
42 | 40 | import re |
43 | | -import time |
44 | | -from os import path |
45 | 41 | from typing import TYPE_CHECKING, cast |
46 | | -from urllib.parse import urlsplit, urlunsplit |
47 | 42 |
|
48 | 43 | from docutils import nodes |
49 | 44 | from docutils.utils import relative_path |
50 | 45 |
|
51 | 46 | import sphinx |
52 | 47 | from sphinx.addnodes import pending_xref |
53 | | -from sphinx.builders.html import INVENTORY_FILENAME |
54 | 48 | from sphinx.deprecation import _deprecation_warning |
55 | 49 | from sphinx.errors import ExtensionError |
56 | 50 | 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 | +) |
57 | 57 | from sphinx.ext.intersphinx._shared import LOGGER as logger |
58 | 58 | from sphinx.ext.intersphinx._shared import InventoryAdapter |
59 | 59 | from sphinx.locale import _, __ |
60 | 60 | from sphinx.transforms.post_transforms import ReferencesResolver |
61 | | -from sphinx.util import requests |
62 | 61 | from sphinx.util.docutils import CustomReSTDispatcher, SphinxRole |
63 | | -from sphinx.util.inventory import InventoryFile |
64 | 62 |
|
65 | 63 | if TYPE_CHECKING: |
66 | 64 | from collections.abc import Iterable |
67 | 65 | from types import ModuleType |
68 | | - from typing import IO, Any |
| 66 | + from typing import Any |
69 | 67 |
|
70 | 68 | from docutils.nodes import Node, TextElement, system_message |
71 | 69 | from docutils.utils import Reporter |
72 | 70 |
|
73 | 71 | from sphinx.application import Sphinx |
74 | | - from sphinx.config import Config |
75 | 72 | from sphinx.domains import Domain |
76 | 73 | from sphinx.environment import BuildEnvironment |
77 | | - from sphinx.ext.intersphinx._shared import InventoryCacheEntry |
78 | 74 | from sphinx.util.typing import ExtensionMetadata, Inventory, InventoryItem, RoleFunction |
79 | 75 |
|
80 | 76 |
|
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 | | - |
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 | | - |
274 | 77 | def _create_element_from_result(domain: Domain, inv_name: str | None, |
275 | 78 | data: InventoryItem, |
276 | 79 | node: pending_xref, contnode: TextElement) -> nodes.reference: |
@@ -744,39 +547,6 @@ def install_dispatcher(app: Sphinx, docname: str, source: list[str]) -> None: |
744 | 547 | dispatcher.enable() |
745 | 548 |
|
746 | 549 |
|
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 | | - |
780 | 550 | def setup(app: Sphinx) -> ExtensionMetadata: |
781 | 551 | app.add_config_value('intersphinx_mapping', {}, 'env') |
782 | 552 | app.add_config_value('intersphinx_cache_limit', 5, '') |
|
0 commit comments