diff --git a/CHANGES b/CHANGES index 414fe0be147..bdd2fb152ce 100644 --- a/CHANGES +++ b/CHANGES @@ -47,6 +47,11 @@ Features added * #9695: More CSS classes on Javascript domain descriptions * #9683: Revert the removal of ``add_stylesheet()`` API. It will be kept until the Sphinx-6.0 release +* #2068, add :confval:`intersphinx_disabled_reftypes` for disabling + interphinx resolution of cross-references that do not have an explicit + inventory specification. Specific types of cross-references can be disabled, + e.g., ``std:doc`` or all cross-references in a specific domain, + e.g., ``std:*``. Bugs fixed ---------- @@ -85,6 +90,8 @@ Bugs fixed * #9733: Fix for logging handler flushing warnings in the middle of the docs build * #9656: Fix warnings without subtype being incorrectly suppressed +* Intersphinx, for unresolved references with an explicit inventory, + e.g., ``proj:myFunc``, leave the inventory prefix in the unresolved text. Testing -------- diff --git a/doc/usage/extensions/intersphinx.rst b/doc/usage/extensions/intersphinx.rst index 478ddb7ae31..a3e65bed642 100644 --- a/doc/usage/extensions/intersphinx.rst +++ b/doc/usage/extensions/intersphinx.rst @@ -148,6 +148,35 @@ linking: exception is raised if the server has not issued a response for timeout seconds. +.. confval:: intersphinx_disabled_reftypes + + .. versionadded:: 4.3 + + A list of strings being either: + + - the name of a specific reference type in a domain, + e.g., ``std:doc``, ``py:func``, or ``cpp:class``, + - the name of a domain, and a wildcard, e.g., + ``std:*``, ``py:*``, or ``cpp:*``, or + - simply a wildcard ``*``. + + The default value is an empty list. + + When a cross-reference without an explicit inventory specification is being + resolved by intersphinx, skip resolution if it matches one of the + specifications in this list. + + For example, with ``intersphinx_disabled_reftypes = ['std:doc']`` + a cross-reference ``:doc:`installation``` will not be attempted to be + resolved by intersphinx, but ``:doc:`otherbook:installation``` will be + attempted to be resolved in the inventory named ``otherbook`` in + :confval:`intersphinx_mapping`. + At the same time, all cross-references generated in, e.g., Python, + declarations will still be attempted to be resolved by intersphinx. + + If ``*`` is in the list of domains, then no references without an explicit + inventory will be resolved by intersphinx. + Showing all links of an Intersphinx mapping file ------------------------------------------------ diff --git a/sphinx/ext/intersphinx.py b/sphinx/ext/intersphinx.py index 4795d1ae25b..d5f92d6fba0 100644 --- a/sphinx/ext/intersphinx.py +++ b/sphinx/ext/intersphinx.py @@ -29,11 +29,11 @@ import sys import time from os import path -from typing import IO, Any, Dict, List, Tuple +from typing import IO, Any, Dict, List, Optional, Tuple from urllib.parse import urlsplit, urlunsplit from docutils import nodes -from docutils.nodes import TextElement +from docutils.nodes import Element, TextElement from docutils.utils import relative_path import sphinx @@ -41,11 +41,12 @@ from sphinx.application import Sphinx from sphinx.builders.html import INVENTORY_FILENAME from sphinx.config import Config +from sphinx.domains import Domain from sphinx.environment import BuildEnvironment from sphinx.locale import _, __ from sphinx.util import logging, requests from sphinx.util.inventory import InventoryFile -from sphinx.util.typing import Inventory +from sphinx.util.typing import Inventory, InventoryItem logger = logging.getLogger(__name__) @@ -258,105 +259,211 @@ def load_mappings(app: Sphinx) -> None: inventories.main_inventory.setdefault(type, {}).update(objects) -def missing_reference(app: Sphinx, env: BuildEnvironment, node: pending_xref, - contnode: TextElement) -> nodes.reference: - """Attempt to resolve a missing reference via intersphinx references.""" - target = node['reftarget'] - inventories = InventoryAdapter(env) - objtypes: List[str] = None - if node['reftype'] == 'any': - # we search anything! - objtypes = ['%s:%s' % (domain.name, objtype) - for domain in env.domains.values() - for objtype in domain.object_types] - domain = None +def _create_element_from_result(domain: Domain, inv_name: Optional[str], + data: InventoryItem, + node: pending_xref, contnode: TextElement) -> Element: + proj, version, uri, dispname = data + if '://' not in uri and node.get('refdoc'): + # get correct path in case of subdirectories + uri = path.join(relative_path(node['refdoc'], '.'), uri) + if version: + reftitle = _('(in %s v%s)') % (proj, version) else: - domain = node.get('refdomain') - if not domain: + reftitle = _('(in %s)') % (proj,) + newnode = nodes.reference('', '', internal=False, refuri=uri, reftitle=reftitle) + if node.get('refexplicit'): + # use whatever title was given + newnode.append(contnode) + elif dispname == '-' or \ + (domain.name == 'std' and node['reftype'] == 'keyword'): + # use whatever title was given, but strip prefix + title = contnode.astext() + if inv_name is not None and title.startswith(inv_name + ':'): + newnode.append(contnode.__class__(title[len(inv_name) + 1:], + title[len(inv_name) + 1:])) + else: + newnode.append(contnode) + else: + # else use the given display name (used for :ref:) + newnode.append(contnode.__class__(dispname, dispname)) + return newnode + + +def _resolve_reference_in_domain_by_target( + inv_name: Optional[str], inventory: Inventory, + domain: Domain, objtypes: List[str], + target: str, + node: pending_xref, contnode: TextElement) -> Optional[Element]: + for objtype in objtypes: + if objtype not in inventory: + # Continue if there's nothing of this kind in the inventory + continue + + if target in inventory[objtype]: + # Case sensitive match, use it + data = inventory[objtype][target] + elif objtype == 'std:term': + # Check for potential case insensitive matches for terms only + target_lower = target.lower() + insensitive_matches = list(filter(lambda k: k.lower() == target_lower, + inventory[objtype].keys())) + if insensitive_matches: + data = inventory[objtype][insensitive_matches[0]] + else: + # No case insensitive match either, continue to the next candidate + continue + else: + # Could reach here if we're not a term but have a case insensitive match. + # This is a fix for terms specifically, but potentially should apply to + # other types. + continue + return _create_element_from_result(domain, inv_name, data, node, contnode) + return None + + +def _resolve_reference_in_domain(env: BuildEnvironment, + inv_name: Optional[str], inventory: Inventory, + honor_disabled_refs: bool, + domain: Domain, objtypes: List[str], + node: pending_xref, contnode: TextElement + ) -> Optional[Element]: + # we adjust the object types for backwards compatibility + if domain.name == 'std' and 'cmdoption' in objtypes: + # until Sphinx-1.6, cmdoptions are stored as std:option + objtypes.append('option') + if domain.name == 'py' and 'attribute' in objtypes: + # Since Sphinx-2.1, properties are stored as py:method + objtypes.append('method') + + # the inventory contains domain:type as objtype + objtypes = ["{}:{}".format(domain.name, t) for t in objtypes] + + # now that the objtypes list is complete we can remove the disabled ones + if honor_disabled_refs: + disabled = env.config.intersphinx_disabled_reftypes + objtypes = [o for o in objtypes if o not in disabled] + + # without qualification + res = _resolve_reference_in_domain_by_target(inv_name, inventory, domain, objtypes, + node['reftarget'], node, contnode) + if res is not None: + return res + + # try with qualification of the current scope instead + full_qualified_name = domain.get_full_qualified_name(node) + if full_qualified_name is None: + return None + return _resolve_reference_in_domain_by_target(inv_name, inventory, domain, objtypes, + full_qualified_name, node, contnode) + + +def _resolve_reference(env: BuildEnvironment, inv_name: Optional[str], inventory: Inventory, + honor_disabled_refs: bool, + node: pending_xref, contnode: TextElement) -> Optional[Element]: + # disabling should only be done if no inventory is given + honor_disabled_refs = honor_disabled_refs and inv_name is None + + if honor_disabled_refs and '*' in env.config.intersphinx_disabled_reftypes: + return None + + typ = node['reftype'] + if typ == 'any': + for domain_name, domain in env.domains.items(): + if honor_disabled_refs \ + and (domain_name + ":*") in env.config.intersphinx_disabled_reftypes: + continue + objtypes = list(domain.object_types) + res = _resolve_reference_in_domain(env, inv_name, inventory, + honor_disabled_refs, + domain, objtypes, + node, contnode) + if res is not None: + return res + return None + else: + domain_name = node.get('refdomain') + if not domain_name: # only objects in domains are in the inventory return None - objtypes = env.get_domain(domain).objtypes_for_role(node['reftype']) + if honor_disabled_refs \ + and (domain_name + ":*") in env.config.intersphinx_disabled_reftypes: + return None + domain = env.get_domain(domain_name) + objtypes = domain.objtypes_for_role(typ) if not objtypes: return None - objtypes = ['%s:%s' % (domain, objtype) for objtype in objtypes] - if 'std:cmdoption' in objtypes: - # until Sphinx-1.6, cmdoptions are stored as std:option - objtypes.append('std:option') - if 'py:attribute' in objtypes: - # Since Sphinx-2.1, properties are stored as py:method - objtypes.append('py:method') - - to_try = [(inventories.main_inventory, target)] - if domain: - full_qualified_name = env.get_domain(domain).get_full_qualified_name(node) - if full_qualified_name: - to_try.append((inventories.main_inventory, full_qualified_name)) - in_set = None - if ':' in target: - # first part may be the foreign doc set name - setname, newtarget = target.split(':', 1) - if setname in inventories.named_inventory: - in_set = setname - to_try.append((inventories.named_inventory[setname], newtarget)) - if domain: - node['reftarget'] = newtarget - full_qualified_name = env.get_domain(domain).get_full_qualified_name(node) - if full_qualified_name: - to_try.append((inventories.named_inventory[setname], full_qualified_name)) - for inventory, target in to_try: - for objtype in objtypes: - if objtype not in inventory: - # Continue if there's nothing of this kind in the inventory - continue - if target in inventory[objtype]: - # Case sensitive match, use it - proj, version, uri, dispname = inventory[objtype][target] - elif objtype == 'std:term': - # Check for potential case insensitive matches for terms only - target_lower = target.lower() - insensitive_matches = list(filter(lambda k: k.lower() == target_lower, - inventory[objtype].keys())) - if insensitive_matches: - proj, version, uri, dispname = inventory[objtype][insensitive_matches[0]] - else: - # No case insensitive match either, continue to the next candidate - continue - else: - # Could reach here if we're not a term but have a case insensitive match. - # This is a fix for terms specifically, but potentially should apply to - # other types. - continue + return _resolve_reference_in_domain(env, inv_name, inventory, + honor_disabled_refs, + domain, objtypes, + node, contnode) - if '://' not in uri and node.get('refdoc'): - # get correct path in case of subdirectories - uri = path.join(relative_path(node['refdoc'], '.'), uri) - if version: - reftitle = _('(in %s v%s)') % (proj, version) - else: - reftitle = _('(in %s)') % (proj,) - newnode = nodes.reference('', '', internal=False, refuri=uri, reftitle=reftitle) - if node.get('refexplicit'): - # use whatever title was given - newnode.append(contnode) - elif dispname == '-' or \ - (domain == 'std' and node['reftype'] == 'keyword'): - # use whatever title was given, but strip prefix - title = contnode.astext() - if in_set and title.startswith(in_set + ':'): - newnode.append(contnode.__class__(title[len(in_set) + 1:], - title[len(in_set) + 1:])) - else: - newnode.append(contnode) - else: - # else use the given display name (used for :ref:) - newnode.append(contnode.__class__(dispname, dispname)) - return newnode - # at least get rid of the ':' in the target if no explicit title given - if in_set is not None and not node.get('refexplicit', True): - if len(contnode) and isinstance(contnode[0], nodes.Text): - contnode[0] = nodes.Text(newtarget, contnode[0].rawsource) - return None +def inventory_exists(env: BuildEnvironment, inv_name: str) -> bool: + return inv_name in InventoryAdapter(env).named_inventory + + +def resolve_reference_in_inventory(env: BuildEnvironment, + inv_name: str, + node: pending_xref, contnode: TextElement + ) -> Optional[Element]: + """Attempt to resolve a missing reference via intersphinx references. + + Resolution is tried in the given inventory with the target as is. + + Requires ``inventory_exists(env, inv_name)``. + """ + assert inventory_exists(env, inv_name) + return _resolve_reference(env, inv_name, InventoryAdapter(env).named_inventory[inv_name], + False, node, contnode) + + +def resolve_reference_any_inventory(env: BuildEnvironment, + honor_disabled_refs: bool, + node: pending_xref, contnode: TextElement + ) -> Optional[Element]: + """Attempt to resolve a missing reference via intersphinx references. + + Resolution is tried with the target as is in any inventory. + """ + return _resolve_reference(env, None, InventoryAdapter(env).main_inventory, + honor_disabled_refs, + node, contnode) + + +def resolve_reference_detect_inventory(env: BuildEnvironment, + node: pending_xref, contnode: TextElement + ) -> Optional[Element]: + """Attempt to resolve a missing reference via intersphinx references. + + Resolution is tried first with the target as is in any inventory. + If this does not succeed, then the target is split by the first ``:``, + to form ``inv_name:newtarget``. If ``inv_name`` is a named inventory, then resolution + is tried in that inventory with the new target. + """ + + # ordinary direct lookup, use data as is + res = resolve_reference_any_inventory(env, True, node, contnode) + if res is not None: + return res + + # try splitting the target into 'inv_name:target' + target = node['reftarget'] + if ':' not in target: + return None + inv_name, newtarget = target.split(':', 1) + if not inventory_exists(env, inv_name): + return None + node['reftarget'] = newtarget + res_inv = resolve_reference_in_inventory(env, inv_name, node, contnode) + node['reftarget'] = target + return res_inv + + +def missing_reference(app: Sphinx, env: BuildEnvironment, node: pending_xref, + contnode: TextElement) -> Optional[Element]: + """Attempt to resolve a missing reference via intersphinx references.""" + + return resolve_reference_detect_inventory(env, node, contnode) def normalize_intersphinx_mapping(app: Sphinx, config: Config) -> None: @@ -387,6 +494,7 @@ def setup(app: Sphinx) -> Dict[str, Any]: app.add_config_value('intersphinx_mapping', {}, True) app.add_config_value('intersphinx_cache_limit', 5, False) app.add_config_value('intersphinx_timeout', None, False) + app.add_config_value('intersphinx_disabled_reftypes', [], True) app.connect('config-inited', normalize_intersphinx_mapping, priority=800) app.connect('builder-inited', load_mappings) app.connect('missing-reference', missing_reference) diff --git a/sphinx/templates/quickstart/conf.py_t b/sphinx/templates/quickstart/conf.py_t index f1da41c4a64..07a1aa86750 100644 --- a/sphinx/templates/quickstart/conf.py_t +++ b/sphinx/templates/quickstart/conf.py_t @@ -108,6 +108,7 @@ html_static_path = ['{{ dot }}static'] intersphinx_mapping = { 'python': ('https://docs.python.org/3', None), } + {%- endif %} {%- if 'sphinx.ext.todo' in extensions %} diff --git a/sphinx/util/typing.py b/sphinx/util/typing.py index a2ab5f93102..e1972d86d78 100644 --- a/sphinx/util/typing.py +++ b/sphinx/util/typing.py @@ -70,7 +70,8 @@ def _evaluate(self, globalns: Dict, localns: Dict) -> Any: TitleGetter = Callable[[nodes.Node], str] # inventory data on memory -Inventory = Dict[str, Dict[str, Tuple[str, str, str, str]]] +InventoryItem = Tuple[str, str, str, str] +Inventory = Dict[str, Dict[str, InventoryItem]] def get_type_hints(obj: Any, globalns: Dict = None, localns: Dict = None) -> Dict[str, Any]: diff --git a/tests/test_ext_intersphinx.py b/tests/test_ext_intersphinx.py index 28b5e63b150..e820730a19c 100644 --- a/tests/test_ext_intersphinx.py +++ b/tests/test_ext_intersphinx.py @@ -42,6 +42,12 @@ def reference_check(app, *args, **kwds): return missing_reference(app, app.env, node, contnode) +def set_config(app, mapping): + app.config.intersphinx_mapping = mapping + app.config.intersphinx_cache_limit = 0 + app.config.intersphinx_disabled_reftypes = [] + + @mock.patch('sphinx.ext.intersphinx.InventoryFile') @mock.patch('sphinx.ext.intersphinx._read_from_url') def test_fetch_inventory_redirection(_read_from_url, InventoryFile, app, status, warning): @@ -90,13 +96,12 @@ def test_fetch_inventory_redirection(_read_from_url, InventoryFile, app, status, def test_missing_reference(tempdir, app, status, warning): inv_file = tempdir / 'inventory' inv_file.write_bytes(inventory_v2) - app.config.intersphinx_mapping = { + set_config(app, { 'https://docs.python.org/': inv_file, 'py3k': ('https://docs.python.org/py3k/', inv_file), 'py3krel': ('py3k', inv_file), # relative path 'py3krelparent': ('../../py3k', inv_file), # relative path, parent dir - } - app.config.intersphinx_cache_limit = 0 + }) # load the inventory and check if it's done correctly normalize_intersphinx_mapping(app, app.config) @@ -133,12 +138,12 @@ def test_missing_reference(tempdir, app, status, warning): refexplicit=True) assert rn[0].astext() == 'py3k:module2' - # prefix given, target not found and nonexplicit title: prefix is stripped + # prefix given, target not found and nonexplicit title: prefix is not stripped node, contnode = fake_node('py', 'mod', 'py3k:unknown', 'py3k:unknown', refexplicit=False) rn = missing_reference(app, app.env, node, contnode) assert rn is None - assert contnode[0].astext() == 'unknown' + assert contnode[0].astext() == 'py3k:unknown' # prefix given, target not found and explicit title: nothing is changed node, contnode = fake_node('py', 'mod', 'py3k:unknown', 'py3k:unknown', @@ -169,10 +174,9 @@ def test_missing_reference(tempdir, app, status, warning): def test_missing_reference_pydomain(tempdir, app, status, warning): inv_file = tempdir / 'inventory' inv_file.write_bytes(inventory_v2) - app.config.intersphinx_mapping = { + set_config(app, { 'https://docs.python.org/': inv_file, - } - app.config.intersphinx_cache_limit = 0 + }) # load the inventory and check if it's done correctly normalize_intersphinx_mapping(app, app.config) @@ -210,10 +214,9 @@ def test_missing_reference_pydomain(tempdir, app, status, warning): def test_missing_reference_stddomain(tempdir, app, status, warning): inv_file = tempdir / 'inventory' inv_file.write_bytes(inventory_v2) - app.config.intersphinx_mapping = { + set_config(app, { 'cmd': ('https://docs.python.org/', inv_file), - } - app.config.intersphinx_cache_limit = 0 + }) # load the inventory and check if it's done correctly normalize_intersphinx_mapping(app, app.config) @@ -242,10 +245,9 @@ def test_missing_reference_stddomain(tempdir, app, status, warning): def test_missing_reference_cppdomain(tempdir, app, status, warning): inv_file = tempdir / 'inventory' inv_file.write_bytes(inventory_v2) - app.config.intersphinx_mapping = { + set_config(app, { 'https://docs.python.org/': inv_file, - } - app.config.intersphinx_cache_limit = 0 + }) # load the inventory and check if it's done correctly normalize_intersphinx_mapping(app, app.config) @@ -269,10 +271,9 @@ def test_missing_reference_cppdomain(tempdir, app, status, warning): def test_missing_reference_jsdomain(tempdir, app, status, warning): inv_file = tempdir / 'inventory' inv_file.write_bytes(inventory_v2) - app.config.intersphinx_mapping = { + set_config(app, { 'https://docs.python.org/': inv_file, - } - app.config.intersphinx_cache_limit = 0 + }) # load the inventory and check if it's done correctly normalize_intersphinx_mapping(app, app.config) @@ -291,14 +292,75 @@ def test_missing_reference_jsdomain(tempdir, app, status, warning): assert rn.astext() == 'baz()' +def test_missing_reference_disabled_domain(tempdir, app, status, warning): + inv_file = tempdir / 'inventory' + inv_file.write_bytes(inventory_v2) + set_config(app, { + 'inv': ('https://docs.python.org/', inv_file), + }) + + # load the inventory and check if it's done correctly + normalize_intersphinx_mapping(app, app.config) + load_mappings(app) + + def case(*, term, doc, py): + def assert_(rn, expected): + if expected is None: + assert rn is None + else: + assert rn.astext() == expected + + kwargs = {} + + node, contnode = fake_node('std', 'term', 'a term', 'a term', **kwargs) + rn = missing_reference(app, app.env, node, contnode) + assert_(rn, 'a term' if term else None) + + node, contnode = fake_node('std', 'term', 'inv:a term', 'a term', **kwargs) + rn = missing_reference(app, app.env, node, contnode) + assert_(rn, 'a term') + + node, contnode = fake_node('std', 'doc', 'docname', 'docname', **kwargs) + rn = missing_reference(app, app.env, node, contnode) + assert_(rn, 'docname' if doc else None) + + node, contnode = fake_node('std', 'doc', 'inv:docname', 'docname', **kwargs) + rn = missing_reference(app, app.env, node, contnode) + assert_(rn, 'docname') + + # an arbitrary ref in another domain + node, contnode = fake_node('py', 'func', 'module1.func', 'func()', **kwargs) + rn = missing_reference(app, app.env, node, contnode) + assert_(rn, 'func()' if py else None) + + node, contnode = fake_node('py', 'func', 'inv:module1.func', 'func()', **kwargs) + rn = missing_reference(app, app.env, node, contnode) + assert_(rn, 'func()') + + # the base case, everything should resolve + assert app.config.intersphinx_disabled_reftypes == [] + case(term=True, doc=True, py=True) + + # disabled a single ref type + app.config.intersphinx_disabled_reftypes = ['std:doc'] + case(term=True, doc=False, py=True) + + # disabled a whole domain + app.config.intersphinx_disabled_reftypes = ['std:*'] + case(term=False, doc=False, py=True) + + # disabled all domains + app.config.intersphinx_disabled_reftypes = ['*'] + case(term=False, doc=False, py=False) + + @pytest.mark.xfail(os.name != 'posix', reason="Path separator mismatch issue") def test_inventory_not_having_version(tempdir, app, status, warning): inv_file = tempdir / 'inventory' inv_file.write_bytes(inventory_v2_not_having_version) - app.config.intersphinx_mapping = { + set_config(app, { 'https://docs.python.org/': inv_file, - } - app.config.intersphinx_cache_limit = 0 + }) # load the inventory and check if it's done correctly normalize_intersphinx_mapping(app, app.config) @@ -318,16 +380,15 @@ def test_load_mappings_warnings(tempdir, app, status, warning): """ inv_file = tempdir / 'inventory' inv_file.write_bytes(inventory_v2) - app.config.intersphinx_mapping = { + set_config(app, { 'https://docs.python.org/': inv_file, 'py3k': ('https://docs.python.org/py3k/', inv_file), 'repoze.workflow': ('http://docs.repoze.org/workflow/', inv_file), 'django-taggit': ('http://django-taggit.readthedocs.org/en/latest/', inv_file), 12345: ('http://www.sphinx-doc.org/en/stable/', inv_file), - } + }) - app.config.intersphinx_cache_limit = 0 # load the inventory and check if it's done correctly normalize_intersphinx_mapping(app, app.config) load_mappings(app) @@ -337,7 +398,7 @@ def test_load_mappings_warnings(tempdir, app, status, warning): def test_load_mappings_fallback(tempdir, app, status, warning): inv_file = tempdir / 'inventory' inv_file.write_bytes(inventory_v2) - app.config.intersphinx_cache_limit = 0 + set_config(app, {}) # connect to invalid path app.config.intersphinx_mapping = {