|
29 | 29 | import sys |
30 | 30 | import time |
31 | 31 | from os import path |
32 | | -from typing import IO, Any, Dict, List, Tuple |
| 32 | +from typing import IO, Any, Dict, List, Optional, Tuple |
33 | 33 | from urllib.parse import urlsplit, urlunsplit |
34 | 34 |
|
35 | 35 | from docutils import nodes |
36 | | -from docutils.nodes import TextElement |
| 36 | +from docutils.nodes import Element, TextElement |
37 | 37 | from docutils.utils import relative_path |
38 | 38 |
|
39 | 39 | import sphinx |
40 | 40 | from sphinx.addnodes import pending_xref |
41 | 41 | from sphinx.application import Sphinx |
42 | 42 | from sphinx.builders.html import INVENTORY_FILENAME |
43 | 43 | from sphinx.config import Config |
| 44 | +from sphinx.domains import Domain |
44 | 45 | from sphinx.environment import BuildEnvironment |
45 | 46 | from sphinx.locale import _, __ |
46 | 47 | from sphinx.util import logging, requests |
47 | 48 | from sphinx.util.inventory import InventoryFile |
48 | | -from sphinx.util.typing import Inventory |
| 49 | +from sphinx.util.typing import Inventory, InventoryItem |
49 | 50 |
|
50 | 51 | logger = logging.getLogger(__name__) |
51 | 52 |
|
@@ -258,105 +259,211 @@ def load_mappings(app: Sphinx) -> None: |
258 | 259 | inventories.main_inventory.setdefault(type, {}).update(objects) |
259 | 260 |
|
260 | 261 |
|
261 | | -def missing_reference(app: Sphinx, env: BuildEnvironment, node: pending_xref, |
262 | | - contnode: TextElement) -> nodes.reference: |
263 | | - """Attempt to resolve a missing reference via intersphinx references.""" |
264 | | - target = node['reftarget'] |
265 | | - inventories = InventoryAdapter(env) |
266 | | - objtypes: List[str] = None |
267 | | - if node['reftype'] == 'any': |
268 | | - # we search anything! |
269 | | - objtypes = ['%s:%s' % (domain.name, objtype) |
270 | | - for domain in env.domains.values() |
271 | | - for objtype in domain.object_types] |
272 | | - domain = None |
| 262 | +def _create_element_from_result(domain: Domain, inv_name: Optional[str], |
| 263 | + data: InventoryItem, |
| 264 | + node: pending_xref, contnode: TextElement) -> Element: |
| 265 | + proj, version, uri, dispname = data |
| 266 | + if '://' not in uri and node.get('refdoc'): |
| 267 | + # get correct path in case of subdirectories |
| 268 | + uri = path.join(relative_path(node['refdoc'], '.'), uri) |
| 269 | + if version: |
| 270 | + reftitle = _('(in %s v%s)') % (proj, version) |
273 | 271 | else: |
274 | | - domain = node.get('refdomain') |
275 | | - if not domain: |
| 272 | + reftitle = _('(in %s)') % (proj,) |
| 273 | + newnode = nodes.reference('', '', internal=False, refuri=uri, reftitle=reftitle) |
| 274 | + if node.get('refexplicit'): |
| 275 | + # use whatever title was given |
| 276 | + newnode.append(contnode) |
| 277 | + elif dispname == '-' or \ |
| 278 | + (domain.name == 'std' and node['reftype'] == 'keyword'): |
| 279 | + # use whatever title was given, but strip prefix |
| 280 | + title = contnode.astext() |
| 281 | + if inv_name is not None and title.startswith(inv_name + ':'): |
| 282 | + newnode.append(contnode.__class__(title[len(inv_name) + 1:], |
| 283 | + title[len(inv_name) + 1:])) |
| 284 | + else: |
| 285 | + newnode.append(contnode) |
| 286 | + else: |
| 287 | + # else use the given display name (used for :ref:) |
| 288 | + newnode.append(contnode.__class__(dispname, dispname)) |
| 289 | + return newnode |
| 290 | + |
| 291 | + |
| 292 | +def _resolve_reference_in_domain_by_target( |
| 293 | + inv_name: Optional[str], inventory: Inventory, |
| 294 | + domain: Domain, objtypes: List[str], |
| 295 | + target: str, |
| 296 | + node: pending_xref, contnode: TextElement) -> Optional[Element]: |
| 297 | + for objtype in objtypes: |
| 298 | + if objtype not in inventory: |
| 299 | + # Continue if there's nothing of this kind in the inventory |
| 300 | + continue |
| 301 | + |
| 302 | + if target in inventory[objtype]: |
| 303 | + # Case sensitive match, use it |
| 304 | + data = inventory[objtype][target] |
| 305 | + elif objtype == 'std:term': |
| 306 | + # Check for potential case insensitive matches for terms only |
| 307 | + target_lower = target.lower() |
| 308 | + insensitive_matches = list(filter(lambda k: k.lower() == target_lower, |
| 309 | + inventory[objtype].keys())) |
| 310 | + if insensitive_matches: |
| 311 | + data = inventory[objtype][insensitive_matches[0]] |
| 312 | + else: |
| 313 | + # No case insensitive match either, continue to the next candidate |
| 314 | + continue |
| 315 | + else: |
| 316 | + # Could reach here if we're not a term but have a case insensitive match. |
| 317 | + # This is a fix for terms specifically, but potentially should apply to |
| 318 | + # other types. |
| 319 | + continue |
| 320 | + return _create_element_from_result(domain, inv_name, data, node, contnode) |
| 321 | + return None |
| 322 | + |
| 323 | + |
| 324 | +def _resolve_reference_in_domain(env: BuildEnvironment, |
| 325 | + inv_name: Optional[str], inventory: Inventory, |
| 326 | + honor_disabled_refs: bool, |
| 327 | + domain: Domain, objtypes: List[str], |
| 328 | + node: pending_xref, contnode: TextElement |
| 329 | + ) -> Optional[Element]: |
| 330 | + # we adjust the object types for backwards compatibility |
| 331 | + if domain.name == 'std' and 'cmdoption' in objtypes: |
| 332 | + # until Sphinx-1.6, cmdoptions are stored as std:option |
| 333 | + objtypes.append('option') |
| 334 | + if domain.name == 'py' and 'attribute' in objtypes: |
| 335 | + # Since Sphinx-2.1, properties are stored as py:method |
| 336 | + objtypes.append('method') |
| 337 | + |
| 338 | + # the inventory contains domain:type as objtype |
| 339 | + objtypes = ["{}:{}".format(domain.name, t) for t in objtypes] |
| 340 | + |
| 341 | + # now that the objtypes list is complete we can remove the disabled ones |
| 342 | + if honor_disabled_refs: |
| 343 | + disabled = env.config.intersphinx_disabled_reftypes |
| 344 | + objtypes = [o for o in objtypes if o not in disabled] |
| 345 | + |
| 346 | + # without qualification |
| 347 | + res = _resolve_reference_in_domain_by_target(inv_name, inventory, domain, objtypes, |
| 348 | + node['reftarget'], node, contnode) |
| 349 | + if res is not None: |
| 350 | + return res |
| 351 | + |
| 352 | + # try with qualification of the current scope instead |
| 353 | + full_qualified_name = domain.get_full_qualified_name(node) |
| 354 | + if full_qualified_name is None: |
| 355 | + return None |
| 356 | + return _resolve_reference_in_domain_by_target(inv_name, inventory, domain, objtypes, |
| 357 | + full_qualified_name, node, contnode) |
| 358 | + |
| 359 | + |
| 360 | +def _resolve_reference(env: BuildEnvironment, inv_name: Optional[str], inventory: Inventory, |
| 361 | + honor_disabled_refs: bool, |
| 362 | + node: pending_xref, contnode: TextElement) -> Optional[Element]: |
| 363 | + # disabling should only be done if no inventory is given |
| 364 | + honor_disabled_refs = honor_disabled_refs and inv_name is None |
| 365 | + |
| 366 | + if honor_disabled_refs and '*' in env.config.intersphinx_disabled_reftypes: |
| 367 | + return None |
| 368 | + |
| 369 | + typ = node['reftype'] |
| 370 | + if typ == 'any': |
| 371 | + for domain_name, domain in env.domains.items(): |
| 372 | + if honor_disabled_refs \ |
| 373 | + and (domain_name + ":*") in env.config.intersphinx_disabled_reftypes: |
| 374 | + continue |
| 375 | + objtypes = list(domain.object_types) |
| 376 | + res = _resolve_reference_in_domain(env, inv_name, inventory, |
| 377 | + honor_disabled_refs, |
| 378 | + domain, objtypes, |
| 379 | + node, contnode) |
| 380 | + if res is not None: |
| 381 | + return res |
| 382 | + return None |
| 383 | + else: |
| 384 | + domain_name = node.get('refdomain') |
| 385 | + if not domain_name: |
276 | 386 | # only objects in domains are in the inventory |
277 | 387 | return None |
278 | | - objtypes = env.get_domain(domain).objtypes_for_role(node['reftype']) |
| 388 | + if honor_disabled_refs \ |
| 389 | + and (domain_name + ":*") in env.config.intersphinx_disabled_reftypes: |
| 390 | + return None |
| 391 | + domain = env.get_domain(domain_name) |
| 392 | + objtypes = domain.objtypes_for_role(typ) |
279 | 393 | if not objtypes: |
280 | 394 | return None |
281 | | - objtypes = ['%s:%s' % (domain, objtype) for objtype in objtypes] |
282 | | - if 'std:cmdoption' in objtypes: |
283 | | - # until Sphinx-1.6, cmdoptions are stored as std:option |
284 | | - objtypes.append('std:option') |
285 | | - if 'py:attribute' in objtypes: |
286 | | - # Since Sphinx-2.1, properties are stored as py:method |
287 | | - objtypes.append('py:method') |
288 | | - |
289 | | - to_try = [(inventories.main_inventory, target)] |
290 | | - if domain: |
291 | | - full_qualified_name = env.get_domain(domain).get_full_qualified_name(node) |
292 | | - if full_qualified_name: |
293 | | - to_try.append((inventories.main_inventory, full_qualified_name)) |
294 | | - in_set = None |
295 | | - if ':' in target: |
296 | | - # first part may be the foreign doc set name |
297 | | - setname, newtarget = target.split(':', 1) |
298 | | - if setname in inventories.named_inventory: |
299 | | - in_set = setname |
300 | | - to_try.append((inventories.named_inventory[setname], newtarget)) |
301 | | - if domain: |
302 | | - node['reftarget'] = newtarget |
303 | | - full_qualified_name = env.get_domain(domain).get_full_qualified_name(node) |
304 | | - if full_qualified_name: |
305 | | - to_try.append((inventories.named_inventory[setname], full_qualified_name)) |
306 | | - for inventory, target in to_try: |
307 | | - for objtype in objtypes: |
308 | | - if objtype not in inventory: |
309 | | - # Continue if there's nothing of this kind in the inventory |
310 | | - continue |
311 | | - if target in inventory[objtype]: |
312 | | - # Case sensitive match, use it |
313 | | - proj, version, uri, dispname = inventory[objtype][target] |
314 | | - elif objtype == 'std:term': |
315 | | - # Check for potential case insensitive matches for terms only |
316 | | - target_lower = target.lower() |
317 | | - insensitive_matches = list(filter(lambda k: k.lower() == target_lower, |
318 | | - inventory[objtype].keys())) |
319 | | - if insensitive_matches: |
320 | | - proj, version, uri, dispname = inventory[objtype][insensitive_matches[0]] |
321 | | - else: |
322 | | - # No case insensitive match either, continue to the next candidate |
323 | | - continue |
324 | | - else: |
325 | | - # Could reach here if we're not a term but have a case insensitive match. |
326 | | - # This is a fix for terms specifically, but potentially should apply to |
327 | | - # other types. |
328 | | - continue |
| 395 | + return _resolve_reference_in_domain(env, inv_name, inventory, |
| 396 | + honor_disabled_refs, |
| 397 | + domain, objtypes, |
| 398 | + node, contnode) |
329 | 399 |
|
330 | | - if '://' not in uri and node.get('refdoc'): |
331 | | - # get correct path in case of subdirectories |
332 | | - uri = path.join(relative_path(node['refdoc'], '.'), uri) |
333 | | - if version: |
334 | | - reftitle = _('(in %s v%s)') % (proj, version) |
335 | | - else: |
336 | | - reftitle = _('(in %s)') % (proj,) |
337 | | - newnode = nodes.reference('', '', internal=False, refuri=uri, reftitle=reftitle) |
338 | | - if node.get('refexplicit'): |
339 | | - # use whatever title was given |
340 | | - newnode.append(contnode) |
341 | | - elif dispname == '-' or \ |
342 | | - (domain == 'std' and node['reftype'] == 'keyword'): |
343 | | - # use whatever title was given, but strip prefix |
344 | | - title = contnode.astext() |
345 | | - if in_set and title.startswith(in_set + ':'): |
346 | | - newnode.append(contnode.__class__(title[len(in_set) + 1:], |
347 | | - title[len(in_set) + 1:])) |
348 | | - else: |
349 | | - newnode.append(contnode) |
350 | | - else: |
351 | | - # else use the given display name (used for :ref:) |
352 | | - newnode.append(contnode.__class__(dispname, dispname)) |
353 | | - return newnode |
354 | | - # at least get rid of the ':' in the target if no explicit title given |
355 | | - if in_set is not None and not node.get('refexplicit', True): |
356 | | - if len(contnode) and isinstance(contnode[0], nodes.Text): |
357 | | - contnode[0] = nodes.Text(newtarget, contnode[0].rawsource) |
358 | 400 |
|
359 | | - return None |
| 401 | +def inventory_exists(env: BuildEnvironment, inv_name: str) -> bool: |
| 402 | + return inv_name in InventoryAdapter(env).named_inventory |
| 403 | + |
| 404 | + |
| 405 | +def resolve_reference_in_inventory(env: BuildEnvironment, |
| 406 | + inv_name: str, |
| 407 | + node: pending_xref, contnode: TextElement |
| 408 | + ) -> Optional[Element]: |
| 409 | + """Attempt to resolve a missing reference via intersphinx references. |
| 410 | +
|
| 411 | + Resolution is tried in the given inventory with the target as is. |
| 412 | +
|
| 413 | + Requires ``inventory_exists(env, inv_name)``. |
| 414 | + """ |
| 415 | + assert inventory_exists(env, inv_name) |
| 416 | + return _resolve_reference(env, inv_name, InventoryAdapter(env).named_inventory[inv_name], |
| 417 | + False, node, contnode) |
| 418 | + |
| 419 | + |
| 420 | +def resolve_reference_any_inventory(env: BuildEnvironment, |
| 421 | + honor_disabled_refs: bool, |
| 422 | + node: pending_xref, contnode: TextElement |
| 423 | + ) -> Optional[Element]: |
| 424 | + """Attempt to resolve a missing reference via intersphinx references. |
| 425 | +
|
| 426 | + Resolution is tried with the target as is in any inventory. |
| 427 | + """ |
| 428 | + return _resolve_reference(env, None, InventoryAdapter(env).main_inventory, |
| 429 | + honor_disabled_refs, |
| 430 | + node, contnode) |
| 431 | + |
| 432 | + |
| 433 | +def resolve_reference_detect_inventory(env: BuildEnvironment, |
| 434 | + node: pending_xref, contnode: TextElement |
| 435 | + ) -> Optional[Element]: |
| 436 | + """Attempt to resolve a missing reference via intersphinx references. |
| 437 | +
|
| 438 | + Resolution is tried first with the target as is in any inventory. |
| 439 | + If this does not succeed, then the target is split by the first ``:``, |
| 440 | + to form ``inv_name:newtarget``. If ``inv_name`` is a named inventory, then resolution |
| 441 | + is tried in that inventory with the new target. |
| 442 | + """ |
| 443 | + |
| 444 | + # ordinary direct lookup, use data as is |
| 445 | + res = resolve_reference_any_inventory(env, True, node, contnode) |
| 446 | + if res is not None: |
| 447 | + return res |
| 448 | + |
| 449 | + # try splitting the target into 'inv_name:target' |
| 450 | + target = node['reftarget'] |
| 451 | + if ':' not in target: |
| 452 | + return None |
| 453 | + inv_name, newtarget = target.split(':', 1) |
| 454 | + if not inventory_exists(env, inv_name): |
| 455 | + return None |
| 456 | + node['reftarget'] = newtarget |
| 457 | + res_inv = resolve_reference_in_inventory(env, inv_name, node, contnode) |
| 458 | + node['reftarget'] = target |
| 459 | + return res_inv |
| 460 | + |
| 461 | + |
| 462 | +def missing_reference(app: Sphinx, env: BuildEnvironment, node: pending_xref, |
| 463 | + contnode: TextElement) -> Optional[Element]: |
| 464 | + """Attempt to resolve a missing reference via intersphinx references.""" |
| 465 | + |
| 466 | + return resolve_reference_detect_inventory(env, node, contnode) |
360 | 467 |
|
361 | 468 |
|
362 | 469 | def normalize_intersphinx_mapping(app: Sphinx, config: Config) -> None: |
@@ -387,6 +494,7 @@ def setup(app: Sphinx) -> Dict[str, Any]: |
387 | 494 | app.add_config_value('intersphinx_mapping', {}, True) |
388 | 495 | app.add_config_value('intersphinx_cache_limit', 5, False) |
389 | 496 | app.add_config_value('intersphinx_timeout', None, False) |
| 497 | + app.add_config_value('intersphinx_disabled_reftypes', [], True) |
390 | 498 | app.connect('config-inited', normalize_intersphinx_mapping, priority=800) |
391 | 499 | app.connect('builder-inited', load_mappings) |
392 | 500 | app.connect('missing-reference', missing_reference) |
|
0 commit comments