diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-06-05 16:20:59 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-06-05 16:20:59 +0000 |
commit | 5de84c9242643f786eff03726286578726d7d390 (patch) | |
tree | 8e8eadab2b786c41d7b8a2cdafbb467588928ad0 /tests/test_builders | |
parent | Releasing progress-linux version 7.2.6-8~progress7.99u1. (diff) | |
download | sphinx-5de84c9242643f786eff03726286578726d7d390.tar.xz sphinx-5de84c9242643f786eff03726286578726d7d390.zip |
Merging upstream version 7.3.7.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
26 files changed, 2356 insertions, 388 deletions
diff --git a/tests/test_builders/__init__.py b/tests/test_builders/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/test_builders/__init__.py diff --git a/tests/test_builders/conftest.py b/tests/test_builders/conftest.py new file mode 100644 index 0000000..1203d5d --- /dev/null +++ b/tests/test_builders/conftest.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from sphinx.testing.util import etree_parse + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + from pathlib import Path + from xml.etree.ElementTree import ElementTree + +_etree_cache: dict[Path, ElementTree] = {} + + +def _parse(path: Path) -> ElementTree: + if path in _etree_cache: + return _etree_cache[path] + + _etree_cache[path] = tree = etree_parse(path) + return tree + + +@pytest.fixture(scope='package') +def cached_etree_parse() -> Iterator[Callable[[Path], ElementTree]]: + yield _parse + _etree_cache.clear() diff --git a/tests/test_build.py b/tests/test_builders/test_build.py index ed4bc43..3f6d12c 100644 --- a/tests/test_build.py +++ b/tests/test_builders/test_build.py @@ -2,13 +2,17 @@ import os import shutil +from contextlib import contextmanager from unittest import mock import pytest from docutils import nodes +from sphinx.cmd.build import build_main from sphinx.errors import SphinxError +from tests.utils import TESTS_ROOT + def request_session_head(url, **kwargs): response = mock.Mock() @@ -60,12 +64,12 @@ def test_root_doc_not_found(tmp_path, make_app): app = make_app('dummy', srcdir=tmp_path) with pytest.raises(SphinxError): - app.builder.build_all() # no index.rst + app.build(force_all=True) # no index.rst @pytest.mark.sphinx(buildername='text', testroot='circular') def test_circular_toctree(app, status, warning): - app.builder.build_all() + app.build(force_all=True) warnings = warning.getvalue() assert ( 'circular toctree references detected, ignoring: ' @@ -77,7 +81,7 @@ def test_circular_toctree(app, status, warning): @pytest.mark.sphinx(buildername='text', testroot='numbered-circular') def test_numbered_circular_toctree(app, status, warning): - app.builder.build_all() + app.build(force_all=True) warnings = warning.getvalue() assert ( 'circular toctree references detected, ignoring: ' @@ -89,7 +93,7 @@ def test_numbered_circular_toctree(app, status, warning): @pytest.mark.sphinx(buildername='dummy', testroot='images') def test_image_glob(app, status, warning): - app.builder.build_all() + app.build(force_all=True) # index.rst doctree = app.env.get_doctree('index') @@ -133,3 +137,29 @@ def test_image_glob(app, status, warning): assert doctree[0][3][0]['candidates'] == {'application/pdf': 'subdir/svgimg.pdf', 'image/svg+xml': 'subdir/svgimg.svg'} assert doctree[0][3][0]['uri'] == 'subdir/svgimg.*' + + +@contextmanager +def force_colors(): + forcecolor = os.environ.get('FORCE_COLOR', None) + + try: + os.environ['FORCE_COLOR'] = '1' + yield + finally: + if forcecolor is None: + os.environ.pop('FORCE_COLOR', None) + else: + os.environ['FORCE_COLOR'] = forcecolor + + +def test_log_no_ansi_colors(tmp_path): + with force_colors(): + wfile = tmp_path / 'warnings.txt' + srcdir = TESTS_ROOT / 'roots' / 'test-nitpicky-warnings' + argv = list(map(str, ['-b', 'html', srcdir, tmp_path, '-n', '-w', wfile])) + retcode = build_main(argv) + assert retcode == 0 + + content = wfile.read_text(encoding='utf8') + assert '\x1b[91m' not in content diff --git a/tests/test_build_changes.py b/tests/test_builders/test_build_changes.py index b340c8d..b537b87 100644 --- a/tests/test_build_changes.py +++ b/tests/test_builders/test_build_changes.py @@ -9,7 +9,7 @@ def test_build(app): # TODO: Use better checking of html content htmltext = (app.outdir / 'changes.html').read_text(encoding='utf8') - assert 'New in version 0.6: Some funny stuff.' in htmltext + assert 'Added in version 0.6: Some funny stuff.' in htmltext assert 'Changed in version 0.6: Even more funny stuff.' in htmltext assert 'Deprecated since version 0.6: Boring stuff.' in htmltext diff --git a/tests/test_build_dirhtml.py b/tests/test_builders/test_build_dirhtml.py index dc5ab86..dc5ab86 100644 --- a/tests/test_build_dirhtml.py +++ b/tests/test_builders/test_build_dirhtml.py diff --git a/tests/test_build_epub.py b/tests/test_builders/test_build_epub.py index 7f5b815..6829f22 100644 --- a/tests/test_build_epub.py +++ b/tests/test_builders/test_build_epub.py @@ -22,6 +22,7 @@ def runnable(command): class EPUBElementTree: """Test helper for content.opf and toc.ncx""" + namespaces = { 'idpf': 'http://www.idpf.org/2007/opf', 'dc': 'http://purl.org/dc/elements/1.1/', @@ -60,7 +61,7 @@ class EPUBElementTree: @pytest.mark.sphinx('epub', testroot='basic') def test_build_epub(app): - app.builder.build_all() + app.build(force_all=True) assert (app.outdir / 'mimetype').read_text(encoding='utf8') == 'application/epub+zip' assert (app.outdir / 'META-INF' / 'container.xml').exists() @@ -277,7 +278,7 @@ def test_escaped_toc(app): @pytest.mark.sphinx('epub', testroot='basic') def test_epub_writing_mode(app): # horizontal (default) - app.builder.build_all() + app.build(force_all=True) # horizontal / page-progression-direction opf = EPUBElementTree.fromstring((app.outdir / 'content.opf').read_text(encoding='utf8')) @@ -323,7 +324,7 @@ def test_epub_anchor_id(app): @pytest.mark.sphinx('epub', testroot='html_assets') def test_epub_assets(app): - app.builder.build_all() + app.build(force_all=True) # epub_sytlesheets (same as html_css_files) content = (app.outdir / 'index.xhtml').read_text(encoding='utf8') @@ -336,7 +337,7 @@ def test_epub_assets(app): @pytest.mark.sphinx('epub', testroot='html_assets', confoverrides={'epub_css_files': ['css/epub.css']}) def test_epub_css_files(app): - app.builder.build_all() + app.build(force_all=True) # epub_css_files content = (app.outdir / 'index.xhtml').read_text(encoding='utf8') @@ -361,13 +362,13 @@ def test_html_download_role(app, status, warning): '<span class="pre">not_found.dat</span></code></p></li>' in content) assert ('<li><p><code class="xref download docutils literal notranslate">' '<span class="pre">Sphinx</span> <span class="pre">logo</span></code>' - '<span class="link-target"> [http://www.sphinx-doc.org/en/master' - '/_static/sphinxheader.png]</span></p></li>' in content) + '<span class="link-target"> [https://www.sphinx-doc.org/en/master' + '/_static/sphinx-logo.svg]</span></p></li>' in content) @pytest.mark.sphinx('epub', testroot='toctree-duplicated') def test_duplicated_toctree_entry(app, status, warning): - app.builder.build_all() + app.build(force_all=True) assert 'WARNING: duplicated ToC entry found: foo.xhtml' in warning.getvalue() @@ -377,16 +378,21 @@ def test_duplicated_toctree_entry(app, status, warning): def test_run_epubcheck(app): app.build() + if not runnable(['java', '-version']): + pytest.skip("Unable to run Java; skipping test") + epubcheck = os.environ.get('EPUBCHECK_PATH', '/usr/share/java/epubcheck.jar') - if runnable(['java', '-version']) and os.path.exists(epubcheck): - try: - subprocess.run(['java', '-jar', epubcheck, app.outdir / 'SphinxTests.epub'], - capture_output=True, check=True) - except CalledProcessError as exc: - print(exc.stdout.decode('utf-8')) - print(exc.stderr.decode('utf-8')) - msg = f'epubcheck exited with return code {exc.returncode}' - raise AssertionError(msg) from exc + if not os.path.exists(epubcheck): + pytest.skip("Could not find epubcheck; skipping test") + + try: + subprocess.run(['java', '-jar', epubcheck, app.outdir / 'SphinxTests.epub'], + capture_output=True, check=True) + except CalledProcessError as exc: + print(exc.stdout.decode('utf-8')) + print(exc.stderr.decode('utf-8')) + msg = f'epubcheck exited with return code {exc.returncode}' + raise AssertionError(msg) from exc def test_xml_name_pattern_check(): diff --git a/tests/test_build_gettext.py b/tests/test_builders/test_build_gettext.py index 6d9154e..ddc6d30 100644 --- a/tests/test_build_gettext.py +++ b/tests/test_builders/test_build_gettext.py @@ -47,7 +47,7 @@ def test_Catalog_duplicated_message(): @pytest.mark.sphinx('gettext', srcdir='root-gettext') def test_build_gettext(app): # Generic build; should fail only when the builder is horribly broken. - app.builder.build_all() + app.build(force_all=True) # Do messages end up in the correct location? # top-level documents end up in a message catalog @@ -62,7 +62,7 @@ def test_build_gettext(app): @pytest.mark.sphinx('gettext', srcdir='root-gettext') def test_msgfmt(app): - app.builder.build_all() + app.build(force_all=True) (app.outdir / 'en' / 'LC_MESSAGES').mkdir(parents=True, exist_ok=True) with chdir(app.outdir): @@ -102,7 +102,7 @@ def test_msgfmt(app): confoverrides={'gettext_compact': False}) def test_gettext_index_entries(app): # regression test for #976 - app.builder.build(['index_entries']) + app.build(filenames=[app.srcdir / 'index_entries.txt']) pot = (app.outdir / 'index_entries.pot').read_text(encoding='utf8') msg_ids = list(filter(None, map(msgid_getter, pot.splitlines()))) @@ -131,7 +131,7 @@ def test_gettext_index_entries(app): def test_gettext_disable_index_entries(app): # regression test for #976 app.env._pickled_doctree_cache.clear() # clear cache - app.builder.build(['index_entries']) + app.build(filenames=[app.srcdir / 'index_entries.txt']) pot = (app.outdir / 'index_entries.pot').read_text(encoding='utf8') msg_ids = list(filter(None, map(msgid_getter, pot.splitlines()))) @@ -147,7 +147,7 @@ def test_gettext_disable_index_entries(app): @pytest.mark.sphinx('gettext', testroot='intl', srcdir='gettext') def test_gettext_template(app): - app.builder.build_all() + app.build(force_all=True) assert (app.outdir / 'sphinx.pot').is_file() @@ -158,7 +158,7 @@ def test_gettext_template(app): @pytest.mark.sphinx('gettext', testroot='gettext-template') def test_gettext_template_msgid_order_in_sphinxpot(app): - app.builder.build_all() + app.build(force_all=True) assert (app.outdir / 'sphinx.pot').is_file() result = (app.outdir / 'sphinx.pot').read_text(encoding='utf8') @@ -175,7 +175,7 @@ def test_gettext_template_msgid_order_in_sphinxpot(app): 'gettext', srcdir='root-gettext', confoverrides={'gettext_compact': 'documentation'}) def test_build_single_pot(app): - app.builder.build_all() + app.build(force_all=True) assert (app.outdir / 'documentation.pot').is_file() @@ -196,7 +196,7 @@ def test_build_single_pot(app): confoverrides={'gettext_compact': False, 'gettext_additional_targets': ['image']}) def test_gettext_prolog_epilog_substitution(app): - app.builder.build_all() + app.build(force_all=True) assert (app.outdir / 'prolog_epilog_substitution.pot').is_file() pot = (app.outdir / 'prolog_epilog_substitution.pot').read_text(encoding='utf8') @@ -223,7 +223,7 @@ def test_gettext_prolog_epilog_substitution(app): 'gettext_additional_targets': ['image']}) def test_gettext_prolog_epilog_substitution_excluded(app): # regression test for #9428 - app.builder.build_all() + app.build(force_all=True) assert (app.outdir / 'prolog_epilog_substitution_excluded.pot').is_file() pot = (app.outdir / 'prolog_epilog_substitution_excluded.pot').read_text(encoding='utf8') diff --git a/tests/test_builders/test_build_html.py b/tests/test_builders/test_build_html.py new file mode 100644 index 0000000..1fa3ba4 --- /dev/null +++ b/tests/test_builders/test_build_html.py @@ -0,0 +1,378 @@ +"""Test the HTML builder and check output against XPath.""" + +import os +import posixpath +import re + +import pytest + +from sphinx.builders.html import validate_html_extra_path, validate_html_static_path +from sphinx.deprecation import RemovedInSphinx80Warning +from sphinx.errors import ConfigError +from sphinx.util.console import strip_colors +from sphinx.util.inventory import InventoryFile + +from tests.test_builders.xpath_data import FIGURE_CAPTION +from tests.test_builders.xpath_util import check_xpath + + +def test_html4_error(make_app, tmp_path): + (tmp_path / 'conf.py').write_text('', encoding='utf-8') + with pytest.raises( + ConfigError, + match='HTML 4 is no longer supported by Sphinx', + ): + make_app( + buildername='html', + srcdir=tmp_path, + confoverrides={'html4_writer': True}, + ) + + +@pytest.mark.parametrize(("fname", "path", "check"), [ + ('index.html', ".//div[@class='citation']/span", r'Ref1'), + ('index.html', ".//div[@class='citation']/span", r'Ref_1'), + + ('footnote.html', ".//a[@class='footnote-reference brackets'][@href='#id9'][@id='id1']", r"1"), + ('footnote.html', ".//a[@class='footnote-reference brackets'][@href='#id10'][@id='id2']", r"2"), + ('footnote.html', ".//a[@class='footnote-reference brackets'][@href='#foo'][@id='id3']", r"3"), + ('footnote.html', ".//a[@class='reference internal'][@href='#bar'][@id='id4']/span", r"\[bar\]"), + ('footnote.html', ".//a[@class='reference internal'][@href='#baz-qux'][@id='id5']/span", r"\[baz_qux\]"), + ('footnote.html', ".//a[@class='footnote-reference brackets'][@href='#id11'][@id='id6']", r"4"), + ('footnote.html', ".//a[@class='footnote-reference brackets'][@href='#id12'][@id='id7']", r"5"), + ('footnote.html', ".//aside[@class='footnote brackets']/span/a[@href='#id1']", r"1"), + ('footnote.html', ".//aside[@class='footnote brackets']/span/a[@href='#id2']", r"2"), + ('footnote.html', ".//aside[@class='footnote brackets']/span/a[@href='#id3']", r"3"), + ('footnote.html', ".//div[@class='citation']/span/a[@href='#id4']", r"bar"), + ('footnote.html', ".//div[@class='citation']/span/a[@href='#id5']", r"baz_qux"), + ('footnote.html', ".//aside[@class='footnote brackets']/span/a[@href='#id6']", r"4"), + ('footnote.html', ".//aside[@class='footnote brackets']/span/a[@href='#id7']", r"5"), + ('footnote.html', ".//aside[@class='footnote brackets']/span/a[@href='#id8']", r"6"), +]) +@pytest.mark.sphinx('html') +@pytest.mark.test_params(shared_result='test_build_html_output_docutils18') +def test_docutils_output(app, cached_etree_parse, fname, path, check): + app.build() + check_xpath(cached_etree_parse(app.outdir / fname), fname, path, check) + + +@pytest.mark.sphinx('html', parallel=2) +def test_html_parallel(app): + app.build() + + +@pytest.mark.sphinx('html', testroot='build-html-translator') +def test_html_translator(app): + app.build() + assert app.builder.docwriter.visitor.depart_with_node == 10 + + +@pytest.mark.parametrize("expect", [ + (FIGURE_CAPTION + "//span[@class='caption-number']", "Fig. 1", True), + (FIGURE_CAPTION + "//span[@class='caption-number']", "Fig. 2", True), + (FIGURE_CAPTION + "//span[@class='caption-number']", "Fig. 3", True), + (".//div//span[@class='caption-number']", "No.1 ", True), + (".//div//span[@class='caption-number']", "No.2 ", True), + (".//li/p/a/span", 'Fig. 1', True), + (".//li/p/a/span", 'Fig. 2', True), + (".//li/p/a/span", 'Fig. 3', True), + (".//li/p/a/span", 'No.1', True), + (".//li/p/a/span", 'No.2', True), +]) +@pytest.mark.sphinx('html', testroot='add_enumerable_node', + srcdir='test_enumerable_node') +def test_enumerable_node(app, cached_etree_parse, expect): + app.build() + check_xpath(cached_etree_parse(app.outdir / 'index.html'), 'index.html', *expect) + + +@pytest.mark.sphinx('html', testroot='basic', confoverrides={'html_copy_source': False}) +def test_html_copy_source(app): + app.build(force_all=True) + assert not (app.outdir / '_sources' / 'index.rst.txt').exists() + + +@pytest.mark.sphinx('html', testroot='basic', confoverrides={'html_sourcelink_suffix': '.txt'}) +def test_html_sourcelink_suffix(app): + app.build(force_all=True) + assert (app.outdir / '_sources' / 'index.rst.txt').exists() + + +@pytest.mark.sphinx('html', testroot='basic', confoverrides={'html_sourcelink_suffix': '.rst'}) +def test_html_sourcelink_suffix_same(app): + app.build(force_all=True) + assert (app.outdir / '_sources' / 'index.rst').exists() + + +@pytest.mark.sphinx('html', testroot='basic', confoverrides={'html_sourcelink_suffix': ''}) +def test_html_sourcelink_suffix_empty(app): + app.build(force_all=True) + assert (app.outdir / '_sources' / 'index.rst').exists() + + +@pytest.mark.sphinx('html', testroot='html_entity') +def test_html_entity(app): + app.build(force_all=True) + valid_entities = {'amp', 'lt', 'gt', 'quot', 'apos'} + content = (app.outdir / 'index.html').read_text(encoding='utf8') + for entity in re.findall(r'&([a-z]+);', content, re.MULTILINE): + assert entity not in valid_entities + + +@pytest.mark.sphinx('html', testroot='basic') +def test_html_inventory(app): + app.build(force_all=True) + + with app.outdir.joinpath('objects.inv').open('rb') as f: + invdata = InventoryFile.load(f, 'https://www.google.com', posixpath.join) + + assert set(invdata.keys()) == {'std:label', 'std:doc'} + assert set(invdata['std:label'].keys()) == {'modindex', + 'py-modindex', + 'genindex', + 'search'} + assert invdata['std:label']['modindex'] == ('Python', + '', + 'https://www.google.com/py-modindex.html', + 'Module Index') + assert invdata['std:label']['py-modindex'] == ('Python', + '', + 'https://www.google.com/py-modindex.html', + 'Python Module Index') + assert invdata['std:label']['genindex'] == ('Python', + '', + 'https://www.google.com/genindex.html', + 'Index') + assert invdata['std:label']['search'] == ('Python', + '', + 'https://www.google.com/search.html', + 'Search Page') + assert set(invdata['std:doc'].keys()) == {'index'} + assert invdata['std:doc']['index'] == ('Python', + '', + 'https://www.google.com/index.html', + 'The basic Sphinx documentation for testing') + + +@pytest.mark.sphinx('html', testroot='images', confoverrides={'html_sourcelink_suffix': ''}) +def test_html_anchor_for_figure(app): + app.build(force_all=True) + content = (app.outdir / 'index.html').read_text(encoding='utf8') + assert ('<figcaption>\n<p><span class="caption-text">The caption of pic</span>' + '<a class="headerlink" href="#id1" title="Link to this image">¶</a></p>\n</figcaption>' + in content) + + +@pytest.mark.sphinx('html', testroot='directives-raw') +def test_html_raw_directive(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'index.html').read_text(encoding='utf8') + + # standard case + assert 'standalone raw directive (HTML)' in result + assert 'standalone raw directive (LaTeX)' not in result + + # with substitution + assert '<p>HTML: abc def ghi</p>' in result + assert '<p>LaTeX: abc ghi</p>' in result + + +@pytest.mark.parametrize("expect", [ + (".//link[@href='_static/persistent.css']" + "[@rel='stylesheet']", '', True), + (".//link[@href='_static/default.css']" + "[@rel='stylesheet']" + "[@title='Default']", '', True), + (".//link[@href='_static/alternate1.css']" + "[@rel='alternate stylesheet']" + "[@title='Alternate']", '', True), + (".//link[@href='_static/alternate2.css']" + "[@rel='alternate stylesheet']", '', True), + (".//link[@href='_static/more_persistent.css']" + "[@rel='stylesheet']", '', True), + (".//link[@href='_static/more_default.css']" + "[@rel='stylesheet']" + "[@title='Default']", '', True), + (".//link[@href='_static/more_alternate1.css']" + "[@rel='alternate stylesheet']" + "[@title='Alternate']", '', True), + (".//link[@href='_static/more_alternate2.css']" + "[@rel='alternate stylesheet']", '', True), +]) +@pytest.mark.sphinx('html', testroot='stylesheets') +def test_alternate_stylesheets(app, cached_etree_parse, expect): + app.build() + check_xpath(cached_etree_parse(app.outdir / 'index.html'), 'index.html', *expect) + + +@pytest.mark.sphinx('html', testroot='html_style') +def test_html_style(app, status, warning): + app.build() + result = (app.outdir / 'index.html').read_text(encoding='utf8') + assert '<link rel="stylesheet" type="text/css" href="_static/default.css" />' in result + assert ('<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />' + not in result) + + +@pytest.mark.sphinx('html', testroot='basic') +def test_html_sidebar(app, status, warning): + ctx = {} + + # default for alabaster + app.build(force_all=True) + result = (app.outdir / 'index.html').read_text(encoding='utf8') + assert ('<div class="sphinxsidebar" role="navigation" ' + 'aria-label="main navigation">' in result) + assert '<h1 class="logo"><a href="#">Python</a></h1>' in result + assert '<h3>Navigation</h3>' in result + assert '<h3>Related Topics</h3>' in result + assert '<h3 id="searchlabel">Quick search</h3>' in result + + app.builder.add_sidebars('index', ctx) + assert ctx['sidebars'] == ['about.html', 'navigation.html', 'relations.html', + 'searchbox.html', 'donate.html'] + + # only relations.html + app.config.html_sidebars = {'**': ['relations.html']} + app.build(force_all=True) + result = (app.outdir / 'index.html').read_text(encoding='utf8') + assert ('<div class="sphinxsidebar" role="navigation" ' + 'aria-label="main navigation">' in result) + assert '<h1 class="logo"><a href="#">Python</a></h1>' not in result + assert '<h3>Navigation</h3>' not in result + assert '<h3>Related Topics</h3>' in result + assert '<h3 id="searchlabel">Quick search</h3>' not in result + + app.builder.add_sidebars('index', ctx) + assert ctx['sidebars'] == ['relations.html'] + + # no sidebars + app.config.html_sidebars = {'**': []} + app.build(force_all=True) + result = (app.outdir / 'index.html').read_text(encoding='utf8') + assert ('<div class="sphinxsidebar" role="navigation" ' + 'aria-label="main navigation">' not in result) + assert '<h1 class="logo"><a href="#">Python</a></h1>' not in result + assert '<h3>Navigation</h3>' not in result + assert '<h3>Related Topics</h3>' not in result + assert '<h3 id="searchlabel">Quick search</h3>' not in result + + app.builder.add_sidebars('index', ctx) + assert ctx['sidebars'] == [] + + +@pytest.mark.parametrize(("fname", "expect"), [ + ('index.html', (".//h1/em/a[@href='https://example.com/cp.1']", '', True)), + ('index.html', (".//em/a[@href='https://example.com/man.1']", '', True)), + ('index.html', (".//em/a[@href='https://example.com/ls.1']", '', True)), + ('index.html', (".//em/a[@href='https://example.com/sphinx.']", '', True)), +]) +@pytest.mark.sphinx('html', testroot='manpage_url', confoverrides={ + 'manpages_url': 'https://example.com/{page}.{section}'}) +@pytest.mark.test_params(shared_result='test_build_html_manpage_url') +def test_html_manpage(app, cached_etree_parse, fname, expect): + app.build() + check_xpath(cached_etree_parse(app.outdir / fname), fname, *expect) + + +@pytest.mark.sphinx('html', testroot='toctree-glob', + confoverrides={'html_baseurl': 'https://example.com/'}) +def test_html_baseurl(app, status, warning): + app.build() + + result = (app.outdir / 'index.html').read_text(encoding='utf8') + assert '<link rel="canonical" href="https://example.com/index.html" />' in result + + result = (app.outdir / 'qux' / 'index.html').read_text(encoding='utf8') + assert '<link rel="canonical" href="https://example.com/qux/index.html" />' in result + + +@pytest.mark.sphinx('html', testroot='toctree-glob', + confoverrides={'html_baseurl': 'https://example.com/subdir', + 'html_file_suffix': '.htm'}) +def test_html_baseurl_and_html_file_suffix(app, status, warning): + app.build() + + result = (app.outdir / 'index.htm').read_text(encoding='utf8') + assert '<link rel="canonical" href="https://example.com/subdir/index.htm" />' in result + + result = (app.outdir / 'qux' / 'index.htm').read_text(encoding='utf8') + assert '<link rel="canonical" href="https://example.com/subdir/qux/index.htm" />' in result + + +@pytest.mark.sphinx(testroot='basic', srcdir='validate_html_extra_path') +def test_validate_html_extra_path(app): + (app.confdir / '_static').mkdir(parents=True, exist_ok=True) + app.config.html_extra_path = [ + '/path/to/not_found', # not found + '_static', + app.outdir, # outdir + app.outdir / '_static', # inside outdir + ] + with pytest.warns(RemovedInSphinx80Warning, match='Use "pathlib.Path" or "os.fspath" instead'): + validate_html_extra_path(app, app.config) + assert app.config.html_extra_path == ['_static'] + + +@pytest.mark.sphinx(testroot='basic', srcdir='validate_html_static_path') +def test_validate_html_static_path(app): + (app.confdir / '_static').mkdir(parents=True, exist_ok=True) + app.config.html_static_path = [ + '/path/to/not_found', # not found + '_static', + app.outdir, # outdir + app.outdir / '_static', # inside outdir + ] + with pytest.warns(RemovedInSphinx80Warning, match='Use "pathlib.Path" or "os.fspath" instead'): + validate_html_static_path(app, app.config) + assert app.config.html_static_path == ['_static'] + + +@pytest.mark.sphinx('html', testroot='basic', + confoverrides={'html_permalinks': False}) +def test_html_permalink_disable(app): + app.build() + content = (app.outdir / 'index.html').read_text(encoding='utf8') + + assert '<h1>The basic Sphinx documentation for testing</h1>' in content + + +@pytest.mark.sphinx('html', testroot='basic', + confoverrides={'html_permalinks_icon': '<span>[PERMALINK]</span>'}) +def test_html_permalink_icon(app): + app.build() + content = (app.outdir / 'index.html').read_text(encoding='utf8') + + assert ('<h1>The basic Sphinx documentation for testing<a class="headerlink" ' + 'href="#the-basic-sphinx-documentation-for-testing" ' + 'title="Link to this heading"><span>[PERMALINK]</span></a></h1>' in content) + + +@pytest.mark.sphinx('html', testroot='html_signaturereturn_icon') +def test_html_signaturereturn_icon(app): + app.build() + content = (app.outdir / 'index.html').read_text(encoding='utf8') + + assert ('<span class="sig-return-icon">→</span>' in content) + + +@pytest.mark.sphinx('html', testroot='root', srcdir=os.urandom(4).hex()) +def test_html_remove_sources_before_write_gh_issue_10786(app, warning): + # see: https://github.com/sphinx-doc/sphinx/issues/10786 + target = app.srcdir / 'img.png' + + def handler(app): + assert target.exists() + target.unlink() + return [] + + app.connect('html-collect-pages', handler) + assert target.exists() + app.build() + assert not target.exists() + + ws = strip_colors(warning.getvalue()).splitlines() + assert len(ws) >= 1 + + file = os.fsdecode(target) + assert f'WARNING: cannot copy image file {file!r}: {file!s} does not exist' == ws[-1] diff --git a/tests/test_builders/test_build_html_5_output.py b/tests/test_builders/test_build_html_5_output.py new file mode 100644 index 0000000..ece6f49 --- /dev/null +++ b/tests/test_builders/test_build_html_5_output.py @@ -0,0 +1,276 @@ +"""Test the HTML builder and check output against XPath.""" + +import re + +import pytest + +from tests.test_builders.xpath_util import check_xpath + + +def tail_check(check): + rex = re.compile(check) + + def checker(nodes): + for node in nodes: + if node.tail and rex.search(node.tail): + return True + msg = f'{check!r} not found in tail of any nodes {nodes}' + raise AssertionError(msg) + return checker + + +@pytest.mark.parametrize(("fname", "path", "check"), [ + ('images.html', ".//img[@src='_images/img.png']", ''), + ('images.html', ".//img[@src='_images/img1.png']", ''), + ('images.html', ".//img[@src='_images/simg.png']", ''), + ('images.html', ".//img[@src='_images/svgimg.svg']", ''), + ('images.html', ".//a[@href='_sources/images.txt']", ''), + + ('subdir/images.html', ".//img[@src='../_images/img1.png']", ''), + ('subdir/images.html', ".//img[@src='../_images/rimg.png']", ''), + + ('subdir/includes.html', ".//a[@class='reference download internal']", ''), + ('subdir/includes.html', ".//img[@src='../_images/img.png']", ''), + ('subdir/includes.html', ".//p", 'This is an include file.'), + ('subdir/includes.html', ".//pre/span", 'line 1'), + ('subdir/includes.html', ".//pre/span", 'line 2'), + + ('includes.html', ".//pre", 'Max Strauß'), + ('includes.html', ".//a[@class='reference download internal']", ''), + ('includes.html', ".//pre/span", '"quotes"'), + ('includes.html', ".//pre/span", "'included'"), + ('includes.html', ".//pre/span[@class='s2']", 'üöä'), + ('includes.html', ".//div[@class='inc-pyobj1 highlight-text notranslate']//pre", + r'^class Foo:\n pass\n\s*$'), + ('includes.html', ".//div[@class='inc-pyobj2 highlight-text notranslate']//pre", + r'^ def baz\(\):\n pass\n\s*$'), + ('includes.html', ".//div[@class='inc-lines highlight-text notranslate']//pre", + r'^class Foo:\n pass\nclass Bar:\n$'), + ('includes.html', ".//div[@class='inc-startend highlight-text notranslate']//pre", + '^foo = "Including Unicode characters: üöä"\\n$'), + ('includes.html', ".//div[@class='inc-preappend highlight-text notranslate']//pre", + r'(?m)^START CODE$'), + ('includes.html', ".//div[@class='inc-pyobj-dedent highlight-python notranslate']//span", + r'def'), + ('includes.html', ".//div[@class='inc-tab3 highlight-text notranslate']//pre", + r'-| |-'), + ('includes.html', ".//div[@class='inc-tab8 highlight-python notranslate']//pre/span", + r'-| |-'), + + ('autodoc.html', ".//dl[@class='py class']/dt[@id='autodoc_target.Class']", ''), + ('autodoc.html', ".//dl[@class='py function']/dt[@id='autodoc_target.function']/em/span/span", r'\*\*'), + ('autodoc.html', ".//dl[@class='py function']/dt[@id='autodoc_target.function']/em/span/span", r'kwds'), + ('autodoc.html', ".//dd/p", r'Return spam\.'), + + ('extapi.html', ".//strong", 'from class: Bar'), + + ('markup.html', ".//title", 'set by title directive'), + ('markup.html', ".//p/em", 'Section author: Georg Brandl'), + ('markup.html', ".//p/em", 'Module author: Georg Brandl'), + # created by the meta directive + ('markup.html', ".//meta[@name='author'][@content='Me']", ''), + ('markup.html', ".//meta[@name='keywords'][@content='docs, sphinx']", ''), + # a label created by ``.. _label:`` + ('markup.html', ".//div[@id='label']", ''), + # code with standard code blocks + ('markup.html', ".//pre", '^some code$'), + # an option list + ('markup.html', ".//span[@class='option']", '--help'), + # admonitions + ('markup.html', ".//p[@class='admonition-title']", 'My Admonition'), + ('markup.html', ".//div[@class='admonition note']/p", 'Note text.'), + ('markup.html', ".//div[@class='admonition warning']/p", 'Warning text.'), + # inline markup + ('markup.html', ".//li/p/strong", r'^command\\n$'), + ('markup.html', ".//li/p/strong", r'^program\\n$'), + ('markup.html', ".//li/p/em", r'^dfn\\n$'), + ('markup.html', ".//li/p/kbd", r'^kbd\\n$'), + ('markup.html', ".//li/p/span", 'File \N{TRIANGULAR BULLET} Close'), + ('markup.html', ".//li/p/code/span[@class='pre']", '^a/$'), + ('markup.html', ".//li/p/code/em/span[@class='pre']", '^varpart$'), + ('markup.html', ".//li/p/code/em/span[@class='pre']", '^i$'), + ('markup.html', ".//a[@href='https://peps.python.org/pep-0008/']" + "[@class='pep reference external']/strong", 'PEP 8'), + ('markup.html', ".//a[@href='https://peps.python.org/pep-0008/']" + "[@class='pep reference external']/strong", + 'Python Enhancement Proposal #8'), + ('markup.html', ".//a[@href='https://datatracker.ietf.org/doc/html/rfc1.html']" + "[@class='rfc reference external']/strong", 'RFC 1'), + ('markup.html', ".//a[@href='https://datatracker.ietf.org/doc/html/rfc1.html']" + "[@class='rfc reference external']/strong", 'Request for Comments #1'), + ('markup.html', ".//a[@href='objects.html#envvar-HOME']" + "[@class='reference internal']/code/span[@class='pre']", 'HOME'), + ('markup.html', ".//a[@href='#with']" + "[@class='reference internal']/code/span[@class='pre']", '^with$'), + ('markup.html', ".//a[@href='#grammar-token-try_stmt']" + "[@class='reference internal']/code/span", '^statement$'), + ('markup.html', ".//a[@href='#some-label'][@class='reference internal']/span", '^here$'), + ('markup.html', ".//a[@href='#some-label'][@class='reference internal']/span", '^there$'), + ('markup.html', ".//a[@href='subdir/includes.html']" + "[@class='reference internal']/span", 'Including in subdir'), + ('markup.html', ".//a[@href='objects.html#cmdoption-python-c']" + "[@class='reference internal']/code/span[@class='pre']", '-c'), + # abbreviations + ('markup.html', ".//abbr[@title='abbreviation']", '^abbr$'), + # version stuff + ('markup.html', ".//div[@class='versionadded']/p/span", 'Added in version 0.6: '), + ('markup.html', ".//div[@class='versionadded']/p/span", + tail_check('First paragraph of versionadded')), + ('markup.html', ".//div[@class='versionchanged']/p/span", + tail_check('First paragraph of versionchanged')), + ('markup.html', ".//div[@class='versionchanged']/p", + 'Second paragraph of versionchanged'), + ('markup.html', ".//div[@class='versionremoved']/p/span", 'Removed in version 0.6: '), + # footnote reference + ('markup.html', ".//a[@class='footnote-reference brackets']", r'1'), + # created by reference lookup + ('markup.html', ".//a[@href='index.html#ref1']", ''), + # ``seealso`` directive + ('markup.html', ".//div/p[@class='admonition-title']", 'See also'), + # a ``hlist`` directive + ('markup.html', ".//table[@class='hlist']/tr/td/ul/li/p", '^This$'), + # a ``centered`` directive + ('markup.html', ".//p[@class='centered']/strong", 'LICENSE'), + # a glossary + ('markup.html', ".//dl/dt[@id='term-boson']", 'boson'), + ('markup.html', ".//dl/dt[@id='term-boson']/a", '¶'), + # a production list + ('markup.html', ".//pre/strong", 'try_stmt'), + ('markup.html', ".//pre/a[@href='#grammar-token-try1_stmt']/code/span", 'try1_stmt'), + # tests for ``only`` directive + ('markup.html', ".//p", 'A global substitution!'), + ('markup.html', ".//p", 'In HTML.'), + ('markup.html', ".//p", 'In both.'), + ('markup.html', ".//p", 'Always present'), + # tests for ``any`` role + ('markup.html', ".//a[@href='#with']/span", 'headings'), + ('markup.html', ".//a[@href='objects.html#func_without_body']/code/span", 'objects'), + # tests for numeric labels + ('markup.html', ".//a[@href='#id1'][@class='reference internal']/span", 'Testing various markup'), + # tests for smartypants + ('markup.html', ".//li/p", 'Smart “quotes” in English ‘text’.'), + ('markup.html', ".//li/p", 'Smart — long and – short dashes.'), + ('markup.html', ".//li/p", 'Ellipsis…'), + ('markup.html', ".//li/p/code/span[@class='pre']", 'foo--"bar"...'), + ('markup.html', ".//p", 'Этот «абзац» должен использовать „русские“ кавычки.'), + ('markup.html', ".//p", 'Il dit : « C’est “super” ! »'), + + ('objects.html', ".//dt[@id='mod.Cls.meth1']", ''), + ('objects.html', ".//dt[@id='errmod.Error']", ''), + ('objects.html', ".//dt/span[@class='sig-name descname']/span[@class='pre']", r'long\(parameter,'), + ('objects.html', ".//dt/span[@class='sig-name descname']/span[@class='pre']", r'list\)'), + ('objects.html', ".//dt/span[@class='sig-name descname']/span[@class='pre']", 'another'), + ('objects.html', ".//dt/span[@class='sig-name descname']/span[@class='pre']", 'one'), + ('objects.html', ".//a[@href='#mod.Cls'][@class='reference internal']", ''), + ('objects.html', ".//dl[@class='std userdesc']", ''), + ('objects.html', ".//dt[@id='userdesc-myobj']", ''), + ('objects.html', ".//a[@href='#userdesc-myobj'][@class='reference internal']", ''), + # docfields + ('objects.html', ".//a[@class='reference internal'][@href='#TimeInt']/em", 'TimeInt'), + ('objects.html', ".//a[@class='reference internal'][@href='#Time']", 'Time'), + ('objects.html', ".//a[@class='reference internal'][@href='#errmod.Error']/strong", 'Error'), + # C references + ('objects.html', ".//span[@class='pre']", 'CFunction()'), + ('objects.html', ".//a[@href='#c.Sphinx_DoSomething']", ''), + ('objects.html', ".//a[@href='#c.SphinxStruct.member']", ''), + ('objects.html', ".//a[@href='#c.SPHINX_USE_PYTHON']", ''), + ('objects.html', ".//a[@href='#c.SphinxType']", ''), + ('objects.html', ".//a[@href='#c.sphinx_global']", ''), + # test global TOC created by toctree() + ('objects.html', ".//ul[@class='current']/li[@class='toctree-l1 current']/a[@href='#']", + 'Testing object descriptions'), + ('objects.html', ".//li[@class='toctree-l1']/a[@href='markup.html']", + 'Testing various markup'), + # test unknown field names + ('objects.html', ".//dt[@class='field-odd']", 'Field_name'), + ('objects.html', ".//dt[@class='field-even']", 'Field_name all lower'), + ('objects.html', ".//dt[@class='field-odd']", 'FIELD_NAME'), + ('objects.html', ".//dt[@class='field-even']", 'FIELD_NAME ALL CAPS'), + ('objects.html', ".//dt[@class='field-odd']", 'Field_Name'), + ('objects.html', ".//dt[@class='field-even']", 'Field_Name All Word Caps'), + # ('objects.html', ".//dt[@class='field-odd']", 'Field_name'), (duplicate) + ('objects.html', ".//dt[@class='field-even']", 'Field_name First word cap'), + ('objects.html', ".//dt[@class='field-odd']", 'FIELd_name'), + ('objects.html', ".//dt[@class='field-even']", 'FIELd_name PARTial caps'), + # custom sidebar + ('objects.html', ".//h4", 'Custom sidebar'), + # docfields + ('objects.html', ".//dd[@class='field-odd']/p/strong", '^moo$'), + ('objects.html', ".//dd[@class='field-odd']/p/strong", tail_check(r'\(Moo\) .* Moo')), + ('objects.html', ".//dd[@class='field-odd']/ul/li/p/strong", '^hour$'), + ('objects.html', ".//dd[@class='field-odd']/ul/li/p/em", '^DuplicateType$'), + ('objects.html', ".//dd[@class='field-odd']/ul/li/p/em", tail_check(r'.* Some parameter')), + # others + ('objects.html', ".//a[@class='reference internal'][@href='#cmdoption-perl-arg-p']/code/span", + 'perl'), + ('objects.html', ".//a[@class='reference internal'][@href='#cmdoption-perl-arg-p']/code/span", + '\\+p'), + ('objects.html', ".//a[@class='reference internal'][@href='#cmdoption-perl-ObjC']/code/span", + '--ObjC\\+\\+'), + ('objects.html', ".//a[@class='reference internal'][@href='#cmdoption-perl-plugin.option']/code/span", + '--plugin.option'), + ('objects.html', ".//a[@class='reference internal'][@href='#cmdoption-perl-arg-create-auth-token']" + "/code/span", + 'create-auth-token'), + ('objects.html', ".//a[@class='reference internal'][@href='#cmdoption-perl-arg-arg']/code/span", + 'arg'), + ('objects.html', ".//a[@class='reference internal'][@href='#cmdoption-perl-j']/code/span", + '-j'), + ('objects.html', ".//a[@class='reference internal'][@href='#cmdoption-hg-arg-commit']/code/span", + 'hg'), + ('objects.html', ".//a[@class='reference internal'][@href='#cmdoption-hg-arg-commit']/code/span", + 'commit'), + ('objects.html', ".//a[@class='reference internal'][@href='#cmdoption-git-commit-p']/code/span", + 'git'), + ('objects.html', ".//a[@class='reference internal'][@href='#cmdoption-git-commit-p']/code/span", + 'commit'), + ('objects.html', ".//a[@class='reference internal'][@href='#cmdoption-git-commit-p']/code/span", + '-p'), + + ('index.html', ".//meta[@name='hc'][@content='hcval']", ''), + ('index.html', ".//meta[@name='hc_co'][@content='hcval_co']", ''), + ('index.html', ".//li[@class='toctree-l1']/a", 'Testing various markup'), + ('index.html', ".//li[@class='toctree-l2']/a", 'Inline markup'), + ('index.html', ".//title", 'Sphinx <Tests>'), + ('index.html', ".//div[@class='footer']", 'copyright text credits'), + ('index.html', ".//a[@href='https://python.org/']" + "[@class='reference external']", ''), + ('index.html', ".//li/p/a[@href='genindex.html']/span", 'Index'), + ('index.html', ".//li/p/a[@href='py-modindex.html']/span", 'Module Index'), + # custom sidebar only for contents + ('index.html', ".//h4", 'Contents sidebar'), + # custom JavaScript + ('index.html', ".//script[@src='file://moo.js']", ''), + # URL in contents + ('index.html', ".//a[@class='reference external'][@href='https://sphinx-doc.org/']", + 'https://sphinx-doc.org/'), + ('index.html', ".//a[@class='reference external'][@href='https://sphinx-doc.org/latest/']", + 'Latest reference'), + # Indirect hyperlink targets across files + ('index.html', ".//a[@href='markup.html#some-label'][@class='reference internal']/span", + '^indirect hyperref$'), + + ('bom.html', ".//title", " File with UTF-8 BOM"), + + ('extensions.html', ".//a[@href='https://python.org/dev/']", "https://python.org/dev/"), + ('extensions.html', ".//a[@href='https://bugs.python.org/issue1000']", "issue 1000"), + ('extensions.html', ".//a[@href='https://bugs.python.org/issue1042']", "explicit caption"), + + # index entries + ('genindex.html', ".//a/strong", "Main"), + ('genindex.html', ".//a/strong", "[1]"), + ('genindex.html', ".//a/strong", "Other"), + ('genindex.html', ".//a", "entry"), + ('genindex.html', ".//li/a", "double"), + + ('otherext.html', ".//h1", "Generated section"), + ('otherext.html', ".//a[@href='_sources/otherext.foo.txt']", ''), + + ('search.html', ".//meta[@name='robots'][@content='noindex']", ''), +]) +@pytest.mark.sphinx('html', tags=['testtag'], + confoverrides={'html_context.hckey_co': 'hcval_co'}) +@pytest.mark.test_params(shared_result='test_build_html_output') +def test_html5_output(app, cached_etree_parse, fname, path, check): + app.build() + check_xpath(cached_etree_parse(app.outdir / fname), fname, path, check) diff --git a/tests/test_builders/test_build_html_assets.py b/tests/test_builders/test_build_html_assets.py new file mode 100644 index 0000000..fc7a987 --- /dev/null +++ b/tests/test_builders/test_build_html_assets.py @@ -0,0 +1,152 @@ +"""Test the HTML builder and check output against XPath.""" + +import re +from pathlib import Path + +import pytest + +import sphinx.builders.html +from sphinx.builders.html._assets import _file_checksum +from sphinx.errors import ThemeError + + +@pytest.mark.sphinx('html', testroot='html_assets') +def test_html_assets(app): + app.build(force_all=True) + + # exclude_path and its family + assert not (app.outdir / 'static' / 'index.html').exists() + assert not (app.outdir / 'extra' / 'index.html').exists() + + # html_static_path + assert not (app.outdir / '_static' / '.htaccess').exists() + assert not (app.outdir / '_static' / '.htpasswd').exists() + assert (app.outdir / '_static' / 'API.html').exists() + assert (app.outdir / '_static' / 'API.html').read_text(encoding='utf8') == 'Sphinx-1.4.4' + assert (app.outdir / '_static' / 'css' / 'style.css').exists() + assert (app.outdir / '_static' / 'js' / 'custom.js').exists() + assert (app.outdir / '_static' / 'rimg.png').exists() + assert not (app.outdir / '_static' / '_build' / 'index.html').exists() + assert (app.outdir / '_static' / 'background.png').exists() + assert not (app.outdir / '_static' / 'subdir' / '.htaccess').exists() + assert not (app.outdir / '_static' / 'subdir' / '.htpasswd').exists() + + # html_extra_path + assert (app.outdir / '.htaccess').exists() + assert not (app.outdir / '.htpasswd').exists() + assert (app.outdir / 'API.html_t').exists() + assert (app.outdir / 'css/style.css').exists() + assert (app.outdir / 'rimg.png').exists() + assert not (app.outdir / '_build' / 'index.html').exists() + assert (app.outdir / 'background.png').exists() + assert (app.outdir / 'subdir' / '.htaccess').exists() + assert not (app.outdir / 'subdir' / '.htpasswd').exists() + + # html_css_files + content = (app.outdir / 'index.html').read_text(encoding='utf8') + assert '<link rel="stylesheet" type="text/css" href="_static/css/style.css" />' in content + assert ('<link media="print" rel="stylesheet" title="title" type="text/css" ' + 'href="https://example.com/custom.css" />' in content) + + # html_js_files + assert '<script src="_static/js/custom.js"></script>' in content + assert ('<script async="async" src="https://example.com/script.js">' + '</script>' in content) + + +@pytest.mark.sphinx('html', testroot='html_assets') +def test_assets_order(app, monkeypatch): + monkeypatch.setattr(sphinx.builders.html, '_file_checksum', lambda o, f: '') + + app.add_css_file('normal.css') + app.add_css_file('early.css', priority=100) + app.add_css_file('late.css', priority=750) + app.add_css_file('lazy.css', priority=900) + app.add_js_file('normal.js') + app.add_js_file('early.js', priority=100) + app.add_js_file('late.js', priority=750) + app.add_js_file('lazy.js', priority=900) + + app.build(force_all=True) + content = (app.outdir / 'index.html').read_text(encoding='utf8') + + # css_files + expected = [ + '_static/early.css', + '_static/pygments.css', + '_static/alabaster.css', + 'https://example.com/custom.css', + '_static/normal.css', + '_static/late.css', + '_static/css/style.css', + '_static/lazy.css', + ] + pattern = '.*'.join(f'href="{re.escape(f)}"' for f in expected) + assert re.search(pattern, content, re.DOTALL), content + + # js_files + expected = [ + '_static/early.js', + '_static/doctools.js', + '_static/sphinx_highlight.js', + 'https://example.com/script.js', + '_static/normal.js', + '_static/late.js', + '_static/js/custom.js', + '_static/lazy.js', + ] + pattern = '.*'.join(f'src="{re.escape(f)}"' for f in expected) + assert re.search(pattern, content, re.DOTALL), content + + +@pytest.mark.sphinx('html', testroot='html_file_checksum') +def test_file_checksum(app): + app.add_css_file('stylesheet-a.css') + app.add_css_file('stylesheet-b.css') + app.add_css_file('https://example.com/custom.css') + app.add_js_file('script.js') + app.add_js_file('empty.js') + app.add_js_file('https://example.com/script.js') + + app.build(force_all=True) + content = (app.outdir / 'index.html').read_text(encoding='utf8') + + # checksum for local files + assert '<link rel="stylesheet" type="text/css" href="_static/stylesheet-a.css?v=e575b6df" />' in content + assert '<link rel="stylesheet" type="text/css" href="_static/stylesheet-b.css?v=a2d5cc0f" />' in content + assert '<script src="_static/script.js?v=48278d48"></script>' in content + + # empty files have no checksum + assert '<script src="_static/empty.js"></script>' in content + + # no checksum for hyperlinks + assert '<link rel="stylesheet" type="text/css" href="https://example.com/custom.css" />' in content + assert '<script src="https://example.com/script.js"></script>' in content + + +def test_file_checksum_query_string(): + with pytest.raises(ThemeError, match='Local asset file paths must not contain query strings'): + _file_checksum(Path(), 'with_query_string.css?dead_parrots=1') + + with pytest.raises(ThemeError, match='Local asset file paths must not contain query strings'): + _file_checksum(Path(), 'with_query_string.js?dead_parrots=1') + + with pytest.raises(ThemeError, match='Local asset file paths must not contain query strings'): + _file_checksum(Path.cwd(), '_static/with_query_string.css?dead_parrots=1') + + with pytest.raises(ThemeError, match='Local asset file paths must not contain query strings'): + _file_checksum(Path.cwd(), '_static/with_query_string.js?dead_parrots=1') + + +@pytest.mark.sphinx('html', testroot='html_assets') +def test_javscript_loading_method(app): + app.add_js_file('normal.js') + app.add_js_file('early.js', loading_method='async') + app.add_js_file('late.js', loading_method='defer') + + app.build(force_all=True) + content = (app.outdir / 'index.html').read_text(encoding='utf8') + + assert '<script src="_static/normal.js"></script>' in content + assert '<script async="async" src="_static/early.js"></script>' in content + assert '<script defer="defer" src="_static/late.js"></script>' in content diff --git a/tests/test_builders/test_build_html_code.py b/tests/test_builders/test_build_html_code.py new file mode 100644 index 0000000..f07eb97 --- /dev/null +++ b/tests/test_builders/test_build_html_code.py @@ -0,0 +1,46 @@ +import pytest + + +@pytest.mark.sphinx('html', testroot='reST-code-block', + confoverrides={'html_codeblock_linenos_style': 'table'}) +def test_html_codeblock_linenos_style_table(app): + app.build() + content = (app.outdir / 'index.html').read_text(encoding='utf8') + + assert ('<div class="linenodiv"><pre><span class="normal">1</span>\n' + '<span class="normal">2</span>\n' + '<span class="normal">3</span>\n' + '<span class="normal">4</span></pre></div>') in content + + +@pytest.mark.sphinx('html', testroot='reST-code-block', + confoverrides={'html_codeblock_linenos_style': 'inline'}) +def test_html_codeblock_linenos_style_inline(app): + app.build() + content = (app.outdir / 'index.html').read_text(encoding='utf8') + + assert '<span class="linenos">1</span>' in content + + +@pytest.mark.sphinx('html', testroot='reST-code-role') +def test_html_code_role(app): + app.build() + content = (app.outdir / 'index.html').read_text(encoding='utf8') + + common_content = ( + '<span class="k">def</span> <span class="nf">foo</span>' + '<span class="p">(</span>' + '<span class="mi">1</span> ' + '<span class="o">+</span> ' + '<span class="mi">2</span> ' + '<span class="o">+</span> ' + '<span class="kc">None</span> ' + '<span class="o">+</span> ' + '<span class="s2">"abc"</span>' + '<span class="p">):</span> ' + '<span class="k">pass</span>') + assert ('<p>Inline <code class="code highlight python docutils literal highlight-python">' + + common_content + '</code> code block</p>') in content + assert ('<div class="highlight-python notranslate">' + + '<div class="highlight"><pre><span></span>' + + common_content) in content diff --git a/tests/test_builders/test_build_html_download.py b/tests/test_builders/test_build_html_download.py new file mode 100644 index 0000000..1201c66 --- /dev/null +++ b/tests/test_builders/test_build_html_download.py @@ -0,0 +1,62 @@ +import hashlib +import re + +import pytest + + +@pytest.mark.sphinx('html') +@pytest.mark.test_params(shared_result='test_build_html_output') +def test_html_download(app): + app.build() + + # subdir/includes.html + result = (app.outdir / 'subdir' / 'includes.html').read_text(encoding='utf8') + pattern = ('<a class="reference download internal" download="" ' + 'href="../(_downloads/.*/img.png)">') + matched = re.search(pattern, result) + assert matched + assert (app.outdir / matched.group(1)).exists() + filename = matched.group(1) + + # includes.html + result = (app.outdir / 'includes.html').read_text(encoding='utf8') + pattern = ('<a class="reference download internal" download="" ' + 'href="(_downloads/.*/img.png)">') + matched = re.search(pattern, result) + assert matched + assert (app.outdir / matched.group(1)).exists() + assert matched.group(1) == filename + + pattern = ('<a class="reference download internal" download="" ' + 'href="(_downloads/.*/)(file_with_special_%23_chars.xyz)">') + matched = re.search(pattern, result) + assert matched + assert (app.outdir / matched.group(1) / "file_with_special_#_chars.xyz").exists() + + +@pytest.mark.sphinx('html', testroot='roles-download') +def test_html_download_role(app, status, warning): + app.build() + digest = hashlib.md5(b'dummy.dat', usedforsecurity=False).hexdigest() + assert (app.outdir / '_downloads' / digest / 'dummy.dat').exists() + digest_another = hashlib.md5(b'another/dummy.dat', usedforsecurity=False).hexdigest() + assert (app.outdir / '_downloads' / digest_another / 'dummy.dat').exists() + + content = (app.outdir / 'index.html').read_text(encoding='utf8') + assert (('<li><p><a class="reference download internal" download="" ' + 'href="_downloads/%s/dummy.dat">' + '<code class="xref download docutils literal notranslate">' + '<span class="pre">dummy.dat</span></code></a></p></li>' % digest) + in content) + assert (('<li><p><a class="reference download internal" download="" ' + 'href="_downloads/%s/dummy.dat">' + '<code class="xref download docutils literal notranslate">' + '<span class="pre">another/dummy.dat</span></code></a></p></li>' % + digest_another) in content) + assert ('<li><p><code class="xref download docutils literal notranslate">' + '<span class="pre">not_found.dat</span></code></p></li>' in content) + assert ('<li><p><a class="reference download external" download="" ' + 'href="https://www.sphinx-doc.org/en/master/_static/sphinx-logo.svg">' + '<code class="xref download docutils literal notranslate">' + '<span class="pre">Sphinx</span> <span class="pre">logo</span>' + '</code></a></p></li>' in content) diff --git a/tests/test_builders/test_build_html_highlight.py b/tests/test_builders/test_build_html_highlight.py new file mode 100644 index 0000000..aee1ece --- /dev/null +++ b/tests/test_builders/test_build_html_highlight.py @@ -0,0 +1,61 @@ +from unittest.mock import ANY, call, patch + +import pytest + + +@pytest.mark.sphinx('html', testroot='basic') +def test_html_pygments_style_default(app): + style = app.builder.highlighter.formatter_args.get('style') + assert style.__name__ == 'Alabaster' + + +@pytest.mark.sphinx('html', testroot='basic', + confoverrides={'pygments_style': 'sphinx'}) +def test_html_pygments_style_manually(app): + style = app.builder.highlighter.formatter_args.get('style') + assert style.__name__ == 'SphinxStyle' + + +@pytest.mark.sphinx('html', testroot='basic', + confoverrides={'html_theme': 'classic'}) +def test_html_pygments_for_classic_theme(app): + style = app.builder.highlighter.formatter_args.get('style') + assert style.__name__ == 'SphinxStyle' + + +@pytest.mark.sphinx('html', testroot='basic') +def test_html_dark_pygments_style_default(app): + assert app.builder.dark_highlighter is None + + +@pytest.mark.sphinx('html', testroot='highlight_options') +def test_highlight_options(app): + subject = app.builder.highlighter + with patch.object(subject, 'highlight_block', wraps=subject.highlight_block) as highlight: + app.build() + + call_args = highlight.call_args_list + assert len(call_args) == 3 + assert call_args[0] == call(ANY, 'default', force=False, linenos=False, + location=ANY, opts={'default_option': True}) + assert call_args[1] == call(ANY, 'python', force=False, linenos=False, + location=ANY, opts={'python_option': True}) + assert call_args[2] == call(ANY, 'java', force=False, linenos=False, + location=ANY, opts={}) + + +@pytest.mark.sphinx('html', testroot='highlight_options', + confoverrides={'highlight_options': {'default_option': True}}) +def test_highlight_options_old(app): + subject = app.builder.highlighter + with patch.object(subject, 'highlight_block', wraps=subject.highlight_block) as highlight: + app.build() + + call_args = highlight.call_args_list + assert len(call_args) == 3 + assert call_args[0] == call(ANY, 'default', force=False, linenos=False, + location=ANY, opts={'default_option': True}) + assert call_args[1] == call(ANY, 'python', force=False, linenos=False, + location=ANY, opts={}) + assert call_args[2] == call(ANY, 'java', force=False, linenos=False, + location=ANY, opts={}) diff --git a/tests/test_builders/test_build_html_image.py b/tests/test_builders/test_build_html_image.py new file mode 100644 index 0000000..08ed618 --- /dev/null +++ b/tests/test_builders/test_build_html_image.py @@ -0,0 +1,80 @@ +import re +from pathlib import Path + +import docutils +import pytest + + +@pytest.mark.sphinx('html', testroot='images') +def test_html_remote_images(app, status, warning): + app.build(force_all=True) + + result = (app.outdir / 'index.html').read_text(encoding='utf8') + assert ('<img alt="http://localhost:7777/sphinx.png" ' + 'src="http://localhost:7777/sphinx.png" />' in result) + assert not (app.outdir / 'sphinx.png').exists() + + +@pytest.mark.sphinx('html', testroot='image-escape') +def test_html_encoded_image(app, status, warning): + app.build(force_all=True) + + result = (app.outdir / 'index.html').read_text(encoding='utf8') + assert ('<img alt="_images/img_%231.png" src="_images/img_%231.png" />' in result) + assert (app.outdir / '_images/img_#1.png').exists() + + +@pytest.mark.sphinx('html', testroot='remote-logo') +def test_html_remote_logo(app, status, warning): + app.build(force_all=True) + + result = (app.outdir / 'index.html').read_text(encoding='utf8') + assert ('<img class="logo" src="https://www.python.org/static/img/python-logo.png" alt="Logo"/>' in result) + assert ('<link rel="icon" href="https://www.python.org/static/favicon.ico"/>' in result) + assert not (app.outdir / 'python-logo.png').exists() + + +@pytest.mark.sphinx('html', testroot='local-logo') +def test_html_local_logo(app, status, warning): + app.build(force_all=True) + + result = (app.outdir / 'index.html').read_text(encoding='utf8') + assert ('<img class="logo" src="_static/img.png" alt="Logo"/>' in result) + assert (app.outdir / '_static/img.png').exists() + + +@pytest.mark.sphinx(testroot='html_scaled_image_link') +def test_html_scaled_image_link(app): + app.build() + context = (app.outdir / 'index.html').read_text(encoding='utf8') + + # no scaled parameters + assert re.search('\n<img alt="_images/img.png" src="_images/img.png" />', context) + + # scaled_image_link + # Docutils 0.21 adds a newline before the closing </a> tag + closing_space = "\n" if docutils.__version_info__[:2] >= (0, 21) else "" + assert re.search('\n<a class="reference internal image-reference" href="_images/img.png">' + '<img alt="_images/img.png" src="_images/img.png" style="[^"]+" />' + f'{closing_space}</a>', + context) + + # no-scaled-link class disables the feature + assert re.search('\n<img alt="_images/img.png" class="no-scaled-link"' + ' src="_images/img.png" style="[^"]+" />', + context) + + +@pytest.mark.sphinx('html', testroot='images') +def test_copy_images(app, status, warning): + app.build() + + images_dir = Path(app.outdir) / '_images' + images = {image.name for image in images_dir.rglob('*')} + assert images == { + 'img.png', + 'rimg.png', + 'rimg1.png', + 'svgimg.svg', + 'testimäge.png', + } diff --git a/tests/test_builders/test_build_html_maths.py b/tests/test_builders/test_build_html_maths.py new file mode 100644 index 0000000..900846b --- /dev/null +++ b/tests/test_builders/test_build_html_maths.py @@ -0,0 +1,58 @@ +import pytest + +from sphinx.errors import ConfigError + + +@pytest.mark.sphinx('html', testroot='basic') +def test_default_html_math_renderer(app, status, warning): + assert app.builder.math_renderer_name == 'mathjax' + + +@pytest.mark.sphinx('html', testroot='basic', + confoverrides={'extensions': ['sphinx.ext.mathjax']}) +def test_html_math_renderer_is_mathjax(app, status, warning): + assert app.builder.math_renderer_name == 'mathjax' + + +@pytest.mark.sphinx('html', testroot='basic', + confoverrides={'extensions': ['sphinx.ext.imgmath']}) +def test_html_math_renderer_is_imgmath(app, status, warning): + assert app.builder.math_renderer_name == 'imgmath' + + +@pytest.mark.sphinx('html', testroot='basic', + confoverrides={'extensions': ['sphinxcontrib.jsmath', + 'sphinx.ext.imgmath']}) +def test_html_math_renderer_is_duplicated(make_app, app_params): + args, kwargs = app_params + with pytest.raises( + ConfigError, + match='Many math_renderers are registered. But no math_renderer is selected.', + ): + make_app(*args, **kwargs) + + +@pytest.mark.sphinx('html', testroot='basic', + confoverrides={'extensions': ['sphinx.ext.imgmath', + 'sphinx.ext.mathjax']}) +def test_html_math_renderer_is_duplicated2(app, status, warning): + # case of both mathjax and another math_renderer is loaded + assert app.builder.math_renderer_name == 'imgmath' # The another one is chosen + + +@pytest.mark.sphinx('html', testroot='basic', + confoverrides={'extensions': ['sphinxcontrib.jsmath', + 'sphinx.ext.imgmath'], + 'html_math_renderer': 'imgmath'}) +def test_html_math_renderer_is_chosen(app, status, warning): + assert app.builder.math_renderer_name == 'imgmath' + + +@pytest.mark.sphinx('html', testroot='basic', + confoverrides={'extensions': ['sphinxcontrib.jsmath', + 'sphinx.ext.mathjax'], + 'html_math_renderer': 'imgmath'}) +def test_html_math_renderer_is_mismatched(make_app, app_params): + args, kwargs = app_params + with pytest.raises(ConfigError, match="Unknown math_renderer 'imgmath' is given."): + make_app(*args, **kwargs) diff --git a/tests/test_builders/test_build_html_numfig.py b/tests/test_builders/test_build_html_numfig.py new file mode 100644 index 0000000..62e68cb --- /dev/null +++ b/tests/test_builders/test_build_html_numfig.py @@ -0,0 +1,487 @@ +"""Test the HTML builder and check output against XPath.""" + +import os +import re + +import pytest + +from tests.test_builders.xpath_data import FIGURE_CAPTION +from tests.test_builders.xpath_util import check_xpath + + +@pytest.mark.sphinx('html', testroot='numfig') +@pytest.mark.test_params(shared_result='test_build_html_numfig') +def test_numfig_disabled_warn(app, warning): + app.build() + warnings = warning.getvalue() + assert 'index.rst:47: WARNING: numfig is disabled. :numref: is ignored.' in warnings + assert 'index.rst:56: WARNING: invalid numfig_format: invalid' not in warnings + assert 'index.rst:57: WARNING: invalid numfig_format: Fig %s %s' not in warnings + + +@pytest.mark.parametrize(("fname", "path", "check", "be_found"), [ + ('index.html', FIGURE_CAPTION + "/span[@class='caption-number']", None, True), + ('index.html', ".//table/caption/span[@class='caption-number']", None, True), + ('index.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", None, True), + ('index.html', ".//li/p/code/span", '^fig1$', True), + ('index.html', ".//li/p/code/span", '^Figure%s$', True), + ('index.html', ".//li/p/code/span", '^table-1$', True), + ('index.html', ".//li/p/code/span", '^Table:%s$', True), + ('index.html', ".//li/p/code/span", '^CODE_1$', True), + ('index.html', ".//li/p/code/span", '^Code-%s$', True), + ('index.html', ".//li/p/a/span", '^Section 1$', True), + ('index.html', ".//li/p/a/span", '^Section 2.1$', True), + ('index.html', ".//li/p/code/span", '^Fig.{number}$', True), + ('index.html', ".//li/p/a/span", '^Sect.1 Foo$', True), + + ('foo.html', FIGURE_CAPTION + "/span[@class='caption-number']", None, True), + ('foo.html', ".//table/caption/span[@class='caption-number']", None, True), + ('foo.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", None, True), + + ('bar.html', FIGURE_CAPTION + "/span[@class='caption-number']", None, True), + ('bar.html', ".//table/caption/span[@class='caption-number']", None, True), + ('bar.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", None, True), + + ('baz.html', FIGURE_CAPTION + "/span[@class='caption-number']", None, True), + ('baz.html', ".//table/caption/span[@class='caption-number']", None, True), + ('baz.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", None, True), +]) +@pytest.mark.sphinx('html', testroot='numfig') +@pytest.mark.test_params(shared_result='test_build_html_numfig') +def test_numfig_disabled(app, cached_etree_parse, fname, path, check, be_found): + app.build() + check_xpath(cached_etree_parse(app.outdir / fname), fname, path, check, be_found) + + +@pytest.mark.sphinx( + 'html', testroot='numfig', + srcdir='test_numfig_without_numbered_toctree_warn', + confoverrides={'numfig': True}) +def test_numfig_without_numbered_toctree_warn(app, warning): + app.build() + # remove :numbered: option + index = (app.srcdir / 'index.rst').read_text(encoding='utf8') + index = re.sub(':numbered:.*', '', index) + (app.srcdir / 'index.rst').write_text(index, encoding='utf8') + app.build() + + warnings = warning.getvalue() + assert 'index.rst:47: WARNING: numfig is disabled. :numref: is ignored.' not in warnings + assert 'index.rst:55: WARNING: Failed to create a cross reference. Any number is not assigned: index' in warnings + assert 'index.rst:56: WARNING: invalid numfig_format: invalid' in warnings + assert 'index.rst:57: WARNING: invalid numfig_format: Fig %s %s' in warnings + + +@pytest.mark.parametrize(("fname", "path", "check", "be_found"), [ + ('index.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 9 $', True), + ('index.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 10 $', True), + ('index.html', ".//table/caption/span[@class='caption-number']", + '^Table 9 $', True), + ('index.html', ".//table/caption/span[@class='caption-number']", + '^Table 10 $', True), + ('index.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 9 $', True), + ('index.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 10 $', True), + ('index.html', ".//li/p/a/span", '^Fig. 9$', True), + ('index.html', ".//li/p/a/span", '^Figure6$', True), + ('index.html', ".//li/p/a/span", '^Table 9$', True), + ('index.html', ".//li/p/a/span", '^Table:6$', True), + ('index.html', ".//li/p/a/span", '^Listing 9$', True), + ('index.html', ".//li/p/a/span", '^Code-6$', True), + ('index.html', ".//li/p/code/span", '^foo$', True), + ('index.html', ".//li/p/code/span", '^bar_a$', True), + ('index.html', ".//li/p/a/span", '^Fig.9 should be Fig.1$', True), + ('index.html', ".//li/p/code/span", '^Sect.{number}$', True), + + ('foo.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 1 $', True), + ('foo.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 2 $', True), + ('foo.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 3 $', True), + ('foo.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 4 $', True), + ('foo.html', ".//table/caption/span[@class='caption-number']", + '^Table 1 $', True), + ('foo.html', ".//table/caption/span[@class='caption-number']", + '^Table 2 $', True), + ('foo.html', ".//table/caption/span[@class='caption-number']", + '^Table 3 $', True), + ('foo.html', ".//table/caption/span[@class='caption-number']", + '^Table 4 $', True), + ('foo.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 1 $', True), + ('foo.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 2 $', True), + ('foo.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 3 $', True), + ('foo.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 4 $', True), + + ('bar.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 5 $', True), + ('bar.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 7 $', True), + ('bar.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 8 $', True), + ('bar.html', ".//table/caption/span[@class='caption-number']", + '^Table 5 $', True), + ('bar.html', ".//table/caption/span[@class='caption-number']", + '^Table 7 $', True), + ('bar.html', ".//table/caption/span[@class='caption-number']", + '^Table 8 $', True), + ('bar.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 5 $', True), + ('bar.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 7 $', True), + ('bar.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 8 $', True), + + ('baz.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 6 $', True), + ('baz.html', ".//table/caption/span[@class='caption-number']", + '^Table 6 $', True), + ('baz.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 6 $', True), +]) +@pytest.mark.sphinx( + 'html', testroot='numfig', + srcdir='test_numfig_without_numbered_toctree', + confoverrides={'numfig': True}) +def test_numfig_without_numbered_toctree(app, cached_etree_parse, fname, path, check, be_found): + # remove :numbered: option + index = (app.srcdir / 'index.rst').read_text(encoding='utf8') + index = re.sub(':numbered:.*', '', index) + (app.srcdir / 'index.rst').write_text(index, encoding='utf8') + + if not os.listdir(app.outdir): + app.build() + check_xpath(cached_etree_parse(app.outdir / fname), fname, path, check, be_found) + + +@pytest.mark.sphinx('html', testroot='numfig', confoverrides={'numfig': True}) +@pytest.mark.test_params(shared_result='test_build_html_numfig_on') +def test_numfig_with_numbered_toctree_warn(app, warning): + app.build() + warnings = warning.getvalue() + assert 'index.rst:47: WARNING: numfig is disabled. :numref: is ignored.' not in warnings + assert 'index.rst:55: WARNING: Failed to create a cross reference. Any number is not assigned: index' in warnings + assert 'index.rst:56: WARNING: invalid numfig_format: invalid' in warnings + assert 'index.rst:57: WARNING: invalid numfig_format: Fig %s %s' in warnings + + +@pytest.mark.parametrize(("fname", "path", "check", "be_found"), [ + ('index.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 1 $', True), + ('index.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 2 $', True), + ('index.html', ".//table/caption/span[@class='caption-number']", + '^Table 1 $', True), + ('index.html', ".//table/caption/span[@class='caption-number']", + '^Table 2 $', True), + ('index.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 1 $', True), + ('index.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 2 $', True), + ('index.html', ".//li/p/a/span", '^Fig. 1$', True), + ('index.html', ".//li/p/a/span", '^Figure2.2$', True), + ('index.html', ".//li/p/a/span", '^Table 1$', True), + ('index.html', ".//li/p/a/span", '^Table:2.2$', True), + ('index.html', ".//li/p/a/span", '^Listing 1$', True), + ('index.html', ".//li/p/a/span", '^Code-2.2$', True), + ('index.html', ".//li/p/a/span", '^Section.1$', True), + ('index.html', ".//li/p/a/span", '^Section.2.1$', True), + ('index.html', ".//li/p/a/span", '^Fig.1 should be Fig.1$', True), + ('index.html', ".//li/p/a/span", '^Sect.1 Foo$', True), + + ('foo.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 1.1 $', True), + ('foo.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 1.2 $', True), + ('foo.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 1.3 $', True), + ('foo.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 1.4 $', True), + ('foo.html', ".//table/caption/span[@class='caption-number']", + '^Table 1.1 $', True), + ('foo.html', ".//table/caption/span[@class='caption-number']", + '^Table 1.2 $', True), + ('foo.html', ".//table/caption/span[@class='caption-number']", + '^Table 1.3 $', True), + ('foo.html', ".//table/caption/span[@class='caption-number']", + '^Table 1.4 $', True), + ('foo.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 1.1 $', True), + ('foo.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 1.2 $', True), + ('foo.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 1.3 $', True), + ('foo.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 1.4 $', True), + + ('bar.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 2.1 $', True), + ('bar.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 2.3 $', True), + ('bar.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 2.4 $', True), + ('bar.html', ".//table/caption/span[@class='caption-number']", + '^Table 2.1 $', True), + ('bar.html', ".//table/caption/span[@class='caption-number']", + '^Table 2.3 $', True), + ('bar.html', ".//table/caption/span[@class='caption-number']", + '^Table 2.4 $', True), + ('bar.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 2.1 $', True), + ('bar.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 2.3 $', True), + ('bar.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 2.4 $', True), + + ('baz.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 2.2 $', True), + ('baz.html', ".//table/caption/span[@class='caption-number']", + '^Table 2.2 $', True), + ('baz.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 2.2 $', True), +]) +@pytest.mark.sphinx('html', testroot='numfig', confoverrides={'numfig': True}) +@pytest.mark.test_params(shared_result='test_build_html_numfig_on') +def test_numfig_with_numbered_toctree(app, cached_etree_parse, fname, path, check, be_found): + app.build() + check_xpath(cached_etree_parse(app.outdir / fname), fname, path, check, be_found) + + +@pytest.mark.sphinx('html', testroot='numfig', confoverrides={ + 'numfig': True, + 'numfig_format': {'figure': 'Figure:%s', + 'table': 'Tab_%s', + 'code-block': 'Code-%s', + 'section': 'SECTION-%s'}}) +@pytest.mark.test_params(shared_result='test_build_html_numfig_format_warn') +def test_numfig_with_prefix_warn(app, warning): + app.build() + warnings = warning.getvalue() + assert 'index.rst:47: WARNING: numfig is disabled. :numref: is ignored.' not in warnings + assert 'index.rst:55: WARNING: Failed to create a cross reference. Any number is not assigned: index' in warnings + assert 'index.rst:56: WARNING: invalid numfig_format: invalid' in warnings + assert 'index.rst:57: WARNING: invalid numfig_format: Fig %s %s' in warnings + + +@pytest.mark.parametrize(("fname", "path", "check", "be_found"), [ + ('index.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Figure:1 $', True), + ('index.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Figure:2 $', True), + ('index.html', ".//table/caption/span[@class='caption-number']", + '^Tab_1 $', True), + ('index.html', ".//table/caption/span[@class='caption-number']", + '^Tab_2 $', True), + ('index.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Code-1 $', True), + ('index.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Code-2 $', True), + ('index.html', ".//li/p/a/span", '^Figure:1$', True), + ('index.html', ".//li/p/a/span", '^Figure2.2$', True), + ('index.html', ".//li/p/a/span", '^Tab_1$', True), + ('index.html', ".//li/p/a/span", '^Table:2.2$', True), + ('index.html', ".//li/p/a/span", '^Code-1$', True), + ('index.html', ".//li/p/a/span", '^Code-2.2$', True), + ('index.html', ".//li/p/a/span", '^SECTION-1$', True), + ('index.html', ".//li/p/a/span", '^SECTION-2.1$', True), + ('index.html', ".//li/p/a/span", '^Fig.1 should be Fig.1$', True), + ('index.html', ".//li/p/a/span", '^Sect.1 Foo$', True), + + ('foo.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Figure:1.1 $', True), + ('foo.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Figure:1.2 $', True), + ('foo.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Figure:1.3 $', True), + ('foo.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Figure:1.4 $', True), + ('foo.html', ".//table/caption/span[@class='caption-number']", + '^Tab_1.1 $', True), + ('foo.html', ".//table/caption/span[@class='caption-number']", + '^Tab_1.2 $', True), + ('foo.html', ".//table/caption/span[@class='caption-number']", + '^Tab_1.3 $', True), + ('foo.html', ".//table/caption/span[@class='caption-number']", + '^Tab_1.4 $', True), + ('foo.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Code-1.1 $', True), + ('foo.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Code-1.2 $', True), + ('foo.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Code-1.3 $', True), + ('foo.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Code-1.4 $', True), + + ('bar.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Figure:2.1 $', True), + ('bar.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Figure:2.3 $', True), + ('bar.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Figure:2.4 $', True), + ('bar.html', ".//table/caption/span[@class='caption-number']", + '^Tab_2.1 $', True), + ('bar.html', ".//table/caption/span[@class='caption-number']", + '^Tab_2.3 $', True), + ('bar.html', ".//table/caption/span[@class='caption-number']", + '^Tab_2.4 $', True), + ('bar.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Code-2.1 $', True), + ('bar.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Code-2.3 $', True), + ('bar.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Code-2.4 $', True), + + ('baz.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Figure:2.2 $', True), + ('baz.html', ".//table/caption/span[@class='caption-number']", + '^Tab_2.2 $', True), + ('baz.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Code-2.2 $', True), +]) +@pytest.mark.sphinx('html', testroot='numfig', + confoverrides={'numfig': True, + 'numfig_format': {'figure': 'Figure:%s', + 'table': 'Tab_%s', + 'code-block': 'Code-%s', + 'section': 'SECTION-%s'}}) +@pytest.mark.test_params(shared_result='test_build_html_numfig_format_warn') +def test_numfig_with_prefix(app, cached_etree_parse, fname, path, check, be_found): + app.build() + check_xpath(cached_etree_parse(app.outdir / fname), fname, path, check, be_found) + + +@pytest.mark.sphinx('html', testroot='numfig', + confoverrides={'numfig': True, 'numfig_secnum_depth': 2}) +@pytest.mark.test_params(shared_result='test_build_html_numfig_depth_2') +def test_numfig_with_secnum_depth_warn(app, warning): + app.build() + warnings = warning.getvalue() + assert 'index.rst:47: WARNING: numfig is disabled. :numref: is ignored.' not in warnings + assert 'index.rst:55: WARNING: Failed to create a cross reference. Any number is not assigned: index' in warnings + assert 'index.rst:56: WARNING: invalid numfig_format: invalid' in warnings + assert 'index.rst:57: WARNING: invalid numfig_format: Fig %s %s' in warnings + + +@pytest.mark.parametrize(("fname", "path", "check", "be_found"), [ + ('index.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 1 $', True), + ('index.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 2 $', True), + ('index.html', ".//table/caption/span[@class='caption-number']", + '^Table 1 $', True), + ('index.html', ".//table/caption/span[@class='caption-number']", + '^Table 2 $', True), + ('index.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 1 $', True), + ('index.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 2 $', True), + ('index.html', ".//li/p/a/span", '^Fig. 1$', True), + ('index.html', ".//li/p/a/span", '^Figure2.1.2$', True), + ('index.html', ".//li/p/a/span", '^Table 1$', True), + ('index.html', ".//li/p/a/span", '^Table:2.1.2$', True), + ('index.html', ".//li/p/a/span", '^Listing 1$', True), + ('index.html', ".//li/p/a/span", '^Code-2.1.2$', True), + ('index.html', ".//li/p/a/span", '^Section.1$', True), + ('index.html', ".//li/p/a/span", '^Section.2.1$', True), + ('index.html', ".//li/p/a/span", '^Fig.1 should be Fig.1$', True), + ('index.html', ".//li/p/a/span", '^Sect.1 Foo$', True), + + ('foo.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 1.1 $', True), + ('foo.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 1.1.1 $', True), + ('foo.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 1.1.2 $', True), + ('foo.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 1.2.1 $', True), + ('foo.html', ".//table/caption/span[@class='caption-number']", + '^Table 1.1 $', True), + ('foo.html', ".//table/caption/span[@class='caption-number']", + '^Table 1.1.1 $', True), + ('foo.html', ".//table/caption/span[@class='caption-number']", + '^Table 1.1.2 $', True), + ('foo.html', ".//table/caption/span[@class='caption-number']", + '^Table 1.2.1 $', True), + ('foo.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 1.1 $', True), + ('foo.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 1.1.1 $', True), + ('foo.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 1.1.2 $', True), + ('foo.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 1.2.1 $', True), + + ('bar.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 2.1.1 $', True), + ('bar.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 2.1.3 $', True), + ('bar.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 2.2.1 $', True), + ('bar.html', ".//table/caption/span[@class='caption-number']", + '^Table 2.1.1 $', True), + ('bar.html', ".//table/caption/span[@class='caption-number']", + '^Table 2.1.3 $', True), + ('bar.html', ".//table/caption/span[@class='caption-number']", + '^Table 2.2.1 $', True), + ('bar.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 2.1.1 $', True), + ('bar.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 2.1.3 $', True), + ('bar.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 2.2.1 $', True), + + ('baz.html', FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 2.1.2 $', True), + ('baz.html', ".//table/caption/span[@class='caption-number']", + '^Table 2.1.2 $', True), + ('baz.html', ".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 2.1.2 $', True), +]) +@pytest.mark.sphinx('html', testroot='numfig', + confoverrides={'numfig': True, + 'numfig_secnum_depth': 2}) +@pytest.mark.test_params(shared_result='test_build_html_numfig_depth_2') +def test_numfig_with_secnum_depth(app, cached_etree_parse, fname, path, check, be_found): + app.build() + check_xpath(cached_etree_parse(app.outdir / fname), fname, path, check, be_found) + + +@pytest.mark.parametrize("expect", [ + (FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 1 $', True), + (FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 2 $', True), + (".//table/caption/span[@class='caption-number']", + '^Table 1 $', True), + (".//table/caption/span[@class='caption-number']", + '^Table 2 $', True), + (".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 1 $', True), + (".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 2 $', True), + (".//li/p/a/span", '^Fig. 1$', True), + (".//li/p/a/span", '^Figure2.2$', True), + (".//li/p/a/span", '^Table 1$', True), + (".//li/p/a/span", '^Table:2.2$', True), + (".//li/p/a/span", '^Listing 1$', True), + (".//li/p/a/span", '^Code-2.2$', True), + (".//li/p/a/span", '^Section.1$', True), + (".//li/p/a/span", '^Section.2.1$', True), + (".//li/p/a/span", '^Fig.1 should be Fig.1$', True), + (".//li/p/a/span", '^Sect.1 Foo$', True), + (FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 1.1 $', True), + (FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 1.2 $', True), + (FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 1.3 $', True), + (FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 1.4 $', True), + (".//table/caption/span[@class='caption-number']", + '^Table 1.1 $', True), + (".//table/caption/span[@class='caption-number']", + '^Table 1.2 $', True), + (".//table/caption/span[@class='caption-number']", + '^Table 1.3 $', True), + (".//table/caption/span[@class='caption-number']", + '^Table 1.4 $', True), + (".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 1.1 $', True), + (".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 1.2 $', True), + (".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 1.3 $', True), + (".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 1.4 $', True), + (FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 2.1 $', True), + (FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 2.3 $', True), + (FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 2.4 $', True), + (".//table/caption/span[@class='caption-number']", + '^Table 2.1 $', True), + (".//table/caption/span[@class='caption-number']", + '^Table 2.3 $', True), + (".//table/caption/span[@class='caption-number']", + '^Table 2.4 $', True), + (".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 2.1 $', True), + (".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 2.3 $', True), + (".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 2.4 $', True), + (FIGURE_CAPTION + "/span[@class='caption-number']", '^Fig. 2.2 $', True), + (".//table/caption/span[@class='caption-number']", + '^Table 2.2 $', True), + (".//div[@class='code-block-caption']/" + "span[@class='caption-number']", '^Listing 2.2 $', True), +]) +@pytest.mark.sphinx('singlehtml', testroot='numfig', confoverrides={'numfig': True}) +@pytest.mark.test_params(shared_result='test_build_html_numfig_on') +def test_numfig_with_singlehtml(app, cached_etree_parse, expect): + app.build() + check_xpath(cached_etree_parse(app.outdir / 'index.html'), 'index.html', *expect) diff --git a/tests/test_builders/test_build_html_tocdepth.py b/tests/test_builders/test_build_html_tocdepth.py new file mode 100644 index 0000000..4d99995 --- /dev/null +++ b/tests/test_builders/test_build_html_tocdepth.py @@ -0,0 +1,94 @@ +"""Test the HTML builder and check output against XPath.""" + +import pytest + +from tests.test_builders.xpath_util import check_xpath + + +@pytest.mark.parametrize(("fname", "path", "check", "be_found"), [ + ('index.html', ".//li[@class='toctree-l3']/a", '1.1.1. Foo A1', True), + ('index.html', ".//li[@class='toctree-l3']/a", '1.2.1. Foo B1', True), + ('index.html', ".//li[@class='toctree-l3']/a", '2.1.1. Bar A1', False), + ('index.html', ".//li[@class='toctree-l3']/a", '2.2.1. Bar B1', False), + + ('foo.html', ".//h1", 'Foo', True), + ('foo.html', ".//h2", 'Foo A', True), + ('foo.html', ".//h3", 'Foo A1', True), + ('foo.html', ".//h2", 'Foo B', True), + ('foo.html', ".//h3", 'Foo B1', True), + + ('foo.html', ".//h1//span[@class='section-number']", '1. ', True), + ('foo.html', ".//h2//span[@class='section-number']", '1.1. ', True), + ('foo.html', ".//h3//span[@class='section-number']", '1.1.1. ', True), + ('foo.html', ".//h2//span[@class='section-number']", '1.2. ', True), + ('foo.html', ".//h3//span[@class='section-number']", '1.2.1. ', True), + + ('foo.html', ".//div[@class='sphinxsidebarwrapper']//li/a", '1.1. Foo A', True), + ('foo.html', ".//div[@class='sphinxsidebarwrapper']//li/a", '1.1.1. Foo A1', True), + ('foo.html', ".//div[@class='sphinxsidebarwrapper']//li/a", '1.2. Foo B', True), + ('foo.html', ".//div[@class='sphinxsidebarwrapper']//li/a", '1.2.1. Foo B1', True), + + ('bar.html', ".//h1", 'Bar', True), + ('bar.html', ".//h2", 'Bar A', True), + ('bar.html', ".//h2", 'Bar B', True), + ('bar.html', ".//h3", 'Bar B1', True), + ('bar.html', ".//h1//span[@class='section-number']", '2. ', True), + ('bar.html', ".//h2//span[@class='section-number']", '2.1. ', True), + ('bar.html', ".//h2//span[@class='section-number']", '2.2. ', True), + ('bar.html', ".//h3//span[@class='section-number']", '2.2.1. ', True), + ('bar.html', ".//div[@class='sphinxsidebarwrapper']//li/a", '2. Bar', True), + ('bar.html', ".//div[@class='sphinxsidebarwrapper']//li/a", '2.1. Bar A', True), + ('bar.html', ".//div[@class='sphinxsidebarwrapper']//li/a", '2.2. Bar B', True), + ('bar.html', ".//div[@class='sphinxsidebarwrapper']//li/a", '2.2.1. Bar B1', False), + + ('baz.html', ".//h1", 'Baz A', True), + ('baz.html', ".//h1//span[@class='section-number']", '2.1.1. ', True), +]) +@pytest.mark.sphinx('html', testroot='tocdepth') +@pytest.mark.test_params(shared_result='test_build_html_tocdepth') +def test_tocdepth(app, cached_etree_parse, fname, path, check, be_found): + app.build() + # issue #1251 + check_xpath(cached_etree_parse(app.outdir / fname), fname, path, check, be_found) + + +@pytest.mark.parametrize("expect", [ + (".//li[@class='toctree-l3']/a", '1.1.1. Foo A1', True), + (".//li[@class='toctree-l3']/a", '1.2.1. Foo B1', True), + (".//li[@class='toctree-l3']/a", '2.1.1. Bar A1', False), + (".//li[@class='toctree-l3']/a", '2.2.1. Bar B1', False), + + # index.rst + (".//h1", 'test-tocdepth', True), + + # foo.rst + (".//h2", 'Foo', True), + (".//h3", 'Foo A', True), + (".//h4", 'Foo A1', True), + (".//h3", 'Foo B', True), + (".//h4", 'Foo B1', True), + (".//h2//span[@class='section-number']", '1. ', True), + (".//h3//span[@class='section-number']", '1.1. ', True), + (".//h4//span[@class='section-number']", '1.1.1. ', True), + (".//h3//span[@class='section-number']", '1.2. ', True), + (".//h4//span[@class='section-number']", '1.2.1. ', True), + + # bar.rst + (".//h2", 'Bar', True), + (".//h3", 'Bar A', True), + (".//h3", 'Bar B', True), + (".//h4", 'Bar B1', True), + (".//h2//span[@class='section-number']", '2. ', True), + (".//h3//span[@class='section-number']", '2.1. ', True), + (".//h3//span[@class='section-number']", '2.2. ', True), + (".//h4//span[@class='section-number']", '2.2.1. ', True), + + # baz.rst + (".//h4", 'Baz A', True), + (".//h4//span[@class='section-number']", '2.1.1. ', True), +]) +@pytest.mark.sphinx('singlehtml', testroot='tocdepth') +@pytest.mark.test_params(shared_result='test_build_html_tocdepth') +def test_tocdepth_singlehtml(app, cached_etree_parse, expect): + app.build() + check_xpath(cached_etree_parse(app.outdir / 'index.html'), 'index.html', *expect) diff --git a/tests/test_build_latex.py b/tests/test_builders/test_build_latex.py index e37a97e..0776c74 100644 --- a/tests/test_build_latex.py +++ b/tests/test_builders/test_build_latex.py @@ -1,9 +1,9 @@ """Test the build process with LaTeX builder with the test root.""" +import http.server import os import re import subprocess -from itertools import chain, product from pathlib import Path from shutil import copyfile from subprocess import CalledProcessError @@ -15,36 +15,26 @@ from sphinx.config import Config from sphinx.errors import SphinxError from sphinx.ext.intersphinx import load_mappings, normalize_intersphinx_mapping from sphinx.ext.intersphinx import setup as intersphinx_setup -from sphinx.testing.util import strip_escseq from sphinx.util.osutil import ensuredir from sphinx.writers.latex import LaTeXTranslator -from .test_build_html import ENV_WARNINGS +from tests.utils import http_server try: from contextlib import chdir except ImportError: from sphinx.util.osutil import _chdir as chdir -LATEX_ENGINES = ['pdflatex', 'lualatex', 'xelatex'] -DOCCLASSES = ['manual', 'howto'] STYLEFILES = ['article.cls', 'fancyhdr.sty', 'titlesec.sty', 'amsmath.sty', 'framed.sty', 'color.sty', 'fancyvrb.sty', 'fncychap.sty', 'geometry.sty', 'kvoptions.sty', 'hyperref.sty', 'booktabs.sty'] -LATEX_WARNINGS = ENV_WARNINGS + """\ -%(root)s/index.rst:\\d+: WARNING: unknown option: '&option' -%(root)s/index.rst:\\d+: WARNING: citation not found: missing -%(root)s/index.rst:\\d+: WARNING: a suitable image for latex builder not found: foo.\\* -%(root)s/index.rst:\\d+: WARNING: Lexing literal_block ".*" as "c" resulted in an error at token: ".*". Retrying in relaxed mode. -""" - # only run latex if all needed packages are there def kpsetest(*filenames): try: - subprocess.run(['kpsewhich'] + list(filenames), capture_output=True, check=True) + subprocess.run(['kpsewhich', *list(filenames)], capture_output=True, check=True) return True except (OSError, CalledProcessError): return False # command not found or exit with non-zero @@ -64,7 +54,7 @@ def compile_latex_document(app, filename='python.tex', docclass='manual'): args = [app.config.latex_engine, '--halt-on-error', '--interaction=nonstopmode', - '-output-directory=%s' % latex_outputdir, + f'-output-directory={latex_outputdir}', filename] subprocess.run(args, capture_output=True, check=True) except OSError as exc: # most likely the latex executable was not found @@ -92,6 +82,28 @@ def skip_if_stylefiles_notfound(testfunc): return testfunc +class RemoteImageHandler(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_GET(self): + content, content_type = None, None + if self.path == "/sphinx.png": + with open("tests/roots/test-local-logo/images/img.png", "rb") as f: + content = f.read() + content_type = "image/png" + + if content: + self.send_response(200, "OK") + self.send_header("Content-Length", str(len(content))) + self.send_header("Content-Type", content_type) + self.end_headers() + self.wfile.write(content) + else: + self.send_response(404, "Not Found") + self.send_header("Content-Length", "0") + self.end_headers() + + @skip_if_requested @skip_if_stylefiles_notfound @pytest.mark.parametrize( @@ -99,13 +111,17 @@ def skip_if_stylefiles_notfound(testfunc): # Only running test with `python_maximum_signature_line_length` not None with last # LaTeX engine to reduce testing time, as if this configuration does not fail with # one engine, it's almost impossible it would fail with another. - chain( - product(LATEX_ENGINES[:-1], DOCCLASSES, [None]), - product([LATEX_ENGINES[-1]], DOCCLASSES, [1]), - ), + [ + ('pdflatex', 'manual', None), + ('pdflatex', 'howto', None), + ('lualatex', 'manual', None), + ('lualatex', 'howto', None), + ('xelatex', 'manual', 1), + ('xelatex', 'howto', 1), + ], ) @pytest.mark.sphinx('latex', freshenv=True) -def test_build_latex_doc(app, status, warning, engine, docclass, python_maximum_signature_line_length): +def test_build_latex_doc(app, engine, docclass, python_maximum_signature_line_length): app.config.python_maximum_signature_line_length = python_maximum_signature_line_length app.config.intersphinx_mapping = { 'sphinx': ('https://www.sphinx-doc.org/en/master/', None), @@ -121,7 +137,8 @@ def test_build_latex_doc(app, status, warning, engine, docclass, python_maximum_ load_mappings(app) app.builder.init() LaTeXTranslator.ignore_missing_images = True - app.builder.build_all() + with http_server(RemoteImageHandler): + app.build(force_all=True) # file from latex_additional_files assert (app.outdir / 'svgimg.svg').is_file() @@ -131,7 +148,7 @@ def test_build_latex_doc(app, status, warning, engine, docclass, python_maximum_ @pytest.mark.sphinx('latex') def test_writer(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'sphinxtests.tex').read_text(encoding='utf8') assert ('\\begin{sphinxfigure-in-table}\n\\centering\n\\capstart\n' @@ -165,7 +182,7 @@ def test_writer(app, status, warning): '\\sphinxAtStartPar\n' 'something, something else, something more\n' '\\begin{description}\n' - '\\sphinxlineitem{\\sphinxhref{http://www.google.com}{Google}}\n' + '\\sphinxlineitem{\\sphinxhref{https://www.google.com}{Google}}\n' '\\sphinxAtStartPar\n' 'For everything.\n' '\n' @@ -173,22 +190,9 @@ def test_writer(app, status, warning): '\n\n\\end{sphinxseealso}\n\n' in result) -@pytest.mark.sphinx('latex', testroot='warnings', freshenv=True) -def test_latex_warnings(app, status, warning): - app.builder.build_all() - - warnings = strip_escseq(re.sub(re.escape(os.sep) + '{1,2}', '/', warning.getvalue())) - warnings_exp = LATEX_WARNINGS % { - 'root': re.escape(app.srcdir.as_posix())} - assert re.match(warnings_exp + '$', warnings), \ - "Warnings don't match:\n" + \ - '--- Expected (regex):\n' + warnings_exp + \ - '--- Got:\n' + warnings - - @pytest.mark.sphinx('latex', testroot='basic') def test_latex_basic(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'test.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -203,7 +207,7 @@ def test_latex_basic(app, status, warning): 'latex_documents': [('index', 'test.tex', 'title', 'author', 'manual')], }) def test_latex_basic_manual(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'test.tex').read_text(encoding='utf8') print(result) assert r'\def\sphinxdocclass{report}' in result @@ -215,7 +219,7 @@ def test_latex_basic_manual(app, status, warning): 'latex_documents': [('index', 'test.tex', 'title', 'author', 'howto')], }) def test_latex_basic_howto(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'test.tex').read_text(encoding='utf8') print(result) assert r'\def\sphinxdocclass{article}' in result @@ -228,7 +232,7 @@ def test_latex_basic_howto(app, status, warning): 'latex_documents': [('index', 'test.tex', 'title', 'author', 'manual')], }) def test_latex_basic_manual_ja(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'test.tex').read_text(encoding='utf8') print(result) assert r'\def\sphinxdocclass{ujbook}' in result @@ -241,7 +245,7 @@ def test_latex_basic_manual_ja(app, status, warning): 'latex_documents': [('index', 'test.tex', 'title', 'author', 'howto')], }) def test_latex_basic_howto_ja(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'test.tex').read_text(encoding='utf8') print(result) assert r'\def\sphinxdocclass{ujreport}' in result @@ -250,7 +254,7 @@ def test_latex_basic_howto_ja(app, status, warning): @pytest.mark.sphinx('latex', testroot='latex-theme') def test_latex_theme(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) assert r'\def\sphinxdocclass{book}' in result @@ -261,7 +265,7 @@ def test_latex_theme(app, status, warning): confoverrides={'latex_elements': {'papersize': 'b5paper', 'pointsize': '9pt'}}) def test_latex_theme_papersize(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) assert r'\def\sphinxdocclass{book}' in result @@ -272,7 +276,7 @@ def test_latex_theme_papersize(app, status, warning): confoverrides={'latex_theme_options': {'papersize': 'b5paper', 'pointsize': '9pt'}}) def test_latex_theme_options(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) assert r'\def\sphinxdocclass{book}' in result @@ -281,7 +285,7 @@ def test_latex_theme_options(app, status, warning): @pytest.mark.sphinx('latex', testroot='basic', confoverrides={'language': 'zh'}) def test_latex_additional_settings_for_language_code(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'test.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -291,7 +295,7 @@ def test_latex_additional_settings_for_language_code(app, status, warning): @pytest.mark.sphinx('latex', testroot='basic', confoverrides={'language': 'el'}) def test_latex_additional_settings_for_greek(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'test.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -302,7 +306,7 @@ def test_latex_additional_settings_for_greek(app, status, warning): @pytest.mark.sphinx('latex', testroot='latex-title') def test_latex_title_after_admonitions(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'test.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -313,7 +317,7 @@ def test_latex_title_after_admonitions(app, status, warning): @pytest.mark.sphinx('latex', testroot='basic', confoverrides={'release': '1.0_0'}) def test_latex_release(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'test.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -325,7 +329,7 @@ def test_latex_release(app, status, warning): @pytest.mark.sphinx('latex', testroot='numfig', confoverrides={'numfig': True}) def test_numref(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -367,7 +371,7 @@ def test_numref(app, status, warning): 'code-block': 'Code-%s', 'section': 'SECTION-%s'}}) def test_numref_with_prefix1(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -415,7 +419,7 @@ def test_numref_with_prefix1(app, status, warning): 'code-block': 'Code-%s | ', 'section': 'SECTION_%s_'}}) def test_numref_with_prefix2(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -491,7 +495,7 @@ def test_numref_with_language_ja(app, status, warning): @pytest.mark.sphinx('latex', testroot='latex-numfig') def test_latex_obey_numfig_is_false(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'SphinxManual.tex').read_text(encoding='utf8') assert '\\usepackage{sphinx}' in result @@ -504,7 +508,7 @@ def test_latex_obey_numfig_is_false(app, status, warning): 'latex', testroot='latex-numfig', confoverrides={'numfig': True, 'numfig_secnum_depth': 0}) def test_latex_obey_numfig_secnum_depth_is_zero(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'SphinxManual.tex').read_text(encoding='utf8') assert '\\usepackage[,nonumfigreset,mathnumfig]{sphinx}' in result @@ -517,7 +521,7 @@ def test_latex_obey_numfig_secnum_depth_is_zero(app, status, warning): 'latex', testroot='latex-numfig', confoverrides={'numfig': True, 'numfig_secnum_depth': 2}) def test_latex_obey_numfig_secnum_depth_is_two(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'SphinxManual.tex').read_text(encoding='utf8') assert '\\usepackage[,numfigreset=2,mathnumfig]{sphinx}' in result @@ -530,7 +534,7 @@ def test_latex_obey_numfig_secnum_depth_is_two(app, status, warning): 'latex', testroot='latex-numfig', confoverrides={'numfig': True, 'math_numfig': False}) def test_latex_obey_numfig_but_math_numfig_false(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'SphinxManual.tex').read_text(encoding='utf8') assert '\\usepackage[,numfigreset=1]{sphinx}' in result @@ -543,7 +547,7 @@ def test_latex_obey_numfig_but_math_numfig_false(app, status, warning): def test_latex_add_latex_package(app, status, warning): app.add_latex_package('foo') app.add_latex_package('bar', 'baz') - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'test.tex').read_text(encoding='utf8') assert '\\usepackage{foo}' in result assert '\\usepackage[baz]{bar}' in result @@ -551,7 +555,7 @@ def test_latex_add_latex_package(app, status, warning): @pytest.mark.sphinx('latex', testroot='latex-babel') def test_babel_with_no_language_settings(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -576,7 +580,7 @@ def test_babel_with_no_language_settings(app, status, warning): 'latex', testroot='latex-babel', confoverrides={'language': 'de'}) def test_babel_with_language_de(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -601,7 +605,7 @@ def test_babel_with_language_de(app, status, warning): 'latex', testroot='latex-babel', confoverrides={'language': 'ru'}) def test_babel_with_language_ru(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -626,7 +630,7 @@ def test_babel_with_language_ru(app, status, warning): 'latex', testroot='latex-babel', confoverrides={'language': 'tr'}) def test_babel_with_language_tr(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -651,7 +655,7 @@ def test_babel_with_language_tr(app, status, warning): 'latex', testroot='latex-babel', confoverrides={'language': 'ja'}) def test_babel_with_language_ja(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -675,7 +679,7 @@ def test_babel_with_language_ja(app, status, warning): 'latex', testroot='latex-babel', confoverrides={'language': 'unknown'}) def test_babel_with_unknown_language(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -702,7 +706,7 @@ def test_babel_with_unknown_language(app, status, warning): 'latex', testroot='latex-babel', confoverrides={'language': 'de', 'latex_engine': 'lualatex'}) def test_polyglossia_with_language_de(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -728,7 +732,7 @@ def test_polyglossia_with_language_de(app, status, warning): 'latex', testroot='latex-babel', confoverrides={'language': 'de-1901', 'latex_engine': 'lualatex'}) def test_polyglossia_with_language_de_1901(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -752,7 +756,7 @@ def test_polyglossia_with_language_de_1901(app, status, warning): @pytest.mark.sphinx('latex') def test_footnote(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'sphinxtests.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -781,7 +785,7 @@ def test_footnote(app, status, warning): @pytest.mark.sphinx('latex', testroot='footnotes') def test_reference_in_caption_and_codeblock_in_footnote(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -821,7 +825,7 @@ def test_reference_in_caption_and_codeblock_in_footnote(app, status, warning): @pytest.mark.sphinx('latex', testroot='footnotes') def test_footnote_referred_multiple_times(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -843,7 +847,7 @@ def test_footnote_referred_multiple_times(app, status, warning): 'latex', testroot='footnotes', confoverrides={'latex_show_urls': 'inline'}) def test_latex_show_urls_is_inline(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -866,29 +870,29 @@ def test_latex_show_urls_is_inline(app, status, warning): assert ('Second footnote: %\n' '\\begin{footnote}[1]\\sphinxAtStartFootnote\n' 'Second\n%\n\\end{footnote}\n') in result - assert '\\sphinxhref{http://sphinx-doc.org/}{Sphinx} (http://sphinx\\sphinxhyphen{}doc.org/)' in result + assert '\\sphinxhref{https://sphinx-doc.org/}{Sphinx} (https://sphinx\\sphinxhyphen{}doc.org/)' in result assert ('Third footnote: %\n\\begin{footnote}[3]\\sphinxAtStartFootnote\n' 'Third \\sphinxfootnotemark[4]\n%\n\\end{footnote}%\n' '\\begin{footnotetext}[4]\\sphinxAtStartFootnote\n' 'Footnote inside footnote\n%\n\\end{footnotetext}\\ignorespaces') in result assert ('Fourth footnote: %\n\\begin{footnote}[5]\\sphinxAtStartFootnote\n' 'Fourth\n%\n\\end{footnote}\n') in result - assert ('\\sphinxhref{http://sphinx-doc.org/~test/}{URL including tilde} ' - '(http://sphinx\\sphinxhyphen{}doc.org/\\textasciitilde{}test/)') in result - assert ('\\sphinxlineitem{\\sphinxhref{http://sphinx-doc.org/}{URL in term} ' - '(http://sphinx\\sphinxhyphen{}doc.org/)}\n' + assert ('\\sphinxhref{https://sphinx-doc.org/~test/}{URL including tilde} ' + '(https://sphinx\\sphinxhyphen{}doc.org/\\textasciitilde{}test/)') in result + assert ('\\sphinxlineitem{\\sphinxhref{https://sphinx-doc.org/}{URL in term} ' + '(https://sphinx\\sphinxhyphen{}doc.org/)}\n' '\\sphinxAtStartPar\nDescription' in result) assert ('\\sphinxlineitem{Footnote in term \\sphinxfootnotemark[7]}%\n' '\\begin{footnotetext}[7]\\sphinxAtStartFootnote\n' in result) - assert ('\\sphinxlineitem{\\sphinxhref{http://sphinx-doc.org/}{URL in term} ' - '(http://sphinx\\sphinxhyphen{}doc.org/)}\n' + assert ('\\sphinxlineitem{\\sphinxhref{https://sphinx-doc.org/}{URL in term} ' + '(https://sphinx\\sphinxhyphen{}doc.org/)}\n' '\\sphinxAtStartPar\nDescription' in result) assert ('\\sphinxlineitem{Footnote in term \\sphinxfootnotemark[7]}%\n' '\\begin{footnotetext}[7]\\sphinxAtStartFootnote\n' 'Footnote in term\n%\n\\end{footnotetext}\\ignorespaces ' '\n\\sphinxAtStartPar\nDescription') in result - assert ('\\sphinxlineitem{\\sphinxhref{http://sphinx-doc.org/}{Term in deflist} ' - '(http://sphinx\\sphinxhyphen{}doc.org/)}' + assert ('\\sphinxlineitem{\\sphinxhref{https://sphinx-doc.org/}{Term in deflist} ' + '(https://sphinx\\sphinxhyphen{}doc.org/)}' '\n\\sphinxAtStartPar\nDescription') in result assert '\\sphinxurl{https://github.com/sphinx-doc/sphinx}\n' in result assert ('\\sphinxhref{mailto:sphinx-dev@googlegroups.com}' @@ -900,7 +904,7 @@ def test_latex_show_urls_is_inline(app, status, warning): 'latex', testroot='footnotes', confoverrides={'latex_show_urls': 'footnote'}) def test_latex_show_urls_is_footnote(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -922,9 +926,9 @@ def test_latex_show_urls_is_footnote(app, status, warning): assert ('Second footnote: %\n' '\\begin{footnote}[1]\\sphinxAtStartFootnote\n' 'Second\n%\n\\end{footnote}') in result - assert ('\\sphinxhref{http://sphinx-doc.org/}{Sphinx}' + assert ('\\sphinxhref{https://sphinx-doc.org/}{Sphinx}' '%\n\\begin{footnote}[4]\\sphinxAtStartFootnote\n' - '\\sphinxnolinkurl{http://sphinx-doc.org/}\n%\n\\end{footnote}') in result + '\\sphinxnolinkurl{https://sphinx-doc.org/}\n%\n\\end{footnote}') in result assert ('Third footnote: %\n\\begin{footnote}[6]\\sphinxAtStartFootnote\n' 'Third \\sphinxfootnotemark[7]\n%\n\\end{footnote}%\n' '\\begin{footnotetext}[7]\\sphinxAtStartFootnote\n' @@ -932,25 +936,25 @@ def test_latex_show_urls_is_footnote(app, status, warning): '\\end{footnotetext}\\ignorespaces') in result assert ('Fourth footnote: %\n\\begin{footnote}[8]\\sphinxAtStartFootnote\n' 'Fourth\n%\n\\end{footnote}\n') in result - assert ('\\sphinxhref{http://sphinx-doc.org/~test/}{URL including tilde}' + assert ('\\sphinxhref{https://sphinx-doc.org/~test/}{URL including tilde}' '%\n\\begin{footnote}[5]\\sphinxAtStartFootnote\n' - '\\sphinxnolinkurl{http://sphinx-doc.org/~test/}\n%\n\\end{footnote}') in result - assert ('\\sphinxlineitem{\\sphinxhref{http://sphinx-doc.org/}' + '\\sphinxnolinkurl{https://sphinx-doc.org/~test/}\n%\n\\end{footnote}') in result + assert ('\\sphinxlineitem{\\sphinxhref{https://sphinx-doc.org/}' '{URL in term}\\sphinxfootnotemark[10]}%\n' '\\begin{footnotetext}[10]' '\\sphinxAtStartFootnote\n' - '\\sphinxnolinkurl{http://sphinx-doc.org/}\n%\n' + '\\sphinxnolinkurl{https://sphinx-doc.org/}\n%\n' '\\end{footnotetext}\\ignorespaces \n\\sphinxAtStartPar\nDescription') in result assert ('\\sphinxlineitem{Footnote in term \\sphinxfootnotemark[12]}%\n' '\\begin{footnotetext}[12]' '\\sphinxAtStartFootnote\n' 'Footnote in term\n%\n\\end{footnotetext}\\ignorespaces ' '\n\\sphinxAtStartPar\nDescription') in result - assert ('\\sphinxlineitem{\\sphinxhref{http://sphinx-doc.org/}{Term in deflist}' + assert ('\\sphinxlineitem{\\sphinxhref{https://sphinx-doc.org/}{Term in deflist}' '\\sphinxfootnotemark[11]}%\n' '\\begin{footnotetext}[11]' '\\sphinxAtStartFootnote\n' - '\\sphinxnolinkurl{http://sphinx-doc.org/}\n%\n' + '\\sphinxnolinkurl{https://sphinx-doc.org/}\n%\n' '\\end{footnotetext}\\ignorespaces \n\\sphinxAtStartPar\nDescription') in result assert ('\\sphinxurl{https://github.com/sphinx-doc/sphinx}\n' in result) assert ('\\sphinxhref{mailto:sphinx-dev@googlegroups.com}' @@ -962,7 +966,7 @@ def test_latex_show_urls_is_footnote(app, status, warning): 'latex', testroot='footnotes', confoverrides={'latex_show_urls': 'no'}) def test_latex_show_urls_is_no(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -984,21 +988,21 @@ def test_latex_show_urls_is_no(app, status, warning): assert ('Second footnote: %\n' '\\begin{footnote}[1]\\sphinxAtStartFootnote\n' 'Second\n%\n\\end{footnote}') in result - assert '\\sphinxhref{http://sphinx-doc.org/}{Sphinx}' in result + assert '\\sphinxhref{https://sphinx-doc.org/}{Sphinx}' in result assert ('Third footnote: %\n\\begin{footnote}[3]\\sphinxAtStartFootnote\n' 'Third \\sphinxfootnotemark[4]\n%\n\\end{footnote}%\n' '\\begin{footnotetext}[4]\\sphinxAtStartFootnote\n' 'Footnote inside footnote\n%\n\\end{footnotetext}\\ignorespaces') in result assert ('Fourth footnote: %\n\\begin{footnote}[5]\\sphinxAtStartFootnote\n' 'Fourth\n%\n\\end{footnote}\n') in result - assert '\\sphinxhref{http://sphinx-doc.org/~test/}{URL including tilde}' in result - assert ('\\sphinxlineitem{\\sphinxhref{http://sphinx-doc.org/}{URL in term}}\n' + assert '\\sphinxhref{https://sphinx-doc.org/~test/}{URL including tilde}' in result + assert ('\\sphinxlineitem{\\sphinxhref{https://sphinx-doc.org/}{URL in term}}\n' '\\sphinxAtStartPar\nDescription') in result assert ('\\sphinxlineitem{Footnote in term \\sphinxfootnotemark[7]}%\n' '\\begin{footnotetext}[7]\\sphinxAtStartFootnote\n' 'Footnote in term\n%\n\\end{footnotetext}\\ignorespaces ' '\n\\sphinxAtStartPar\nDescription') in result - assert ('\\sphinxlineitem{\\sphinxhref{http://sphinx-doc.org/}{Term in deflist}}' + assert ('\\sphinxlineitem{\\sphinxhref{https://sphinx-doc.org/}{Term in deflist}}' '\n\\sphinxAtStartPar\nDescription') in result assert ('\\sphinxurl{https://github.com/sphinx-doc/sphinx}\n' in result) assert ('\\sphinxhref{mailto:sphinx-dev@googlegroups.com}' @@ -1009,7 +1013,7 @@ def test_latex_show_urls_is_no(app, status, warning): @pytest.mark.sphinx( 'latex', testroot='footnotes', confoverrides={'latex_show_urls': 'footnote', - 'rst_prolog': '.. |URL| replace:: `text <http://www.example.com/>`__'}) + 'rst_prolog': '.. |URL| replace:: `text <https://www.example.com/>`__'}) def test_latex_show_urls_footnote_and_substitutions(app, status, warning): # hyperlinks in substitutions should not effect to make footnotes (refs: #4784) test_latex_show_urls_is_footnote(app, status, warning) @@ -1017,7 +1021,7 @@ def test_latex_show_urls_footnote_and_substitutions(app, status, warning): @pytest.mark.sphinx('latex', testroot='image-in-section') def test_image_in_section(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -1035,12 +1039,12 @@ def test_image_in_section(app, status, warning): confoverrides={'latex_logo': 'notfound.jpg'}) def test_latex_logo_if_not_found(app, status, warning): with pytest.raises(SphinxError): - app.builder.build_all() + app.build(force_all=True) @pytest.mark.sphinx('latex', testroot='toctree-maxdepth') def test_toctree_maxdepth_manual(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -1057,7 +1061,7 @@ def test_toctree_maxdepth_manual(app, status, warning): 'Georg Brandl', 'howto'), ]}) def test_toctree_maxdepth_howto(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -1071,7 +1075,7 @@ def test_toctree_maxdepth_howto(app, status, warning): 'latex', testroot='toctree-maxdepth', confoverrides={'root_doc': 'foo'}) def test_toctree_not_found(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -1085,7 +1089,7 @@ def test_toctree_not_found(app, status, warning): 'latex', testroot='toctree-maxdepth', confoverrides={'root_doc': 'bar'}) def test_toctree_without_maxdepth(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -1098,7 +1102,7 @@ def test_toctree_without_maxdepth(app, status, warning): 'latex', testroot='toctree-maxdepth', confoverrides={'root_doc': 'qux'}) def test_toctree_with_deeper_maxdepth(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -1111,7 +1115,7 @@ def test_toctree_with_deeper_maxdepth(app, status, warning): 'latex', testroot='toctree-maxdepth', confoverrides={'latex_toplevel_sectioning': None}) def test_latex_toplevel_sectioning_is_None(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -1123,7 +1127,7 @@ def test_latex_toplevel_sectioning_is_None(app, status, warning): 'latex', testroot='toctree-maxdepth', confoverrides={'latex_toplevel_sectioning': 'part'}) def test_latex_toplevel_sectioning_is_part(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -1141,7 +1145,7 @@ def test_latex_toplevel_sectioning_is_part(app, status, warning): 'Georg Brandl', 'howto'), ]}) def test_latex_toplevel_sectioning_is_part_with_howto(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -1155,7 +1159,7 @@ def test_latex_toplevel_sectioning_is_part_with_howto(app, status, warning): 'latex', testroot='toctree-maxdepth', confoverrides={'latex_toplevel_sectioning': 'chapter'}) def test_latex_toplevel_sectioning_is_chapter(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -1171,7 +1175,7 @@ def test_latex_toplevel_sectioning_is_chapter(app, status, warning): 'Georg Brandl', 'howto'), ]}) def test_latex_toplevel_sectioning_is_chapter_with_howto(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -1183,7 +1187,7 @@ def test_latex_toplevel_sectioning_is_chapter_with_howto(app, status, warning): 'latex', testroot='toctree-maxdepth', confoverrides={'latex_toplevel_sectioning': 'section'}) def test_latex_toplevel_sectioning_is_section(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -1194,7 +1198,7 @@ def test_latex_toplevel_sectioning_is_section(app, status, warning): @skip_if_stylefiles_notfound @pytest.mark.sphinx('latex', testroot='maxlistdepth') def test_maxlistdepth_at_ten(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) print(status.getvalue()) @@ -1206,7 +1210,7 @@ def test_maxlistdepth_at_ten(app, status, warning): confoverrides={'latex_table_style': []}) @pytest.mark.test_params(shared_result='latex-table') def test_latex_table_tabulars(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') tables = {} for chap in re.split(r'\\(?:section|chapter){', result)[1:]: @@ -1277,7 +1281,7 @@ def test_latex_table_tabulars(app, status, warning): confoverrides={'latex_table_style': []}) @pytest.mark.test_params(shared_result='latex-table') def test_latex_table_longtable(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') tables = {} for chap in re.split(r'\\(?:section|chapter){', result)[1:]: @@ -1338,7 +1342,7 @@ def test_latex_table_longtable(app, status, warning): confoverrides={'latex_table_style': []}) @pytest.mark.test_params(shared_result='latex-table') def test_latex_table_complex_tables(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') tables = {} for chap in re.split(r'\\(?:section|renewcommand){', result)[1:]: @@ -1368,7 +1372,7 @@ def test_latex_table_complex_tables(app, status, warning): @pytest.mark.sphinx('latex', testroot='latex-table') def test_latex_table_with_booktabs_and_colorrows(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') assert r'\PassOptionsToPackage{booktabs}{sphinx}' in result assert r'\PassOptionsToPackage{colorrows}{sphinx}' in result @@ -1384,7 +1388,7 @@ def test_latex_table_with_booktabs_and_colorrows(app, status, warning): @pytest.mark.sphinx('latex', testroot='latex-table', confoverrides={'templates_path': ['_mytemplates/latex']}) def test_latex_table_custom_template_caseA(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') assert 'SALUT LES COPAINS' in result @@ -1392,7 +1396,7 @@ def test_latex_table_custom_template_caseA(app, status, warning): @pytest.mark.sphinx('latex', testroot='latex-table', confoverrides={'templates_path': ['_mytemplates']}) def test_latex_table_custom_template_caseB(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') assert 'SALUT LES COPAINS' not in result @@ -1400,14 +1404,14 @@ def test_latex_table_custom_template_caseB(app, status, warning): @pytest.mark.sphinx('latex', testroot='latex-table') @pytest.mark.test_params(shared_result='latex-table') def test_latex_table_custom_template_caseC(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') assert 'SALUT LES COPAINS' not in result @pytest.mark.sphinx('latex', testroot='directives-raw') def test_latex_raw_directive(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') # standard case @@ -1422,18 +1426,19 @@ def test_latex_raw_directive(app, status, warning): @pytest.mark.sphinx('latex', testroot='images') def test_latex_images(app, status, warning): - app.builder.build_all() + with http_server(RemoteImageHandler, port=7777): + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') # images are copied - assert '\\sphinxincludegraphics{{python-logo}.png}' in result - assert (app.outdir / 'python-logo.png').exists() + assert '\\sphinxincludegraphics{{sphinx}.png}' in result + assert (app.outdir / 'sphinx.png').exists() # not found images assert '\\sphinxincludegraphics{{NOT_EXIST}.PNG}' not in result assert ('WARNING: Could not fetch remote image: ' - 'https://www.google.com/NOT_EXIST.PNG [404]' in warning.getvalue()) + 'http://localhost:7777/NOT_EXIST.PNG [404]' in warning.getvalue()) # an image having target assert ('\\sphinxhref{https://www.sphinx-doc.org/}' @@ -1446,7 +1451,7 @@ def test_latex_images(app, status, warning): @pytest.mark.sphinx('latex', testroot='latex-index') def test_latex_index(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') assert ('A \\index{famous@\\spxentry{famous}}famous ' @@ -1460,7 +1465,7 @@ def test_latex_index(app, status, warning): @pytest.mark.sphinx('latex', testroot='latex-equations') def test_latex_equations(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') expected = (app.srcdir / 'expects' / 'latex-equations.tex').read_text(encoding='utf8').strip() @@ -1470,7 +1475,7 @@ def test_latex_equations(app, status, warning): @pytest.mark.sphinx('latex', testroot='image-in-parsed-literal') def test_latex_image_in_parsed_literal(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') assert ('{\\sphinxunactivateextrasandspace \\raisebox{-0.5\\height}' @@ -1480,7 +1485,7 @@ def test_latex_image_in_parsed_literal(app, status, warning): @pytest.mark.sphinx('latex', testroot='nested-enumerated-list') def test_latex_nested_enumerated_list(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') assert ('\\sphinxsetlistlabels{\\arabic}{enumi}{enumii}{}{.}%\n' @@ -1497,7 +1502,7 @@ def test_latex_nested_enumerated_list(app, status, warning): @pytest.mark.sphinx('latex', testroot='footnotes') def test_latex_thebibliography(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) @@ -1510,7 +1515,7 @@ def test_latex_thebibliography(app, status, warning): @pytest.mark.sphinx('latex', testroot='glossary') def test_latex_glossary(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') assert (r'\sphinxlineitem{ähnlich\index{ähnlich@\spxentry{ähnlich}|spxpagem}' @@ -1534,7 +1539,7 @@ def test_latex_glossary(app, status, warning): @pytest.mark.sphinx('latex', testroot='latex-labels') def test_latex_labels(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') @@ -1583,7 +1588,7 @@ def test_latex_labels(app, status, warning): @pytest.mark.sphinx('latex', testroot='latex-figure-in-admonition') def test_latex_figure_in_admonition(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') assert r'\begin{figure}[H]' in result @@ -1594,7 +1599,6 @@ def test_default_latex_documents(): config = Config({'root_doc': 'index', 'project': 'STASI™ Documentation', 'author': "Wolfgang Schäuble & G'Beckstein."}) - config.init_values() config.add('latex_engine', None, True, None) config.add('latex_theme', 'manual', True, None) expected = [('index', 'stasi.tex', 'STASI™ Documentation', @@ -1606,7 +1610,7 @@ def test_default_latex_documents(): @skip_if_stylefiles_notfound @pytest.mark.sphinx('latex', testroot='latex-includegraphics') def test_includegraphics_oversized(app, status, warning): - app.builder.build_all() + app.build(force_all=True) print(status.getvalue()) print(warning.getvalue()) compile_latex_document(app) @@ -1614,7 +1618,7 @@ def test_includegraphics_oversized(app, status, warning): @pytest.mark.sphinx('latex', testroot='index_on_title') def test_index_on_title(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') assert ('\\chapter{Test for index in top level title}\n' '\\label{\\detokenize{contents:test-for-index-in-top-level-title}}' @@ -1625,7 +1629,7 @@ def test_index_on_title(app, status, warning): @pytest.mark.sphinx('latex', testroot='latex-unicode', confoverrides={'latex_engine': 'pdflatex'}) def test_texescape_for_non_unicode_supported_engine(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) assert 'script small e: e' in result @@ -1637,7 +1641,7 @@ def test_texescape_for_non_unicode_supported_engine(app, status, warning): @pytest.mark.sphinx('latex', testroot='latex-unicode', confoverrides={'latex_engine': 'xelatex'}) def test_texescape_for_unicode_supported_engine(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') print(result) assert 'script small e: e' in result @@ -1649,20 +1653,20 @@ def test_texescape_for_unicode_supported_engine(app, status, warning): @pytest.mark.sphinx('latex', testroot='basic', confoverrides={'latex_elements': {'extrapackages': r'\usepackage{foo}'}}) def test_latex_elements_extrapackages(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'test.tex').read_text(encoding='utf8') assert r'\usepackage{foo}' in result @pytest.mark.sphinx('latex', testroot='nested-tables') def test_latex_nested_tables(app, status, warning): - app.builder.build_all() + app.build(force_all=True) assert warning.getvalue() == '' @pytest.mark.sphinx('latex', testroot='latex-container') def test_latex_container(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') assert r'\begin{sphinxuseclass}{classname}' in result assert r'\end{sphinxuseclass}' in result @@ -1704,7 +1708,7 @@ def test_copy_images(app, status, warning): image.name for image in test_dir.rglob('*') if image.suffix in {'.gif', '.pdf', '.png', '.svg'} } - images.discard('python-logo.png') + images.discard('sphinx.png') assert images == { 'img.pdf', 'rimg.png', @@ -1745,7 +1749,7 @@ def test_duplicated_labels_before_module(app, status, warning): @pytest.mark.sphinx('latex', testroot='domain-py-python_maximum_signature_line_length', confoverrides={'python_maximum_signature_line_length': 23}) def test_one_parameter_per_line(app, status, warning): - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'python.tex').read_text(encoding='utf8') # TODO: should these asserts check presence or absence of a final \sphinxparamcomma? diff --git a/tests/test_build_linkcheck.py b/tests/test_builders/test_build_linkcheck.py index 38a0bd1..c8d8515 100644 --- a/tests/test_build_linkcheck.py +++ b/tests/test_builders/test_build_linkcheck.py @@ -2,7 +2,6 @@ from __future__ import annotations -import http.server import json import re import sys @@ -10,10 +9,11 @@ import textwrap import time import wsgiref.handlers from base64 import b64encode -from os import path +from http.server import BaseHTTPRequestHandler from queue import Queue from unittest import mock +import docutils import pytest from urllib3.poolmanager import PoolManager @@ -23,18 +23,18 @@ from sphinx.builders.linkcheck import ( Hyperlink, HyperlinkAvailabilityCheckWorker, RateLimit, + compile_linkcheck_allowed_redirects, ) -from sphinx.testing.util import strip_escseq +from sphinx.deprecation import RemovedInSphinx80Warning from sphinx.util import requests from sphinx.util.console import strip_colors -from .utils import CERT_FILE, http_server, https_server +from tests.utils import CERT_FILE, serve_application ts_re = re.compile(r".*\[(?P<ts>.*)\].*") -SPHINX_DOCS_INDEX = path.abspath(path.join(__file__, "..", "roots", "test-linkcheck", "sphinx-docs-index.html")) -class DefaultsHandler(http.server.BaseHTTPRequestHandler): +class DefaultsHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def do_HEAD(self): @@ -102,7 +102,7 @@ class ConnectionMeasurement: @pytest.mark.sphinx('linkcheck', testroot='linkcheck', freshenv=True) def test_defaults(app): - with http_server(DefaultsHandler): + with serve_application(app, DefaultsHandler) as address: with ConnectionMeasurement() as m: app.build() assert m.connection_count <= 5 @@ -115,9 +115,9 @@ def test_defaults(app): assert "Anchor 'top' not found" in content assert "Anchor 'does-not-exist' not found" in content # images should fail - assert "Not Found for url: http://localhost:7777/image.png" in content - assert "Not Found for url: http://localhost:7777/image2.png" in content - # looking for local file should fail + assert f"Not Found for url: http://{address}/image.png" in content + assert f"Not Found for url: http://{address}/image2.png" in content + # looking for missing local file should fail assert "[broken] path/to/notfound" in content assert len(content.splitlines()) == 5 @@ -135,35 +135,42 @@ def test_defaults(app): # the output order of the rows is not stable # due to possible variance in network latency rowsby = {row["uri"]: row for row in rows} - assert rowsby["http://localhost:7777#!bar"] == { + # looking for local file that exists should succeed + assert rowsby["conf.py"]["status"] == "working" + assert rowsby[f"http://{address}#!bar"] == { 'filename': 'links.rst', 'lineno': 5, 'status': 'working', 'code': 0, - 'uri': 'http://localhost:7777#!bar', + 'uri': f'http://{address}#!bar', 'info': '', } - assert rowsby['http://localhost:7777/image2.png'] == { - 'filename': 'links.rst', - 'lineno': 13, - 'status': 'broken', - 'code': 0, - 'uri': 'http://localhost:7777/image2.png', - 'info': '404 Client Error: Not Found for url: http://localhost:7777/image2.png', - } + + def _missing_resource(filename: str, lineno: int): + return { + 'filename': 'links.rst', + 'lineno': lineno, + 'status': 'broken', + 'code': 0, + 'uri': f'http://{address}/{filename}', + 'info': f'404 Client Error: Not Found for url: http://{address}/{filename}', + } + accurate_linenumbers = docutils.__version_info__[:2] >= (0, 21) + image2_lineno = 12 if accurate_linenumbers else 13 + assert rowsby[f'http://{address}/image2.png'] == _missing_resource("image2.png", image2_lineno) # looking for '#top' and '#does-not-exist' not found should fail - assert rowsby["http://localhost:7777/#top"]["info"] == "Anchor 'top' not found" - assert rowsby["http://localhost:7777/#top"]["status"] == "broken" - assert rowsby["http://localhost:7777#does-not-exist"]["info"] == "Anchor 'does-not-exist' not found" + assert rowsby[f"http://{address}/#top"]["info"] == "Anchor 'top' not found" + assert rowsby[f"http://{address}/#top"]["status"] == "broken" + assert rowsby[f"http://{address}#does-not-exist"]["info"] == "Anchor 'does-not-exist' not found" # images should fail - assert "Not Found for url: http://localhost:7777/image.png" in rowsby["http://localhost:7777/image.png"]["info"] + assert f"Not Found for url: http://{address}/image.png" in rowsby[f"http://{address}/image.png"]["info"] # anchor should be found - assert rowsby['http://localhost:7777/anchor.html#found'] == { + assert rowsby[f'http://{address}/anchor.html#found'] == { 'filename': 'links.rst', 'lineno': 14, 'status': 'working', 'code': 0, - 'uri': 'http://localhost:7777/anchor.html#found', + 'uri': f'http://{address}/anchor.html#found', 'info': '', } @@ -172,7 +179,7 @@ def test_defaults(app): 'linkcheck', testroot='linkcheck', freshenv=True, confoverrides={'linkcheck_anchors': False}) def test_check_link_response_only(app): - with http_server(DefaultsHandler): + with serve_application(app, DefaultsHandler) as address: app.build() # JSON output @@ -181,12 +188,12 @@ def test_check_link_response_only(app): rows = [json.loads(x) for x in content.splitlines()] rowsby = {row["uri"]: row for row in rows} - assert rowsby["http://localhost:7777/#top"]["status"] == "working" + assert rowsby[f"http://{address}/#top"]["status"] == "working" @pytest.mark.sphinx('linkcheck', testroot='linkcheck-too-many-retries', freshenv=True) def test_too_many_retries(app): - with http_server(DefaultsHandler): + with serve_application(app, DefaultsHandler) as address: app.build() # Text output @@ -210,12 +217,20 @@ def test_too_many_retries(app): assert row['lineno'] == 1 assert row['status'] == 'broken' assert row['code'] == 0 - assert row['uri'] == 'https://localhost:7777/doesnotexist' + assert row['uri'] == f'https://{address}/doesnotexist' @pytest.mark.sphinx('linkcheck', testroot='linkcheck-raw-node', freshenv=True) def test_raw_node(app): - with http_server(OKHandler): + with serve_application(app, OKHandler) as address: + # write an index file that contains a link back to this webserver's root + # URL. docutils will replace the raw node with the contents retrieved.. + # ..and then the linkchecker will check that the root URL is available. + index = (app.srcdir / "index.rst") + index.write_text( + ".. raw:: 'html'\n" + " :url: http://{address}/".format(address=address), + ) app.build() # JSON output @@ -231,7 +246,7 @@ def test_raw_node(app): 'lineno': 1, 'status': 'working', 'code': 0, - 'uri': 'http://localhost:7777/', + 'uri': f'http://{address}/', # the received rST contains a link to its' own URL 'info': '', } @@ -240,7 +255,7 @@ def test_raw_node(app): 'linkcheck', testroot='linkcheck-anchors-ignore', freshenv=True, confoverrides={'linkcheck_anchors_ignore': ["^!", "^top$"]}) def test_anchors_ignored(app): - with http_server(OKHandler): + with serve_application(app, OKHandler): app.build() assert (app.outdir / 'output.txt').exists() @@ -250,7 +265,7 @@ def test_anchors_ignored(app): assert not content -class AnchorsIgnoreForUrlHandler(http.server.BaseHTTPRequestHandler): +class AnchorsIgnoreForUrlHandler(BaseHTTPRequestHandler): def do_HEAD(self): if self.path in {'/valid', '/ignored'}: self.send_response(200, "OK") @@ -266,14 +281,13 @@ class AnchorsIgnoreForUrlHandler(http.server.BaseHTTPRequestHandler): self.wfile.write(b"no anchor but page exists\n") -@pytest.mark.sphinx( - 'linkcheck', testroot='linkcheck-anchors-ignore-for-url', freshenv=True, - confoverrides={'linkcheck_anchors_ignore_for_url': [ - 'http://localhost:7777/ignored', # existing page - 'http://localhost:7777/invalid', # unknown page - ]}) +@pytest.mark.sphinx('linkcheck', testroot='linkcheck-anchors-ignore-for-url', freshenv=True) def test_anchors_ignored_for_url(app): - with http_server(AnchorsIgnoreForUrlHandler): + with serve_application(app, AnchorsIgnoreForUrlHandler) as address: + app.config.linkcheck_anchors_ignore_for_url = [ # type: ignore[attr-defined] + f'http://{address}/ignored', # existing page + f'http://{address}/invalid', # unknown page + ] app.build() assert (app.outdir / 'output.txt').exists() @@ -288,41 +302,41 @@ def test_anchors_ignored_for_url(app): # the order the threads are processing the links rows = {r['uri']: {'status': r['status'], 'info': r['info']} for r in data} - assert rows['http://localhost:7777/valid']['status'] == 'working' - assert rows['http://localhost:7777/valid#valid-anchor']['status'] == 'working' - assert rows['http://localhost:7777/valid#invalid-anchor'] == { + assert rows[f'http://{address}/valid']['status'] == 'working' + assert rows[f'http://{address}/valid#valid-anchor']['status'] == 'working' + assert rows[f'http://{address}/valid#invalid-anchor'] == { 'status': 'broken', 'info': "Anchor 'invalid-anchor' not found", } - assert rows['http://localhost:7777/ignored']['status'] == 'working' - assert rows['http://localhost:7777/ignored#invalid-anchor']['status'] == 'working' + assert rows[f'http://{address}/ignored']['status'] == 'working' + assert rows[f'http://{address}/ignored#invalid-anchor']['status'] == 'working' - assert rows['http://localhost:7777/invalid'] == { + assert rows[f'http://{address}/invalid'] == { 'status': 'broken', - 'info': '404 Client Error: Not Found for url: http://localhost:7777/invalid', + 'info': f'404 Client Error: Not Found for url: http://{address}/invalid', } - assert rows['http://localhost:7777/invalid#anchor'] == { + assert rows[f'http://{address}/invalid#anchor'] == { 'status': 'broken', - 'info': '404 Client Error: Not Found for url: http://localhost:7777/invalid', + 'info': f'404 Client Error: Not Found for url: http://{address}/invalid', } @pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver-anchor', freshenv=True) def test_raises_for_invalid_status(app): - class InternalServerErrorHandler(http.server.BaseHTTPRequestHandler): + class InternalServerErrorHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def do_GET(self): self.send_error(500, "Internal Server Error") - with http_server(InternalServerErrorHandler): + with serve_application(app, InternalServerErrorHandler) as address: app.build() content = (app.outdir / 'output.txt').read_text(encoding='utf8') assert content == ( - "index.rst:1: [broken] http://localhost:7777/#anchor: " + f"index.rst:1: [broken] http://{address}/#anchor: " "500 Server Error: Internal Server Error " - "for url: http://localhost:7777/\n" + f"for url: http://{address}/\n" ) @@ -338,13 +352,17 @@ def custom_handler(valid_credentials=(), success_criteria=lambda _: True): expected_token = b64encode(":".join(valid_credentials).encode()).decode("utf-8") del valid_credentials - class CustomHandler(http.server.BaseHTTPRequestHandler): + class CustomHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def authenticated(method): def method_if_authenticated(self): - if (expected_token is None - or self.headers["Authorization"] == f"Basic {expected_token}"): + if expected_token is None: + return method(self) + elif not self.headers["Authorization"]: + self.send_response(401, "Unauthorized") + self.end_headers() + elif self.headers["Authorization"] == f"Basic {expected_token}": return method(self) else: self.send_response(403, "Forbidden") @@ -370,15 +388,14 @@ def custom_handler(valid_credentials=(), success_criteria=lambda _: True): return CustomHandler -@pytest.mark.sphinx( - 'linkcheck', testroot='linkcheck-localserver', freshenv=True, - confoverrides={'linkcheck_auth': [ - (r'^$', ('no', 'match')), - (r'^http://localhost:7777/$', ('user1', 'password')), - (r'.*local.*', ('user2', 'hunter2')), - ]}) +@pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver', freshenv=True) def test_auth_header_uses_first_match(app): - with http_server(custom_handler(valid_credentials=("user1", "password"))): + with serve_application(app, custom_handler(valid_credentials=("user1", "password"))) as address: + app.config.linkcheck_auth = [ # type: ignore[attr-defined] + (r'^$', ('no', 'match')), + (fr'^http://{re.escape(address)}/$', ('user1', 'password')), + (r'.*local.*', ('user2', 'hunter2')), + ] app.build() with open(app.outdir / "output.json", encoding="utf-8") as fp: @@ -387,41 +404,51 @@ def test_auth_header_uses_first_match(app): assert content["status"] == "working" +@pytest.mark.filterwarnings('ignore::sphinx.deprecation.RemovedInSphinx80Warning') @pytest.mark.sphinx( 'linkcheck', testroot='linkcheck-localserver', freshenv=True, - confoverrides={'linkcheck_auth': [(r'^$', ('user1', 'password'))]}) -def test_auth_header_no_match(app): - with http_server(custom_handler(valid_credentials=("user1", "password"))): + confoverrides={'linkcheck_allow_unauthorized': False}) +def test_unauthorized_broken(app): + with serve_application(app, custom_handler(valid_credentials=("user1", "password"))): app.build() with open(app.outdir / "output.json", encoding="utf-8") as fp: content = json.load(fp) - # TODO: should this test's webserver return HTTP 401 here? - # https://github.com/sphinx-doc/sphinx/issues/11433 - assert content["info"] == "403 Client Error: Forbidden for url: http://localhost:7777/" + assert content["info"] == "unauthorized" assert content["status"] == "broken" @pytest.mark.sphinx( 'linkcheck', testroot='linkcheck-localserver', freshenv=True, - confoverrides={'linkcheck_request_headers': { - "http://localhost:7777/": { - "Accept": "text/html", - }, - "*": { - "X-Secret": "open sesami", - }, - }}) + confoverrides={'linkcheck_auth': [(r'^$', ('user1', 'password'))]}) +def test_auth_header_no_match(app): + with ( + serve_application(app, custom_handler(valid_credentials=("user1", "password"))), + pytest.warns(RemovedInSphinx80Warning, match='linkcheck builder encountered an HTTP 401'), + ): + app.build() + + with open(app.outdir / "output.json", encoding="utf-8") as fp: + content = json.load(fp) + + # This link is considered working based on the default linkcheck_allow_unauthorized=true + assert content["info"] == "unauthorized" + assert content["status"] == "working" + + +@pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver', freshenv=True) def test_linkcheck_request_headers(app): def check_headers(self): if "X-Secret" in self.headers: return False - if self.headers["Accept"] != "text/html": - return False - return True + return self.headers["Accept"] == "text/html" - with http_server(custom_handler(success_criteria=check_headers)): + with serve_application(app, custom_handler(success_criteria=check_headers)) as address: + app.config.linkcheck_request_headers = { # type: ignore[attr-defined] + f"http://{address}/": {"Accept": "text/html"}, + "*": {"X-Secret": "open sesami"}, + } app.build() with open(app.outdir / "output.json", encoding="utf-8") as fp: @@ -430,21 +457,18 @@ def test_linkcheck_request_headers(app): assert content["status"] == "working" -@pytest.mark.sphinx( - 'linkcheck', testroot='linkcheck-localserver', freshenv=True, - confoverrides={'linkcheck_request_headers': { - "http://localhost:7777": {"Accept": "application/json"}, - "*": {"X-Secret": "open sesami"}, - }}) +@pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver', freshenv=True) def test_linkcheck_request_headers_no_slash(app): def check_headers(self): if "X-Secret" in self.headers: return False - if self.headers["Accept"] != "application/json": - return False - return True + return self.headers["Accept"] == "application/json" - with http_server(custom_handler(success_criteria=check_headers)): + with serve_application(app, custom_handler(success_criteria=check_headers)) as address: + app.config.linkcheck_request_headers = { # type: ignore[attr-defined] + f"http://{address}": {"Accept": "application/json"}, + "*": {"X-Secret": "open sesami"}, + } app.build() with open(app.outdir / "output.json", encoding="utf-8") as fp: @@ -463,11 +487,9 @@ def test_linkcheck_request_headers_default(app): def check_headers(self): if self.headers["X-Secret"] != "open sesami": return False - if self.headers["Accept"] == "application/json": - return False - return True + return self.headers["Accept"] != "application/json" - with http_server(custom_handler(success_criteria=check_headers)): + with serve_application(app, custom_handler(success_criteria=check_headers)): app.build() with open(app.outdir / "output.json", encoding="utf-8") as fp: @@ -477,7 +499,7 @@ def test_linkcheck_request_headers_default(app): def make_redirect_handler(*, support_head): - class RedirectOnceHandler(http.server.BaseHTTPRequestHandler): + class RedirectOnceHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def do_HEAD(self): @@ -493,7 +515,7 @@ def make_redirect_handler(*, support_head): self.send_response(204, "No content") else: self.send_response(302, "Found") - self.send_header("Location", "http://localhost:7777/?redirected=1") + self.send_header("Location", "/?redirected=1") self.send_header("Content-Length", "0") self.end_headers() @@ -506,13 +528,13 @@ def make_redirect_handler(*, support_head): @pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver', freshenv=True) def test_follows_redirects_on_HEAD(app, capsys, warning): - with http_server(make_redirect_handler(support_head=True)): + with serve_application(app, make_redirect_handler(support_head=True)) as address: app.build() stdout, stderr = capsys.readouterr() content = (app.outdir / 'output.txt').read_text(encoding='utf8') assert content == ( "index.rst:1: [redirected with Found] " - "http://localhost:7777/ to http://localhost:7777/?redirected=1\n" + f"http://{address}/ to http://{address}/?redirected=1\n" ) assert stderr == textwrap.dedent( """\ @@ -525,13 +547,13 @@ def test_follows_redirects_on_HEAD(app, capsys, warning): @pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver', freshenv=True) def test_follows_redirects_on_GET(app, capsys, warning): - with http_server(make_redirect_handler(support_head=False)): + with serve_application(app, make_redirect_handler(support_head=False)) as address: app.build() stdout, stderr = capsys.readouterr() content = (app.outdir / 'output.txt').read_text(encoding='utf8') assert content == ( "index.rst:1: [redirected with Found] " - "http://localhost:7777/ to http://localhost:7777/?redirected=1\n" + f"http://{address}/ to http://{address}/?redirected=1\n" ) assert stderr == textwrap.dedent( """\ @@ -543,35 +565,34 @@ def test_follows_redirects_on_GET(app, capsys, warning): assert warning.getvalue() == '' -@pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver-warn-redirects', - freshenv=True, confoverrides={ - 'linkcheck_allowed_redirects': {'http://localhost:7777/.*1': '.*'}, - }) +@pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver-warn-redirects') def test_linkcheck_allowed_redirects(app, warning): - with http_server(make_redirect_handler(support_head=False)): + with serve_application(app, make_redirect_handler(support_head=False)) as address: + app.config.linkcheck_allowed_redirects = {f'http://{address}/.*1': '.*'} # type: ignore[attr-defined] + compile_linkcheck_allowed_redirects(app, app.config) app.build() with open(app.outdir / 'output.json', encoding='utf-8') as fp: - rows = [json.loads(l) for l in fp.readlines()] + rows = [json.loads(l) for l in fp] assert len(rows) == 2 records = {row["uri"]: row for row in rows} - assert records["http://localhost:7777/path1"]["status"] == "working" - assert records["http://localhost:7777/path2"] == { + assert records[f"http://{address}/path1"]["status"] == "working" + assert records[f"http://{address}/path2"] == { 'filename': 'index.rst', 'lineno': 3, 'status': 'redirected', 'code': 302, - 'uri': 'http://localhost:7777/path2', - 'info': 'http://localhost:7777/?redirected=1', + 'uri': f'http://{address}/path2', + 'info': f'http://{address}/?redirected=1', } - assert ("index.rst:3: WARNING: redirect http://localhost:7777/path2 - with Found to " - "http://localhost:7777/?redirected=1\n" in strip_escseq(warning.getvalue())) + assert (f"index.rst:3: WARNING: redirect http://{address}/path2 - with Found to " + f"http://{address}/?redirected=1\n" in strip_colors(warning.getvalue())) assert len(warning.getvalue().splitlines()) == 1 -class OKHandler(http.server.BaseHTTPRequestHandler): +class OKHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def do_HEAD(self): @@ -591,7 +612,7 @@ class OKHandler(http.server.BaseHTTPRequestHandler): @pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver-https', freshenv=True) def test_invalid_ssl(get_request, app): # Link indicates SSL should be used (https) but the server does not handle it. - with http_server(OKHandler): + with serve_application(app, OKHandler) as address: app.build() assert not get_request.called @@ -600,13 +621,13 @@ def test_invalid_ssl(get_request, app): assert content["status"] == "broken" assert content["filename"] == "index.rst" assert content["lineno"] == 1 - assert content["uri"] == "https://localhost:7777/" + assert content["uri"] == f"https://{address}/" assert "SSLError" in content["info"] @pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver-https', freshenv=True) def test_connect_to_selfsigned_fails(app): - with https_server(OKHandler): + with serve_application(app, OKHandler, tls_enabled=True) as address: app.build() with open(app.outdir / 'output.json', encoding='utf-8') as fp: @@ -614,14 +635,14 @@ def test_connect_to_selfsigned_fails(app): assert content["status"] == "broken" assert content["filename"] == "index.rst" assert content["lineno"] == 1 - assert content["uri"] == "https://localhost:7777/" + assert content["uri"] == f"https://{address}/" assert "[SSL: CERTIFICATE_VERIFY_FAILED]" in content["info"] @pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver-https', freshenv=True) def test_connect_to_selfsigned_with_tls_verify_false(app): app.config.tls_verify = False - with https_server(OKHandler): + with serve_application(app, OKHandler, tls_enabled=True) as address: app.build() with open(app.outdir / 'output.json', encoding='utf-8') as fp: @@ -631,7 +652,7 @@ def test_connect_to_selfsigned_with_tls_verify_false(app): "status": "working", "filename": "index.rst", "lineno": 1, - "uri": "https://localhost:7777/", + "uri": f'https://{address}/', "info": "", } @@ -639,7 +660,7 @@ def test_connect_to_selfsigned_with_tls_verify_false(app): @pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver-https', freshenv=True) def test_connect_to_selfsigned_with_tls_cacerts(app): app.config.tls_cacerts = CERT_FILE - with https_server(OKHandler): + with serve_application(app, OKHandler, tls_enabled=True) as address: app.build() with open(app.outdir / 'output.json', encoding='utf-8') as fp: @@ -649,7 +670,7 @@ def test_connect_to_selfsigned_with_tls_cacerts(app): "status": "working", "filename": "index.rst", "lineno": 1, - "uri": "https://localhost:7777/", + "uri": f'https://{address}/', "info": "", } @@ -657,7 +678,7 @@ def test_connect_to_selfsigned_with_tls_cacerts(app): @pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver-https', freshenv=True) def test_connect_to_selfsigned_with_requests_env_var(monkeypatch, app): monkeypatch.setenv("REQUESTS_CA_BUNDLE", CERT_FILE) - with https_server(OKHandler): + with serve_application(app, OKHandler, tls_enabled=True) as address: app.build() with open(app.outdir / 'output.json', encoding='utf-8') as fp: @@ -667,7 +688,7 @@ def test_connect_to_selfsigned_with_requests_env_var(monkeypatch, app): "status": "working", "filename": "index.rst", "lineno": 1, - "uri": "https://localhost:7777/", + "uri": f'https://{address}/', "info": "", } @@ -675,7 +696,7 @@ def test_connect_to_selfsigned_with_requests_env_var(monkeypatch, app): @pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver-https', freshenv=True) def test_connect_to_selfsigned_nonexistent_cert_file(app): app.config.tls_cacerts = "does/not/exist" - with https_server(OKHandler): + with serve_application(app, OKHandler, tls_enabled=True) as address: app.build() with open(app.outdir / 'output.json', encoding='utf-8') as fp: @@ -685,17 +706,17 @@ def test_connect_to_selfsigned_nonexistent_cert_file(app): "status": "broken", "filename": "index.rst", "lineno": 1, - "uri": "https://localhost:7777/", + "uri": f'https://{address}/', "info": "Could not find a suitable TLS CA certificate bundle, invalid path: does/not/exist", } -class InfiniteRedirectOnHeadHandler(http.server.BaseHTTPRequestHandler): +class InfiniteRedirectOnHeadHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def do_HEAD(self): self.send_response(302, "Found") - self.send_header("Location", "http://localhost:7777/") + self.send_header("Location", "/") self.send_header("Content-Length", "0") self.end_headers() @@ -714,7 +735,7 @@ def test_TooManyRedirects_on_HEAD(app, monkeypatch): monkeypatch.setattr(requests.sessions, "DEFAULT_REDIRECT_LIMIT", 5) - with http_server(InfiniteRedirectOnHeadHandler): + with serve_application(app, InfiniteRedirectOnHeadHandler) as address: app.build() with open(app.outdir / 'output.json', encoding='utf-8') as fp: @@ -724,13 +745,13 @@ def test_TooManyRedirects_on_HEAD(app, monkeypatch): "status": "working", "filename": "index.rst", "lineno": 1, - "uri": "http://localhost:7777/", + "uri": f'http://{address}/', "info": "", } def make_retry_after_handler(responses): - class RetryAfterHandler(http.server.BaseHTTPRequestHandler): + class RetryAfterHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def do_HEAD(self): @@ -750,9 +771,11 @@ def make_retry_after_handler(responses): @pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver', freshenv=True) def test_too_many_requests_retry_after_int_delay(app, capsys, status): - with http_server(make_retry_after_handler([(429, "0"), (200, None)])), \ - mock.patch("sphinx.builders.linkcheck.DEFAULT_DELAY", 0), \ - mock.patch("sphinx.builders.linkcheck.QUEUE_POLL_SECS", 0.01): + with ( + serve_application(app, make_retry_after_handler([(429, "0"), (200, None)])) as address, + mock.patch("sphinx.builders.linkcheck.DEFAULT_DELAY", 0), + mock.patch("sphinx.builders.linkcheck.QUEUE_POLL_SECS", 0.01), + ): app.build() content = (app.outdir / 'output.json').read_text(encoding='utf8') assert json.loads(content) == { @@ -760,10 +783,10 @@ def test_too_many_requests_retry_after_int_delay(app, capsys, status): "lineno": 1, "status": "working", "code": 0, - "uri": "http://localhost:7777/", + "uri": f'http://{address}/', "info": "", } - rate_limit_log = "-rate limited- http://localhost:7777/ | sleeping...\n" + rate_limit_log = f"-rate limited- http://{address}/ | sleeping...\n" assert rate_limit_log in strip_colors(status.getvalue()) _stdout, stderr = capsys.readouterr() assert stderr == textwrap.dedent( @@ -787,7 +810,7 @@ def test_too_many_requests_retry_after_HTTP_date(tz, app, monkeypatch, capsys): m.setattr(sphinx.util.http_date, '_GMT_OFFSET', float(time.localtime().tm_gmtoff)) - with http_server(make_retry_after_handler([(429, retry_after), (200, None)])): + with serve_application(app, make_retry_after_handler([(429, retry_after), (200, None)])) as address: app.build() content = (app.outdir / 'output.json').read_text(encoding='utf8') @@ -796,7 +819,7 @@ def test_too_many_requests_retry_after_HTTP_date(tz, app, monkeypatch, capsys): "lineno": 1, "status": "working", "code": 0, - "uri": "http://localhost:7777/", + "uri": f'http://{address}/', "info": "", } _stdout, stderr = capsys.readouterr() @@ -810,8 +833,10 @@ def test_too_many_requests_retry_after_HTTP_date(tz, app, monkeypatch, capsys): @pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver', freshenv=True) def test_too_many_requests_retry_after_without_header(app, capsys): - with http_server(make_retry_after_handler([(429, None), (200, None)])), \ - mock.patch("sphinx.builders.linkcheck.DEFAULT_DELAY", 0): + with ( + serve_application(app, make_retry_after_handler([(429, None), (200, None)])) as address, + mock.patch("sphinx.builders.linkcheck.DEFAULT_DELAY", 0), + ): app.build() content = (app.outdir / 'output.json').read_text(encoding='utf8') assert json.loads(content) == { @@ -819,7 +844,7 @@ def test_too_many_requests_retry_after_without_header(app, capsys): "lineno": 1, "status": "working", "code": 0, - "uri": "http://localhost:7777/", + "uri": f'http://{address}/', "info": "", } _stdout, stderr = capsys.readouterr() @@ -831,10 +856,36 @@ def test_too_many_requests_retry_after_without_header(app, capsys): ) +@pytest.mark.sphinx( + 'linkcheck', testroot='linkcheck-localserver', freshenv=True, + confoverrides={ + 'linkcheck_report_timeouts_as_broken': False, + 'linkcheck_timeout': 0.01, + } +) +def test_requests_timeout(app): + class DelayedResponseHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_GET(self): + time.sleep(0.2) # wait before sending any response data + self.send_response(200, "OK") + self.send_header("Content-Length", "0") + self.end_headers() + + with serve_application(app, DelayedResponseHandler): + app.build() + + with open(app.outdir / "output.json", encoding="utf-8") as fp: + content = json.load(fp) + + assert content["status"] == "timeout" + + @pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver', freshenv=True) def test_too_many_requests_user_timeout(app): app.config.linkcheck_rate_limit_timeout = 0.0 - with http_server(make_retry_after_handler([(429, None)])): + with serve_application(app, make_retry_after_handler([(429, None)])) as address: app.build() content = (app.outdir / 'output.json').read_text(encoding='utf8') assert json.loads(content) == { @@ -842,8 +893,8 @@ def test_too_many_requests_user_timeout(app): "lineno": 1, "status": "broken", "code": 0, - "uri": "http://localhost:7777/", - "info": "429 Client Error: Too Many Requests for url: http://localhost:7777/", + "uri": f'http://{address}/', + "info": f"429 Client Error: Too Many Requests for url: http://{address}/", } @@ -901,14 +952,15 @@ def test_connection_contention(get_adapter, app, capsys): import socket socket.setdefaulttimeout(5) - # Place a workload into the linkcheck queue - link_count = 10 - rqueue, wqueue = Queue(), Queue() - for _ in range(link_count): - wqueue.put(CheckRequest(0, Hyperlink("http://localhost:7777", "test", "test.rst", 1))) - # Create parallel consumer threads - with http_server(make_redirect_handler(support_head=True)): + with serve_application(app, make_redirect_handler(support_head=True)) as address: + + # Place a workload into the linkcheck queue + link_count = 10 + rqueue, wqueue = Queue(), Queue() + for _ in range(link_count): + wqueue.put(CheckRequest(0, Hyperlink(f"http://{address}", "test", "test.rst", 1))) + begin, checked = time.time(), [] threads = [ HyperlinkAvailabilityCheckWorker( @@ -932,7 +984,7 @@ def test_connection_contention(get_adapter, app, capsys): assert "TimeoutError" not in stderr -class ConnectionResetHandler(http.server.BaseHTTPRequestHandler): +class ConnectionResetHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def do_HEAD(self): @@ -946,7 +998,7 @@ class ConnectionResetHandler(http.server.BaseHTTPRequestHandler): @pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver', freshenv=True) def test_get_after_head_raises_connection_error(app): - with http_server(ConnectionResetHandler): + with serve_application(app, ConnectionResetHandler) as address: app.build() content = (app.outdir / 'output.txt').read_text(encoding='utf8') assert not content @@ -956,34 +1008,33 @@ def test_get_after_head_raises_connection_error(app): "lineno": 1, "status": "working", "code": 0, - "uri": "http://localhost:7777/", + "uri": f'http://{address}/', "info": "", } @pytest.mark.sphinx('linkcheck', testroot='linkcheck-documents_exclude', freshenv=True) def test_linkcheck_exclude_documents(app): - with http_server(DefaultsHandler): + with serve_application(app, DefaultsHandler): app.build() with open(app.outdir / 'output.json', encoding='utf-8') as fp: content = [json.loads(record) for record in fp] - assert content == [ - { - 'filename': 'broken_link.rst', - 'lineno': 4, - 'status': 'ignored', - 'code': 0, - 'uri': 'https://www.sphinx-doc.org/this-is-a-broken-link', - 'info': 'broken_link matched ^broken_link$ from linkcheck_exclude_documents', - }, - { - 'filename': 'br0ken_link.rst', - 'lineno': 4, - 'status': 'ignored', - 'code': 0, - 'uri': 'https://www.sphinx-doc.org/this-is-another-broken-link', - 'info': 'br0ken_link matched br[0-9]ken_link from linkcheck_exclude_documents', - }, - ] + assert len(content) == 2 + assert { + 'filename': 'broken_link.rst', + 'lineno': 4, + 'status': 'ignored', + 'code': 0, + 'uri': 'https://www.sphinx-doc.org/this-is-a-broken-link', + 'info': 'broken_link matched ^broken_link$ from linkcheck_exclude_documents', + } in content + assert { + 'filename': 'br0ken_link.rst', + 'lineno': 4, + 'status': 'ignored', + 'code': 0, + 'uri': 'https://www.sphinx-doc.org/this-is-another-broken-link', + 'info': 'br0ken_link matched br[0-9]ken_link from linkcheck_exclude_documents', + } in content diff --git a/tests/test_build_manpage.py b/tests/test_builders/test_build_manpage.py index e765644..7172281 100644 --- a/tests/test_build_manpage.py +++ b/tests/test_builders/test_build_manpage.py @@ -9,7 +9,7 @@ from sphinx.config import Config @pytest.mark.sphinx('man') def test_all(app, status, warning): - app.builder.build_all() + app.build(force_all=True) assert (app.outdir / 'sphinxtests.1').exists() content = (app.outdir / 'sphinxtests.1').read_text(encoding='utf8') @@ -34,7 +34,7 @@ def test_all(app, status, warning): @pytest.mark.sphinx('man', testroot='basic', confoverrides={'man_pages': [('index', 'title', None, [], 1)]}) def test_man_pages_empty_description(app, status, warning): - app.builder.build_all() + app.build(force_all=True) content = (app.outdir / 'title.1').read_text(encoding='utf8') assert r'title \-' not in content @@ -49,7 +49,7 @@ def test_man_make_section_directory(app, status, warning): @pytest.mark.sphinx('man', testroot='directive-code') def test_captioned_code_block(app, status, warning): - app.builder.build_all() + app.build(force_all=True) content = (app.outdir / 'python.1').read_text(encoding='utf8') if docutils.__version_info__[:2] < (0, 21): @@ -92,7 +92,6 @@ def test_default_man_pages(): config = Config({'project': 'STASI™ Documentation', 'author': "Wolfgang Schäuble & G'Beckstein", 'release': '1.0'}) - config.init_values() expected = [('index', 'stasi', 'STASI™ Documentation 1.0', ["Wolfgang Schäuble & G'Beckstein"], 1)] assert default_man_pages(config) == expected diff --git a/tests/test_build_texinfo.py b/tests/test_builders/test_build_texinfo.py index 9964382..f9effb2 100644 --- a/tests/test_build_texinfo.py +++ b/tests/test_builders/test_build_texinfo.py @@ -1,6 +1,5 @@ """Test the build process with Texinfo builder with the test root.""" -import os import re import subprocess from pathlib import Path @@ -11,37 +10,14 @@ import pytest from sphinx.builders.texinfo import default_texinfo_documents from sphinx.config import Config -from sphinx.testing.util import strip_escseq from sphinx.util.docutils import new_document from sphinx.writers.texinfo import TexinfoTranslator -from .test_build_html import ENV_WARNINGS - -TEXINFO_WARNINGS = ENV_WARNINGS + """\ -%(root)s/index.rst:\\d+: WARNING: unknown option: '&option' -%(root)s/index.rst:\\d+: WARNING: citation not found: missing -%(root)s/index.rst:\\d+: WARNING: a suitable image for texinfo builder not found: foo.\\* -%(root)s/index.rst:\\d+: WARNING: a suitable image for texinfo builder not found: \ -\\['application/pdf', 'image/svg\\+xml'\\] \\(svgimg.\\*\\) -""" - - -@pytest.mark.sphinx('texinfo', testroot='warnings', freshenv=True) -def test_texinfo_warnings(app, status, warning): - app.builder.build_all() - warnings = strip_escseq(re.sub(re.escape(os.sep) + '{1,2}', '/', warning.getvalue())) - warnings_exp = TEXINFO_WARNINGS % { - 'root': re.escape(app.srcdir.as_posix())} - assert re.match(warnings_exp + '$', warnings), \ - "Warnings don't match:\n" + \ - '--- Expected (regex):\n' + warnings_exp + \ - '--- Got:\n' + warnings - @pytest.mark.sphinx('texinfo') def test_texinfo(app, status, warning): TexinfoTranslator.ignore_missing_images = True - app.builder.build_all() + app.build(force_all=True) result = (app.outdir / 'sphinxtests.texi').read_text(encoding='utf8') assert ('@anchor{markup doc}@anchor{11}' '@anchor{markup id1}@anchor{12}' @@ -71,7 +47,7 @@ def test_texinfo_rubric(app, status, warning): @pytest.mark.sphinx('texinfo', testroot='markup-citation') def test_texinfo_citation(app, status, warning): - app.builder.build_all() + app.build(force_all=True) output = (app.outdir / 'python.texi').read_text(encoding='utf8') assert 'This is a citation ref; @ref{1,,[CITE1]} and @ref{2,,[CITE2]}.' in output @@ -84,7 +60,6 @@ def test_texinfo_citation(app, status, warning): def test_default_texinfo_documents(): config = Config({'project': 'STASI™ Documentation', 'author': "Wolfgang Schäuble & G'Beckstein"}) - config.init_values() expected = [('index', 'stasi', 'STASI™ Documentation', "Wolfgang Schäuble & G'Beckstein", 'stasi', 'One line description of project', 'Miscellaneous')] @@ -110,7 +85,7 @@ def test_texinfo_escape_id(app, status, warning): @pytest.mark.sphinx('texinfo', testroot='footnotes') def test_texinfo_footnote(app, status, warning): - app.builder.build_all() + app.build(force_all=True) output = (app.outdir / 'python.texi').read_text(encoding='utf8') assert 'First footnote: @footnote{\nFirst\n}' in output @@ -118,13 +93,13 @@ def test_texinfo_footnote(app, status, warning): @pytest.mark.sphinx('texinfo') def test_texinfo_xrefs(app, status, warning): - app.builder.build_all() + app.build(force_all=True) output = (app.outdir / 'sphinxtests.texi').read_text(encoding='utf8') assert re.search(r'@ref{\w+,,--plugin\.option}', output) # Now rebuild it without xrefs app.config.texinfo_cross_references = False - app.builder.build_all() + app.build(force_all=True) output = (app.outdir / 'sphinxtests.texi').read_text(encoding='utf8') assert not re.search(r'@ref{\w+,,--plugin\.option}', output) assert 'Link to perl +p, --ObjC++, --plugin.option, create-auth-token, arg and -j' in output diff --git a/tests/test_build_text.py b/tests/test_builders/test_build_text.py index 4a53be3..6dc0d03 100644 --- a/tests/test_build_text.py +++ b/tests/test_builders/test_build_text.py @@ -17,7 +17,7 @@ def with_text_app(*args, **kw): @with_text_app() def test_maxwitdh_with_prefix(app, status, warning): - app.builder.build_update() + app.build() result = (app.outdir / 'maxwidth.txt').read_text(encoding='utf8') lines = result.splitlines() @@ -40,7 +40,7 @@ def test_maxwitdh_with_prefix(app, status, warning): @with_text_app() def test_lineblock(app, status, warning): # regression test for #1109: need empty line after line block - app.builder.build_update() + app.build() result = (app.outdir / 'lineblock.txt').read_text(encoding='utf8') expect = ( "* one\n" @@ -55,7 +55,7 @@ def test_lineblock(app, status, warning): @with_text_app() def test_nonascii_title_line(app, status, warning): - app.builder.build_update() + app.build() result = (app.outdir / 'nonascii_title.txt').read_text(encoding='utf8') expect_underline = '*********' result_underline = result.splitlines()[1].strip() @@ -64,7 +64,7 @@ def test_nonascii_title_line(app, status, warning): @with_text_app() def test_nonascii_table(app, status, warning): - app.builder.build_update() + app.build() result = (app.outdir / 'nonascii_table.txt').read_text(encoding='utf8') lines = [line.strip() for line in result.splitlines() if line.strip()] line_widths = [column_width(line) for line in lines] @@ -73,7 +73,7 @@ def test_nonascii_table(app, status, warning): @with_text_app() def test_nonascii_maxwidth(app, status, warning): - app.builder.build_update() + app.build() result = (app.outdir / 'nonascii_maxwidth.txt').read_text(encoding='utf8') lines = [line.strip() for line in result.splitlines() if line.strip()] line_widths = [column_width(line) for line in lines] @@ -117,7 +117,7 @@ def test_table_cell(): @with_text_app() def test_table_with_empty_cell(app, status, warning): - app.builder.build_update() + app.build() result = (app.outdir / 'table.txt').read_text(encoding='utf8') lines = [line.strip() for line in result.splitlines() if line.strip()] assert lines[0] == "+-------+-------+" @@ -131,7 +131,7 @@ def test_table_with_empty_cell(app, status, warning): @with_text_app() def test_table_with_rowspan(app, status, warning): - app.builder.build_update() + app.build() result = (app.outdir / 'table_rowspan.txt').read_text(encoding='utf8') lines = [line.strip() for line in result.splitlines() if line.strip()] assert lines[0] == "+-------+-------+" @@ -145,7 +145,7 @@ def test_table_with_rowspan(app, status, warning): @with_text_app() def test_table_with_colspan(app, status, warning): - app.builder.build_update() + app.build() result = (app.outdir / 'table_colspan.txt').read_text(encoding='utf8') lines = [line.strip() for line in result.splitlines() if line.strip()] assert lines[0] == "+-------+-------+" @@ -159,7 +159,7 @@ def test_table_with_colspan(app, status, warning): @with_text_app() def test_table_with_colspan_left(app, status, warning): - app.builder.build_update() + app.build() result = (app.outdir / 'table_colspan_left.txt').read_text(encoding='utf8') lines = [line.strip() for line in result.splitlines() if line.strip()] assert lines[0] == "+-------+-------+" @@ -173,7 +173,7 @@ def test_table_with_colspan_left(app, status, warning): @with_text_app() def test_table_with_colspan_and_rowspan(app, status, warning): - app.builder.build_update() + app.build() result = (app.outdir / 'table_colspan_and_rowspan.txt').read_text(encoding='utf8') lines = [line.strip() for line in result.splitlines() if line.strip()] assert result @@ -188,7 +188,7 @@ def test_table_with_colspan_and_rowspan(app, status, warning): @with_text_app() def test_list_items_in_admonition(app, status, warning): - app.builder.build_update() + app.build() result = (app.outdir / 'listitems.txt').read_text(encoding='utf8') lines = [line.rstrip() for line in result.splitlines()] assert lines[0] == "See also:" @@ -200,7 +200,7 @@ def test_list_items_in_admonition(app, status, warning): @with_text_app() def test_secnums(app, status, warning): - app.builder.build_all() + app.build(force_all=True) index = (app.outdir / 'index.txt').read_text(encoding='utf8') lines = index.splitlines() assert lines[0] == "* 1. Section A" @@ -226,7 +226,7 @@ def test_secnums(app, status, warning): assert doc2 == expect app.config.text_secnumber_suffix = " " - app.builder.build_all() + app.build(force_all=True) index = (app.outdir / 'index.txt').read_text(encoding='utf8') lines = index.splitlines() assert lines[0] == "* 1 Section A" @@ -252,7 +252,7 @@ def test_secnums(app, status, warning): assert doc2 == expect app.config.text_add_secnumbers = False - app.builder.build_all() + app.build(force_all=True) index = (app.outdir / 'index.txt').read_text(encoding='utf8') lines = index.splitlines() assert lines[0] == "* Section A" diff --git a/tests/test_builders/test_build_warnings.py b/tests/test_builders/test_build_warnings.py new file mode 100644 index 0000000..eeb7e9d --- /dev/null +++ b/tests/test_builders/test_build_warnings.py @@ -0,0 +1,89 @@ +import os +import re +import sys + +import pytest + +from sphinx.util.console import strip_colors + +ENV_WARNINGS = """\ +{root}/autodoc_fodder.py:docstring of autodoc_fodder.MarkupError:\\d+: \ +WARNING: Explicit markup ends without a blank line; unexpected unindent. +{root}/index.rst:\\d+: WARNING: Encoding 'utf-8-sig' used for reading included \ +file '{root}/wrongenc.inc' seems to be wrong, try giving an :encoding: option +{root}/index.rst:\\d+: WARNING: invalid single index entry '' +{root}/index.rst:\\d+: WARNING: image file not readable: foo.png +{root}/index.rst:\\d+: WARNING: download file not readable: {root}/nonexisting.png +{root}/undecodable.rst:\\d+: WARNING: undecodable source characters, replacing \ +with "\\?": b?'here: >>>(\\\\|/)xbb<<<((\\\\|/)r)?' +""" + +HTML_WARNINGS = ENV_WARNINGS + """\ +{root}/index.rst:\\d+: WARNING: unknown option: '&option' +{root}/index.rst:\\d+: WARNING: citation not found: missing +{root}/index.rst:\\d+: WARNING: a suitable image for html builder not found: foo.\\* +{root}/index.rst:\\d+: WARNING: Lexing literal_block ".*" as "c" resulted in an error at token: ".*". Retrying in relaxed mode. +""" + +LATEX_WARNINGS = ENV_WARNINGS + """\ +{root}/index.rst:\\d+: WARNING: unknown option: '&option' +{root}/index.rst:\\d+: WARNING: citation not found: missing +{root}/index.rst:\\d+: WARNING: a suitable image for latex builder not found: foo.\\* +{root}/index.rst:\\d+: WARNING: Lexing literal_block ".*" as "c" resulted in an error at token: ".*". Retrying in relaxed mode. +""" + +TEXINFO_WARNINGS = ENV_WARNINGS + """\ +{root}/index.rst:\\d+: WARNING: unknown option: '&option' +{root}/index.rst:\\d+: WARNING: citation not found: missing +{root}/index.rst:\\d+: WARNING: a suitable image for texinfo builder not found: foo.\\* +{root}/index.rst:\\d+: WARNING: a suitable image for texinfo builder not found: \ +\\['application/pdf', 'image/svg\\+xml'\\] \\(svgimg.\\*\\) +""" + + +def _check_warnings(expected_warnings: str, warning: str) -> None: + warnings = strip_colors(re.sub(re.escape(os.sep) + '{1,2}', '/', warning)) + assert re.match(f'{expected_warnings}$', warnings), ( + "Warnings don't match:\n" + + f'--- Expected (regex):\n{expected_warnings}\n' + + f'--- Got:\n{warnings}' + ) + sys.modules.pop('autodoc_fodder', None) + + +@pytest.mark.sphinx('html', testroot='warnings', freshenv=True) +def test_html_warnings(app, warning): + app.build(force_all=True) + warnings_exp = HTML_WARNINGS.format(root=re.escape(app.srcdir.as_posix())) + _check_warnings(warnings_exp, warning.getvalue()) + + +@pytest.mark.sphinx('latex', testroot='warnings', freshenv=True) +def test_latex_warnings(app, warning): + app.build(force_all=True) + warnings_exp = LATEX_WARNINGS.format(root=re.escape(app.srcdir.as_posix())) + _check_warnings(warnings_exp, warning.getvalue()) + + +@pytest.mark.sphinx('texinfo', testroot='warnings', freshenv=True) +def test_texinfo_warnings(app, warning): + app.build(force_all=True) + warnings_exp = TEXINFO_WARNINGS.format(root=re.escape(app.srcdir.as_posix())) + _check_warnings(warnings_exp, warning.getvalue()) + + +def test_uncacheable_config_warning(make_app, tmp_path): + """Test that an unpickleable config value raises a warning.""" + tmp_path.joinpath('conf.py').write_text("""\ +my_config = lambda: None +show_warning_types = True +def setup(app): + app.add_config_value('my_config', None, 'env') + """, encoding='utf-8') + tmp_path.joinpath('index.rst').write_text('Test\n====\n', encoding='utf-8') + app = make_app(srcdir=tmp_path) + app.build() + assert strip_colors(app.warning.getvalue()).strip() == ( + "WARNING: cannot cache unpickable configuration value: 'my_config' " + "(because it contains a function, class, or module object) [config.cache]" + ) diff --git a/tests/test_builder.py b/tests/test_builders/test_builder.py index 1ff8aea..ee946a5 100644 --- a/tests/test_builder.py +++ b/tests/test_builders/test_builder.py @@ -1,4 +1,7 @@ """Test the Builder class.""" + +import sys + import pytest @@ -37,3 +40,5 @@ def test_incremental_reading_for_missing_files(app): # "index" is listed up to updated because it contains references # to nonexisting downloadable or image files assert set(updated) == {'index'} + + sys.modules.pop('autodoc_fodder', None) diff --git a/tests/test_builders/xpath_data.py b/tests/test_builders/xpath_data.py new file mode 100644 index 0000000..30f8e07 --- /dev/null +++ b/tests/test_builders/xpath_data.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Final + +FIGURE_CAPTION: Final[str] = ".//figure/figcaption/p" diff --git a/tests/test_builders/xpath_util.py b/tests/test_builders/xpath_util.py new file mode 100644 index 0000000..7525c19 --- /dev/null +++ b/tests/test_builders/xpath_util.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import re +import textwrap +from typing import TYPE_CHECKING +from xml.etree.ElementTree import tostring + +if TYPE_CHECKING: + import os + from collections.abc import Callable, Iterable, Sequence + from xml.etree.ElementTree import Element, ElementTree + + +def _get_text(node: Element) -> str: + if node.text is not None: + # the node has only one text + return node.text + + # the node has tags and text; gather texts just under the node + return ''.join(n.tail or '' for n in node) + + +def _prettify(nodes: Iterable[Element]) -> str: + def pformat(node: Element) -> str: + return tostring(node, encoding='unicode', method='html') + + return ''.join(f'(i={index}) {pformat(node)}\n' for index, node in enumerate(nodes)) + + +def check_xpath( + etree: ElementTree, + filename: str | os.PathLike[str], + xpath: str, + check: str | re.Pattern[str] | Callable[[Sequence[Element]], None] | None, + be_found: bool = True, + *, + min_count: int = 1, +) -> None: + """Check that one or more nodes satisfy a predicate. + + :param etree: The element tree. + :param filename: The element tree source name (for errors only). + :param xpath: An XPath expression to use. + :param check: Optional regular expression or a predicate the nodes must validate. + :param be_found: If false, negate the predicate. + :param min_count: Minimum number of nodes expected to satisfy the predicate. + + * If *check* is empty (``''``), only the minimum count is checked. + * If *check* is ``None``, no node should satisfy the XPath expression. + """ + nodes = etree.findall(xpath) + assert isinstance(nodes, list) + + if check is None: + # use == to have a nice pytest diff + assert nodes == [], f'found nodes matching xpath {xpath!r} in file {filename}' + return + + assert len(nodes) >= min_count, (f'expecting at least {min_count} node(s) ' + f'to satisfy {xpath!r} in file {filename}') + + if check == '': + return + + if callable(check): + check(nodes) + return + + rex = re.compile(check) + if be_found: + if any(rex.search(_get_text(node)) for node in nodes): + return + else: + if all(not rex.search(_get_text(node)) for node in nodes): + return + + ctx = textwrap.indent(_prettify(nodes), ' ' * 2) + msg = f'{check!r} not found in any node matching {xpath!r} in file {filename}:\n{ctx}' + raise AssertionError(msg) |