mirror of
https://github.com/sphinx-doc/sphinx.git
synced 2025-02-25 18:55:22 -06:00
872 lines
33 KiB
Python
872 lines
33 KiB
Python
"""Test the intersphinx extension."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import http.server
|
|
import time
|
|
from typing import TYPE_CHECKING
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
from docutils import nodes
|
|
|
|
from sphinx import addnodes
|
|
from sphinx._cli.util.errors import strip_escape_sequences
|
|
from sphinx.builders.html import INVENTORY_FILENAME
|
|
from sphinx.config import Config
|
|
from sphinx.errors import ConfigError
|
|
from sphinx.ext.intersphinx import setup as intersphinx_setup
|
|
from sphinx.ext.intersphinx._cli import inspect_main
|
|
from sphinx.ext.intersphinx._load import (
|
|
_fetch_inventory,
|
|
_fetch_inventory_group,
|
|
_get_safe_url,
|
|
_InvConfig,
|
|
_strip_basic_auth,
|
|
load_mappings,
|
|
validate_intersphinx_mapping,
|
|
)
|
|
from sphinx.ext.intersphinx._resolve import missing_reference
|
|
from sphinx.ext.intersphinx._shared import _IntersphinxProject
|
|
from sphinx.util.inventory import _InventoryItem
|
|
|
|
from tests.test_util.intersphinx_data import (
|
|
INVENTORY_V2,
|
|
INVENTORY_V2_AMBIGUOUS_TERMS,
|
|
INVENTORY_V2_NO_VERSION,
|
|
)
|
|
from tests.utils import http_server
|
|
|
|
if TYPE_CHECKING:
|
|
from typing import NoReturn
|
|
|
|
from sphinx.ext.intersphinx._shared import InventoryCacheEntry
|
|
from sphinx.util.typing import Inventory
|
|
|
|
|
|
class FakeList(list[str]):
|
|
def __iter__(self) -> NoReturn:
|
|
raise NotImplementedError
|
|
|
|
|
|
def fake_node(domain, type, target, content, **attrs):
|
|
contnode = nodes.emphasis(content, content)
|
|
node = addnodes.pending_xref('')
|
|
node['reftarget'] = target
|
|
node['reftype'] = type
|
|
node['refdomain'] = domain
|
|
node.attributes.update(attrs)
|
|
node += contnode
|
|
return node, contnode
|
|
|
|
|
|
def reference_check(app, *args, **kwds):
|
|
node, contnode = fake_node(*args, **kwds)
|
|
return missing_reference(app, app.env, node, contnode)
|
|
|
|
|
|
def set_config(app, mapping):
|
|
# copy *mapping* so that normalization does not alter it
|
|
app.config.intersphinx_mapping = mapping.copy()
|
|
app.config.intersphinx_cache_limit = 0
|
|
app.config.intersphinx_disabled_reftypes = []
|
|
app.config.intersphinx_timeout = None
|
|
|
|
|
|
@mock.patch('sphinx.ext.intersphinx._load.InventoryFile')
|
|
@mock.patch('sphinx.ext.intersphinx._load.requests.get')
|
|
@pytest.mark.sphinx('html', testroot='root')
|
|
def test_fetch_inventory_redirection(get_request, InventoryFile, app):
|
|
mocked_get = get_request.return_value.__enter__.return_value
|
|
intersphinx_setup(app)
|
|
mocked_get.content = b'# Sphinx inventory version 2'
|
|
|
|
# same uri and inv, not redirected
|
|
mocked_get.url = 'https://hostname/' + INVENTORY_FILENAME
|
|
_fetch_inventory(
|
|
target_uri='https://hostname/',
|
|
inv_location='https://hostname/' + INVENTORY_FILENAME,
|
|
config=_InvConfig.from_config(app.config),
|
|
srcdir=app.srcdir,
|
|
)
|
|
assert 'intersphinx inventory has moved' not in app.status.getvalue()
|
|
assert InventoryFile.loads.call_args[1]['uri'] == 'https://hostname/'
|
|
|
|
# same uri and inv, redirected
|
|
app.status.seek(0)
|
|
app.status.truncate(0)
|
|
mocked_get.url = 'https://hostname/new/' + INVENTORY_FILENAME
|
|
|
|
_fetch_inventory(
|
|
target_uri='https://hostname/',
|
|
inv_location='https://hostname/' + INVENTORY_FILENAME,
|
|
config=_InvConfig.from_config(app.config),
|
|
srcdir=app.srcdir,
|
|
)
|
|
assert app.status.getvalue() == (
|
|
'intersphinx inventory has moved: '
|
|
'https://hostname/%s -> https://hostname/new/%s\n'
|
|
% (INVENTORY_FILENAME, INVENTORY_FILENAME)
|
|
)
|
|
assert InventoryFile.loads.call_args[1]['uri'] == 'https://hostname/new'
|
|
|
|
# different uri and inv, not redirected
|
|
app.status.seek(0)
|
|
app.status.truncate(0)
|
|
mocked_get.url = 'https://hostname/new/' + INVENTORY_FILENAME
|
|
|
|
_fetch_inventory(
|
|
target_uri='https://hostname/',
|
|
inv_location='https://hostname/new/' + INVENTORY_FILENAME,
|
|
config=_InvConfig.from_config(app.config),
|
|
srcdir=app.srcdir,
|
|
)
|
|
assert 'intersphinx inventory has moved' not in app.status.getvalue()
|
|
assert InventoryFile.loads.call_args[1]['uri'] == 'https://hostname/'
|
|
|
|
# different uri and inv, redirected
|
|
app.status.seek(0)
|
|
app.status.truncate(0)
|
|
mocked_get.url = 'https://hostname/other/' + INVENTORY_FILENAME
|
|
|
|
_fetch_inventory(
|
|
target_uri='https://hostname/',
|
|
inv_location='https://hostname/new/' + INVENTORY_FILENAME,
|
|
config=_InvConfig.from_config(app.config),
|
|
srcdir=app.srcdir,
|
|
)
|
|
assert app.status.getvalue() == (
|
|
'intersphinx inventory has moved: '
|
|
'https://hostname/new/%s -> https://hostname/other/%s\n'
|
|
% (INVENTORY_FILENAME, INVENTORY_FILENAME)
|
|
)
|
|
assert InventoryFile.loads.call_args[1]['uri'] == 'https://hostname/'
|
|
|
|
|
|
@pytest.mark.sphinx('html', testroot='root')
|
|
def test_missing_reference(tmp_path, app):
|
|
inv_file = tmp_path / 'inventory'
|
|
inv_file.write_bytes(INVENTORY_V2)
|
|
set_config(
|
|
app,
|
|
{
|
|
'python': ('https://docs.python.org/', str(inv_file)),
|
|
'py3k': ('https://docs.python.org/py3k/', str(inv_file)),
|
|
'py3krel': ('py3k', str(inv_file)), # relative path
|
|
'py3krelparent': ('../../py3k', str(inv_file)), # relative path, parent dir
|
|
},
|
|
)
|
|
|
|
# load the inventory and check if it's done correctly
|
|
validate_intersphinx_mapping(app, app.config)
|
|
load_mappings(app)
|
|
inv = app.env.intersphinx_inventory
|
|
|
|
assert inv['py:module']['module2'] == _InventoryItem(
|
|
project_name='foo',
|
|
project_version='2.0',
|
|
uri='https://docs.python.org/foo.html#module-module2',
|
|
display_name='-',
|
|
)
|
|
|
|
# check resolution when a target is found
|
|
rn = reference_check(app, 'py', 'func', 'module1.func', 'foo')
|
|
assert isinstance(rn, nodes.reference)
|
|
assert rn['refuri'] == 'https://docs.python.org/sub/foo.html#module1.func'
|
|
assert rn['reftitle'] == '(in foo v2.0)'
|
|
assert rn[0].astext() == 'foo'
|
|
|
|
# create unresolvable nodes and check None return value
|
|
assert reference_check(app, 'py', 'foo', 'module1.func', 'foo') is None
|
|
assert reference_check(app, 'py', 'func', 'foo', 'foo') is None
|
|
assert reference_check(app, 'py', 'func', 'foo', 'foo') is None
|
|
|
|
# check handling of prefixes
|
|
|
|
# prefix given, target found: prefix is stripped
|
|
rn = reference_check(app, 'py', 'mod', 'py3k:module2', 'py3k:module2')
|
|
assert rn[0].astext() == 'module2'
|
|
|
|
# prefix given, but not in title: nothing stripped
|
|
rn = reference_check(app, 'py', 'mod', 'py3k:module2', 'module2')
|
|
assert rn[0].astext() == 'module2'
|
|
|
|
# prefix given, but explicit: nothing stripped
|
|
rn = reference_check(
|
|
app, 'py', 'mod', 'py3k:module2', 'py3k:module2', refexplicit=True
|
|
)
|
|
assert rn[0].astext() == 'py3k:module2'
|
|
|
|
# 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() == 'py3k:unknown'
|
|
|
|
# prefix given, target not found and explicit title: nothing is changed
|
|
node, contnode = fake_node(
|
|
'py', 'mod', 'py3k:unknown', 'py3k:unknown', refexplicit=True
|
|
)
|
|
rn = missing_reference(app, app.env, node, contnode)
|
|
assert rn is None
|
|
assert contnode[0].astext() == 'py3k:unknown'
|
|
|
|
# check relative paths
|
|
rn = reference_check(app, 'py', 'mod', 'py3krel:module1', 'foo')
|
|
assert rn['refuri'] == 'py3k/foo.html#module-module1'
|
|
|
|
rn = reference_check(app, 'py', 'mod', 'py3krelparent:module1', 'foo')
|
|
assert rn['refuri'] == '../../py3k/foo.html#module-module1'
|
|
|
|
rn = reference_check(
|
|
app, 'py', 'mod', 'py3krel:module1', 'foo', refdoc='sub/dir/test'
|
|
)
|
|
assert rn['refuri'] == '../../py3k/foo.html#module-module1'
|
|
|
|
rn = reference_check(
|
|
app, 'py', 'mod', 'py3krelparent:module1', 'foo', refdoc='sub/dir/test'
|
|
)
|
|
assert rn['refuri'] == '../../../../py3k/foo.html#module-module1'
|
|
|
|
# check refs of standard domain
|
|
rn = reference_check(app, 'std', 'doc', 'docname', 'docname')
|
|
assert rn['refuri'] == 'https://docs.python.org/docname.html'
|
|
|
|
|
|
@pytest.mark.sphinx('html', testroot='root')
|
|
def test_missing_reference_pydomain(tmp_path, app):
|
|
inv_file = tmp_path / 'inventory'
|
|
inv_file.write_bytes(INVENTORY_V2)
|
|
set_config(
|
|
app,
|
|
{
|
|
'python': ('https://docs.python.org/', str(inv_file)),
|
|
},
|
|
)
|
|
|
|
# load the inventory and check if it's done correctly
|
|
validate_intersphinx_mapping(app, app.config)
|
|
load_mappings(app)
|
|
|
|
# no context data
|
|
kwargs = {}
|
|
node, contnode = fake_node('py', 'func', 'func', 'func()', **kwargs)
|
|
rn = missing_reference(app, app.env, node, contnode)
|
|
assert rn is None
|
|
|
|
# py:module context helps to search objects
|
|
kwargs = {'py:module': 'module1'}
|
|
node, contnode = fake_node('py', 'func', 'func', 'func()', **kwargs)
|
|
rn = missing_reference(app, app.env, node, contnode)
|
|
assert rn.astext() == 'func()'
|
|
|
|
# py:attr context helps to search objects
|
|
kwargs = {'py:module': 'module1'}
|
|
node, contnode = fake_node('py', 'attr', 'Foo.bar', 'Foo.bar', **kwargs)
|
|
rn = missing_reference(app, app.env, node, contnode)
|
|
assert rn.astext() == 'Foo.bar'
|
|
|
|
|
|
@pytest.mark.sphinx('html', testroot='root')
|
|
def test_missing_reference_stddomain(tmp_path, app):
|
|
inv_file = tmp_path / 'inventory'
|
|
inv_file.write_bytes(INVENTORY_V2)
|
|
set_config(
|
|
app,
|
|
{
|
|
'cmd': ('https://docs.python.org/', str(inv_file)),
|
|
},
|
|
)
|
|
|
|
# load the inventory and check if it's done correctly
|
|
validate_intersphinx_mapping(app, app.config)
|
|
load_mappings(app)
|
|
|
|
# no context data
|
|
kwargs = {}
|
|
node, contnode = fake_node('std', 'option', '-l', '-l', **kwargs)
|
|
rn = missing_reference(app, app.env, node, contnode)
|
|
assert rn is None
|
|
|
|
# std:program context helps to search objects
|
|
kwargs = {'std:program': 'ls'}
|
|
node, contnode = fake_node('std', 'option', '-l', 'ls -l', **kwargs)
|
|
rn = missing_reference(app, app.env, node, contnode)
|
|
assert rn.astext() == 'ls -l'
|
|
|
|
# refers inventory by name
|
|
kwargs = {}
|
|
node, contnode = fake_node('std', 'option', 'cmd:ls -l', '-l', **kwargs)
|
|
rn = missing_reference(app, app.env, node, contnode)
|
|
assert rn.astext() == '-l'
|
|
|
|
# term reference (normal)
|
|
node, contnode = fake_node('std', 'term', 'a term', 'a term')
|
|
rn = missing_reference(app, app.env, node, contnode)
|
|
assert rn.astext() == 'a term'
|
|
|
|
# term reference (case insensitive)
|
|
node, contnode = fake_node('std', 'term', 'A TERM', 'A TERM')
|
|
rn = missing_reference(app, app.env, node, contnode)
|
|
assert rn.astext() == 'A TERM'
|
|
|
|
# label reference (normal)
|
|
node, contnode = fake_node('std', 'ref', 'The-Julia-Domain', 'The-Julia-Domain')
|
|
rn = missing_reference(app, app.env, node, contnode)
|
|
assert rn.astext() == 'The Julia Domain'
|
|
|
|
# label reference (case insensitive)
|
|
node, contnode = fake_node('std', 'ref', 'the-julia-domain', 'the-julia-domain')
|
|
rn = missing_reference(app, app.env, node, contnode)
|
|
assert rn.astext() == 'The Julia Domain'
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('term', 'expected_ambiguity'),
|
|
[
|
|
('A TERM', False),
|
|
('B TERM', True),
|
|
],
|
|
)
|
|
@pytest.mark.sphinx('html', testroot='root')
|
|
def test_ambiguous_reference_handling(term, expected_ambiguity, tmp_path, app, warning):
|
|
inv_file = tmp_path / 'inventory'
|
|
inv_file.write_bytes(INVENTORY_V2_AMBIGUOUS_TERMS)
|
|
set_config(
|
|
app,
|
|
{
|
|
'cmd': ('https://docs.python.org/', str(inv_file)),
|
|
},
|
|
)
|
|
|
|
# load the inventory
|
|
validate_intersphinx_mapping(app, app.config)
|
|
load_mappings(app)
|
|
|
|
# term reference (case insensitive)
|
|
node, contnode = fake_node('std', 'term', term, term)
|
|
missing_reference(app, app.env, node, contnode)
|
|
|
|
ambiguity = f'multiple matches found for std:term:{term}' in warning.getvalue()
|
|
assert ambiguity is expected_ambiguity
|
|
|
|
|
|
@pytest.mark.sphinx('html', testroot='ext-intersphinx-cppdomain')
|
|
def test_missing_reference_cppdomain(tmp_path, app):
|
|
inv_file = tmp_path / 'inventory'
|
|
inv_file.write_bytes(INVENTORY_V2)
|
|
set_config(
|
|
app,
|
|
{
|
|
'python': ('https://docs.python.org/', str(inv_file)),
|
|
},
|
|
)
|
|
|
|
# load the inventory and check if it's done correctly
|
|
validate_intersphinx_mapping(app, app.config)
|
|
load_mappings(app)
|
|
|
|
app.build()
|
|
html = (app.outdir / 'index.html').read_text(encoding='utf8')
|
|
assert (
|
|
'<a class="reference external"'
|
|
' href="https://docs.python.org/index.html#cpp_foo_bar"'
|
|
' title="(in foo v2.0)">'
|
|
'<code class="xref cpp cpp-class docutils literal notranslate">'
|
|
'<span class="pre">Bar</span></code></a>'
|
|
) in html
|
|
assert (
|
|
'<a class="reference external"'
|
|
' href="https://docs.python.org/index.html#foons"'
|
|
' title="(in foo v2.0)"><span class="n"><span class="pre">foons</span></span></a>'
|
|
) in html
|
|
assert (
|
|
'<a class="reference external"'
|
|
' href="https://docs.python.org/index.html#foons_bartype"'
|
|
' title="(in foo v2.0)"><span class="n"><span class="pre">bartype</span></span></a>'
|
|
) in html
|
|
|
|
|
|
@pytest.mark.sphinx('html', testroot='root')
|
|
def test_missing_reference_jsdomain(tmp_path, app):
|
|
inv_file = tmp_path / 'inventory'
|
|
inv_file.write_bytes(INVENTORY_V2)
|
|
set_config(
|
|
app,
|
|
{
|
|
'python': ('https://docs.python.org/', str(inv_file)),
|
|
},
|
|
)
|
|
|
|
# load the inventory and check if it's done correctly
|
|
validate_intersphinx_mapping(app, app.config)
|
|
load_mappings(app)
|
|
|
|
# no context data
|
|
kwargs = {}
|
|
node, contnode = fake_node('js', 'meth', 'baz', 'baz()', **kwargs)
|
|
rn = missing_reference(app, app.env, node, contnode)
|
|
assert rn is None
|
|
|
|
# js:module and js:object context helps to search objects
|
|
kwargs = {'js:module': 'foo', 'js:object': 'bar'}
|
|
node, contnode = fake_node('js', 'meth', 'baz', 'baz()', **kwargs)
|
|
rn = missing_reference(app, app.env, node, contnode)
|
|
assert rn.astext() == 'baz()'
|
|
|
|
|
|
@pytest.mark.sphinx('html', testroot='root')
|
|
def test_missing_reference_disabled_domain(tmp_path, app):
|
|
inv_file = tmp_path / 'inventory'
|
|
inv_file.write_bytes(INVENTORY_V2)
|
|
set_config(
|
|
app,
|
|
{
|
|
'inv': ('https://docs.python.org/', str(inv_file)),
|
|
},
|
|
)
|
|
|
|
# load the inventory and check if it's done correctly
|
|
validate_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.sphinx('html', testroot='root')
|
|
def test_inventory_not_having_version(tmp_path, app):
|
|
inv_file = tmp_path / 'inventory'
|
|
inv_file.write_bytes(INVENTORY_V2_NO_VERSION)
|
|
set_config(
|
|
app,
|
|
{
|
|
'python': ('https://docs.python.org/', str(inv_file)),
|
|
},
|
|
)
|
|
|
|
# load the inventory and check if it's done correctly
|
|
validate_intersphinx_mapping(app, app.config)
|
|
load_mappings(app)
|
|
|
|
rn = reference_check(app, 'py', 'mod', 'module1', 'foo')
|
|
assert isinstance(rn, nodes.reference)
|
|
assert rn['refuri'] == 'https://docs.python.org/foo.html#module-module1'
|
|
assert rn['reftitle'] == '(in foo)'
|
|
assert rn[0].astext() == 'Long Module desc'
|
|
|
|
|
|
@pytest.mark.sphinx('html', testroot='root')
|
|
def test_validate_intersphinx_mapping_warnings(app):
|
|
"""Check warnings in :func:`sphinx.ext.intersphinx.validate_intersphinx_mapping`."""
|
|
bad_intersphinx_mapping = {
|
|
'': ('789.example', None), # invalid project name (value)
|
|
12345: ('456.example', None), # invalid project name (type)
|
|
None: ('123.example', None), # invalid project name (type)
|
|
'https://example/': None, # Sphinx 0.5 style value (None)
|
|
'https://server/': 'inventory', # Sphinx 0.5 style value (str)
|
|
'bad-dict-item': 0, # invalid dict item type
|
|
'unpack-except-1': [0], # invalid dict item size (native ValueError)
|
|
'unpack-except-2': FakeList(), # invalid dict item size (custom exception)
|
|
'bad-uri-type-1': (123456789, None), # invalid target URI type
|
|
'bad-uri-type-2': (None, None), # invalid target URI type
|
|
'bad-uri-value': ('', None), # invalid target URI value
|
|
'good': ('example.org', None), # duplicated target URI (good entry)
|
|
'dedup-good': ('example.org', None), # duplicated target URI
|
|
'bad-location-1': ('a.example', 1), # invalid inventory location (single input, bad type)
|
|
'bad-location-2': ('b.example', ''), # invalid inventory location (single input, bad string)
|
|
'bad-location-3': ('c.example', [2, 'x']), # invalid inventory location (sequence input, bad type)
|
|
'bad-location-4': ('d.example', ['y', '']), # invalid inventory location (sequence input, bad string)
|
|
'good-target-1': ('e.example', None), # valid inventory location (None)
|
|
'good-target-2': ('f.example', ('x',)), # valid inventory location (sequence input)
|
|
} # fmt: skip
|
|
set_config(app, bad_intersphinx_mapping)
|
|
|
|
# normalise the inventory and check if it's done correctly
|
|
with pytest.raises(
|
|
ConfigError,
|
|
match=r'^Invalid `intersphinx_mapping` configuration \(16 errors\).$',
|
|
):
|
|
validate_intersphinx_mapping(app, app.config)
|
|
warnings = strip_escape_sequences(app.warning.getvalue()).splitlines()
|
|
assert len(warnings) == len(bad_intersphinx_mapping) - 3
|
|
assert warnings == [
|
|
"ERROR: Invalid intersphinx project identifier `''` in intersphinx_mapping. Project identifiers must be non-empty strings.",
|
|
'ERROR: Invalid intersphinx project identifier `12345` in intersphinx_mapping. Project identifiers must be non-empty strings.',
|
|
'ERROR: Invalid intersphinx project identifier `None` in intersphinx_mapping. Project identifiers must be non-empty strings.',
|
|
"ERROR: Invalid value `None` in intersphinx_mapping['https://example/']. Expected a two-element tuple or list.",
|
|
"ERROR: Invalid value `'inventory'` in intersphinx_mapping['https://server/']. Expected a two-element tuple or list.",
|
|
"ERROR: Invalid value `0` in intersphinx_mapping['bad-dict-item']. Expected a two-element tuple or list.",
|
|
"ERROR: Invalid value `[0]` in intersphinx_mapping['unpack-except-1']. Values must be a (target URI, inventory locations) pair.",
|
|
"ERROR: Invalid value `[]` in intersphinx_mapping['unpack-except-2']. Values must be a (target URI, inventory locations) pair.",
|
|
"ERROR: Invalid target URI value `123456789` in intersphinx_mapping['bad-uri-type-1'][0]. Target URIs must be unique non-empty strings.",
|
|
"ERROR: Invalid target URI value `None` in intersphinx_mapping['bad-uri-type-2'][0]. Target URIs must be unique non-empty strings.",
|
|
"ERROR: Invalid target URI value `''` in intersphinx_mapping['bad-uri-value'][0]. Target URIs must be unique non-empty strings.",
|
|
"ERROR: Invalid target URI value `'example.org'` in intersphinx_mapping['dedup-good'][0]. Target URIs must be unique (other instance in intersphinx_mapping['good']).",
|
|
"ERROR: Invalid inventory location value `1` in intersphinx_mapping['bad-location-1'][1]. Inventory locations must be non-empty strings or None.",
|
|
"ERROR: Invalid inventory location value `''` in intersphinx_mapping['bad-location-2'][1]. Inventory locations must be non-empty strings or None.",
|
|
"ERROR: Invalid inventory location value `2` in intersphinx_mapping['bad-location-3'][1]. Inventory locations must be non-empty strings or None.",
|
|
"ERROR: Invalid inventory location value `''` in intersphinx_mapping['bad-location-4'][1]. Inventory locations must be non-empty strings or None.",
|
|
]
|
|
|
|
|
|
@pytest.mark.sphinx('html', testroot='root')
|
|
def test_load_mappings_fallback(tmp_path, app):
|
|
inv_file = tmp_path / 'inventory'
|
|
inv_file.write_bytes(INVENTORY_V2)
|
|
set_config(app, {})
|
|
|
|
# connect to invalid path
|
|
app.config.intersphinx_mapping = {
|
|
'fallback': ('https://docs.python.org/py3k/', '/invalid/inventory/path'),
|
|
}
|
|
validate_intersphinx_mapping(app, app.config)
|
|
load_mappings(app)
|
|
assert 'failed to reach any of the inventories' in app.warning.getvalue()
|
|
|
|
rn = reference_check(app, 'py', 'func', 'module1.func', 'foo')
|
|
assert rn is None
|
|
|
|
# clear messages
|
|
app.status.truncate(0)
|
|
app.warning.truncate(0)
|
|
|
|
# add fallbacks to mapping
|
|
app.config.intersphinx_mapping = {
|
|
'fallback': (
|
|
'https://docs.python.org/py3k/',
|
|
('/invalid/inventory/path', str(inv_file)),
|
|
),
|
|
}
|
|
validate_intersphinx_mapping(app, app.config)
|
|
load_mappings(app)
|
|
assert (
|
|
'encountered some issues with some of the inventories'
|
|
) in app.status.getvalue()
|
|
assert app.warning.getvalue() == ''
|
|
|
|
rn = reference_check(app, 'py', 'func', 'module1.func', 'foo')
|
|
assert isinstance(rn, nodes.reference)
|
|
|
|
|
|
class TestStripBasicAuth:
|
|
"""Tests for sphinx.ext.intersphinx._strip_basic_auth()"""
|
|
|
|
def test_auth_stripped(self):
|
|
"""Basic auth creds stripped from URL containing creds"""
|
|
url = 'https://user:12345@domain.com/project/objects.inv'
|
|
expected = 'https://domain.com/project/objects.inv'
|
|
actual = _strip_basic_auth(url)
|
|
assert actual == expected
|
|
|
|
def test_no_auth(self):
|
|
"""Url unchanged if param doesn't contain basic auth creds"""
|
|
url = 'https://domain.com/project/objects.inv'
|
|
expected = 'https://domain.com/project/objects.inv'
|
|
actual = _strip_basic_auth(url)
|
|
assert actual == expected
|
|
|
|
def test_having_port(self):
|
|
"""Basic auth creds correctly stripped from URL containing creds even if URL
|
|
contains port
|
|
"""
|
|
url = 'https://user:12345@domain.com:8080/project/objects.inv'
|
|
expected = 'https://domain.com:8080/project/objects.inv'
|
|
actual = _strip_basic_auth(url)
|
|
assert actual == expected
|
|
|
|
|
|
def test_getsafeurl_authed():
|
|
"""_get_safe_url() with a url with basic auth"""
|
|
url = 'https://user:12345@domain.com/project/objects.inv'
|
|
expected = 'https://user@domain.com/project/objects.inv'
|
|
actual = _get_safe_url(url)
|
|
assert actual == expected
|
|
|
|
|
|
def test_getsafeurl_authed_having_port():
|
|
"""_get_safe_url() with a url with basic auth having port"""
|
|
url = 'https://user:12345@domain.com:8080/project/objects.inv'
|
|
expected = 'https://user@domain.com:8080/project/objects.inv'
|
|
actual = _get_safe_url(url)
|
|
assert actual == expected
|
|
|
|
|
|
def test_getsafeurl_unauthed():
|
|
"""_get_safe_url() with a url without basic auth"""
|
|
url = 'https://domain.com/project/objects.inv'
|
|
expected = 'https://domain.com/project/objects.inv'
|
|
actual = _get_safe_url(url)
|
|
assert actual == expected
|
|
|
|
|
|
def test_inspect_main_noargs(capsys):
|
|
"""inspect_main interface, without arguments"""
|
|
assert inspect_main([]) == 1
|
|
|
|
expected = (
|
|
'Print out an inventory file.\n'
|
|
'Error: must specify local path or URL to an inventory file.'
|
|
)
|
|
stdout, stderr = capsys.readouterr()
|
|
assert stdout == ''
|
|
assert stderr == expected + '\n'
|
|
|
|
|
|
def test_inspect_main_file(capsys, tmp_path):
|
|
"""inspect_main interface, with file argument"""
|
|
inv_file = tmp_path / 'inventory'
|
|
inv_file.write_bytes(INVENTORY_V2)
|
|
|
|
inspect_main([str(inv_file)])
|
|
|
|
stdout, stderr = capsys.readouterr()
|
|
assert stdout.startswith('c:function\n')
|
|
assert stderr == ''
|
|
|
|
|
|
def test_inspect_main_url(capsys):
|
|
"""inspect_main interface, with url argument"""
|
|
|
|
class InventoryHandler(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
self.send_response(200, 'OK')
|
|
self.end_headers()
|
|
self.wfile.write(INVENTORY_V2)
|
|
|
|
def log_message(*args, **kwargs):
|
|
# Silenced.
|
|
pass
|
|
|
|
with http_server(InventoryHandler) as server:
|
|
url = f'http://localhost:{server.server_port}/{INVENTORY_FILENAME}'
|
|
inspect_main([url])
|
|
|
|
stdout, stderr = capsys.readouterr()
|
|
assert stdout.startswith('c:function\n')
|
|
assert stderr == ''
|
|
|
|
|
|
@pytest.mark.sphinx('html', testroot='ext-intersphinx-role')
|
|
def test_intersphinx_role(app):
|
|
inv_file = app.srcdir / 'inventory'
|
|
inv_file.write_bytes(INVENTORY_V2)
|
|
app.config.intersphinx_mapping = {
|
|
'inv': ('https://example.org/', str(inv_file)),
|
|
}
|
|
app.config.intersphinx_cache_limit = 0
|
|
app.config.nitpicky = True
|
|
|
|
# load the inventory and check if it's done correctly
|
|
validate_intersphinx_mapping(app, app.config)
|
|
load_mappings(app)
|
|
|
|
app.build()
|
|
content = (app.outdir / 'index.html').read_text(encoding='utf8')
|
|
warnings = strip_escape_sequences(app.warning.getvalue()).splitlines()
|
|
index_path = app.srcdir / 'index.rst'
|
|
assert warnings == [
|
|
f"{index_path}:21: WARNING: role for external cross-reference not found in domain 'py': 'nope' [intersphinx.external]",
|
|
f"{index_path}:28: WARNING: role for external cross-reference not found in domains 'cpp', 'std': 'nope' [intersphinx.external]",
|
|
f"{index_path}:39: WARNING: inventory for external cross-reference not found: 'invNope' [intersphinx.external]",
|
|
f"{index_path}:44: WARNING: role for external cross-reference not found in domain 'c': 'function' (perhaps you meant one of: 'func', 'identifier', 'type') [intersphinx.external]",
|
|
f"{index_path}:45: WARNING: role for external cross-reference not found in domains 'cpp', 'std': 'function' (perhaps you meant one of: 'cpp:func', 'cpp:identifier', 'cpp:type') [intersphinx.external]",
|
|
f'{index_path}:9: WARNING: external py:mod reference target not found: module3 [ref.mod]',
|
|
f'{index_path}:14: WARNING: external py:mod reference target not found: module10 [ref.mod]',
|
|
f'{index_path}:19: WARNING: external py:meth reference target not found: inv:Foo.bar [ref.meth]',
|
|
]
|
|
|
|
html = '<a class="reference external" href="https://example.org/{}" title="(in foo v2.0)">'
|
|
assert html.format('foo.html#module-module1') in content
|
|
assert html.format('foo.html#module-module2') in content
|
|
|
|
assert html.format('sub/foo.html#module1.func') in content
|
|
|
|
# default domain
|
|
assert html.format('index.html#std_uint8_t') in content
|
|
|
|
# std roles without domain prefix
|
|
assert html.format('docname.html') in content
|
|
assert html.format('index.html#cmdoption-ls-l') in content
|
|
|
|
# explicit inventory
|
|
assert html.format('cfunc.html#CFunc') in content
|
|
|
|
# explicit title
|
|
assert html.format('index.html#foons') in content
|
|
|
|
|
|
@pytest.mark.sphinx('html', testroot='root')
|
|
@pytest.mark.parametrize(
|
|
('cache_limit', 'expected_expired'),
|
|
[
|
|
(5, False), # cache for 5 days
|
|
(1, True), # cache for 1 day
|
|
(0, True), # cache for 0 days
|
|
(-1, False), # cache forever
|
|
],
|
|
)
|
|
def test_intersphinx_cache_limit(app, monkeypatch, cache_limit, expected_expired):
|
|
url = 'https://example.org/'
|
|
app.config.intersphinx_cache_limit = cache_limit
|
|
app.config.intersphinx_mapping = {
|
|
'inv': (url, None),
|
|
}
|
|
app.config.intersphinx_timeout = None
|
|
# load the inventory and check if it's done correctly
|
|
intersphinx_cache: dict[str, InventoryCacheEntry] = {
|
|
url: ('inv', 0, {}), # Timestamp of last cache write is zero.
|
|
}
|
|
validate_intersphinx_mapping(app, app.config)
|
|
|
|
# The test's `now` is two days after the cache was created.
|
|
now = 2 * 86400
|
|
monkeypatch.setattr('time.time', lambda: now)
|
|
|
|
# `_fetch_inventory_group` calls `_fetch_inventory`.
|
|
# We replace it with a mock to test whether it has been called.
|
|
# If it has been called, it means the cache had expired.
|
|
mock_fake_inventory: Inventory = {'std:label': {}} # must be truthy
|
|
mock_fetch_inventory = mock.Mock(return_value=mock_fake_inventory)
|
|
monkeypatch.setattr(
|
|
'sphinx.ext.intersphinx._load._fetch_inventory', mock_fetch_inventory
|
|
)
|
|
|
|
for name, (uri, locations) in app.config.intersphinx_mapping.values():
|
|
project = _IntersphinxProject(name=name, target_uri=uri, locations=locations)
|
|
updated = _fetch_inventory_group(
|
|
project=project,
|
|
cache=intersphinx_cache,
|
|
now=now,
|
|
config=_InvConfig.from_config(app.config),
|
|
srcdir=app.srcdir,
|
|
)
|
|
# If we hadn't mocked `_fetch_inventory`, it would've made
|
|
# a request to `https://example.org/` and found no inventory
|
|
# file. That would've been an error, and `updated` would've been
|
|
# False even if the cache had expired. The mock makes it behave
|
|
# "correctly".
|
|
assert updated is expected_expired
|
|
# Double-check: If the cache was expired, `mock_fetch_inventory`
|
|
# must've been called.
|
|
assert mock_fetch_inventory.called is expected_expired
|
|
|
|
|
|
def test_intersphinx_fetch_inventory_group_url():
|
|
class InventoryHandler(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
self.send_response(200, 'OK')
|
|
self.end_headers()
|
|
self.wfile.write(INVENTORY_V2)
|
|
|
|
def log_message(*args, **kwargs):
|
|
# Silenced.
|
|
pass
|
|
|
|
with http_server(InventoryHandler) as server:
|
|
url1 = f'http://localhost:{server.server_port}'
|
|
url2 = f'http://localhost:{server.server_port}/'
|
|
|
|
config = Config()
|
|
config.intersphinx_cache_limit = -1
|
|
config.intersphinx_mapping = {
|
|
'1': (url1, None),
|
|
'2': (url2, None),
|
|
}
|
|
|
|
now = int(time.time())
|
|
# we can use 'srcdir=None' since we are raising in _fetch_inventory
|
|
kwds = {'cache': {}, 'now': now, 'config': config, 'srcdir': None}
|
|
# We need an exception with its 'args' attribute set (see error
|
|
# handling in sphinx.ext.intersphinx._load._fetch_inventory_group).
|
|
side_effect = ValueError('')
|
|
|
|
project1 = _IntersphinxProject(
|
|
name='1', target_uri=url1, locations=(url1, None)
|
|
)
|
|
with mock.patch(
|
|
'sphinx.ext.intersphinx._load._fetch_inventory', side_effect=side_effect
|
|
) as mockfn:
|
|
assert not _fetch_inventory_group(project=project1, **kwds)
|
|
mockfn.assert_any_call(
|
|
target_uri=url1,
|
|
inv_location=url1,
|
|
config=config,
|
|
srcdir=None,
|
|
)
|
|
mockfn.assert_any_call(
|
|
target_uri=url1,
|
|
inv_location=url1 + '/' + INVENTORY_FILENAME,
|
|
config=config,
|
|
srcdir=None,
|
|
)
|
|
|
|
project2 = _IntersphinxProject(
|
|
name='2', target_uri=url2, locations=(url2, None)
|
|
)
|
|
with mock.patch(
|
|
'sphinx.ext.intersphinx._load._fetch_inventory', side_effect=side_effect
|
|
) as mockfn:
|
|
assert not _fetch_inventory_group(project=project2, **kwds)
|
|
mockfn.assert_any_call(
|
|
target_uri=url2,
|
|
inv_location=url2,
|
|
config=config,
|
|
srcdir=None,
|
|
)
|
|
mockfn.assert_any_call(
|
|
target_uri=url2,
|
|
inv_location=url2 + INVENTORY_FILENAME,
|
|
config=config,
|
|
srcdir=None,
|
|
)
|