diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-06-05 16:20:58 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-06-05 16:20:58 +0000 |
commit | 5bb0bb4be543fd5eca41673696a62ed80d493591 (patch) | |
tree | ad2c464f140e86c7f178a6276d7ea4a93e3e6c92 /tests/test_builders | |
parent | Adding upstream version 7.2.6. (diff) | |
download | sphinx-5bb0bb4be543fd5eca41673696a62ed80d493591.tar.xz sphinx-5bb0bb4be543fd5eca41673696a62ed80d493591.zip |
Adding upstream version 7.3.7.upstream/7.3.7
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'tests/test_builders')
26 files changed, 6144 insertions, 0 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_builders/test_build.py b/tests/test_builders/test_build.py new file mode 100644 index 0000000..3f6d12c --- /dev/null +++ b/tests/test_builders/test_build.py @@ -0,0 +1,165 @@ +"""Test all builders.""" + +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() + response.status_code = 200 + response.url = url + return response + + +@pytest.fixture() +def nonascii_srcdir(request, rootdir, sphinx_test_tempdir): + # Build in a non-ASCII source dir + test_name = '\u65e5\u672c\u8a9e' + basedir = sphinx_test_tempdir / request.node.originalname + srcdir = basedir / test_name + if not srcdir.exists(): + shutil.copytree(rootdir / 'test-root', srcdir) + + # add a doc with a non-ASCII file name to the source dir + (srcdir / (test_name + '.txt')).write_text(""" +nonascii file name page +======================= +""", encoding='utf8') + + root_doc = srcdir / 'index.txt' + root_doc.write_text(root_doc.read_text(encoding='utf8') + f""" +.. toctree:: + +{test_name}/{test_name} +""", encoding='utf8') + return srcdir + + +# note: this test skips building docs for some builders because they have independent testcase. +# (html, changes, epub, latex, texinfo and manpage) +@pytest.mark.parametrize( + "buildername", + ['dirhtml', 'singlehtml', 'text', 'xml', 'pseudoxml', 'linkcheck'], +) +@mock.patch('sphinx.builders.linkcheck.requests.head', + side_effect=request_session_head) +def test_build_all(requests_head, make_app, nonascii_srcdir, buildername): + app = make_app(buildername, srcdir=nonascii_srcdir) + app.build() + + +def test_root_doc_not_found(tmp_path, make_app): + (tmp_path / 'conf.py').write_text('', encoding='utf8') + assert os.listdir(tmp_path) == ['conf.py'] + + app = make_app('dummy', srcdir=tmp_path) + with pytest.raises(SphinxError): + app.build(force_all=True) # no index.rst + + +@pytest.mark.sphinx(buildername='text', testroot='circular') +def test_circular_toctree(app, status, warning): + app.build(force_all=True) + warnings = warning.getvalue() + assert ( + 'circular toctree references detected, ignoring: ' + 'sub <- index <- sub') in warnings + assert ( + 'circular toctree references detected, ignoring: ' + 'index <- sub <- index') in warnings + + +@pytest.mark.sphinx(buildername='text', testroot='numbered-circular') +def test_numbered_circular_toctree(app, status, warning): + app.build(force_all=True) + warnings = warning.getvalue() + assert ( + 'circular toctree references detected, ignoring: ' + 'sub <- index <- sub') in warnings + assert ( + 'circular toctree references detected, ignoring: ' + 'index <- sub <- index') in warnings + + +@pytest.mark.sphinx(buildername='dummy', testroot='images') +def test_image_glob(app, status, warning): + app.build(force_all=True) + + # index.rst + doctree = app.env.get_doctree('index') + + assert isinstance(doctree[0][1], nodes.image) + assert doctree[0][1]['candidates'] == {'*': 'rimg.png'} + assert doctree[0][1]['uri'] == 'rimg.png' + + assert isinstance(doctree[0][2], nodes.figure) + assert isinstance(doctree[0][2][0], nodes.image) + assert doctree[0][2][0]['candidates'] == {'*': 'rimg.png'} + assert doctree[0][2][0]['uri'] == 'rimg.png' + + assert isinstance(doctree[0][3], nodes.image) + assert doctree[0][3]['candidates'] == {'application/pdf': 'img.pdf', + 'image/gif': 'img.gif', + 'image/png': 'img.png'} + assert doctree[0][3]['uri'] == 'img.*' + + assert isinstance(doctree[0][4], nodes.figure) + assert isinstance(doctree[0][4][0], nodes.image) + assert doctree[0][4][0]['candidates'] == {'application/pdf': 'img.pdf', + 'image/gif': 'img.gif', + 'image/png': 'img.png'} + assert doctree[0][4][0]['uri'] == 'img.*' + + # subdir/index.rst + doctree = app.env.get_doctree('subdir/index') + + assert isinstance(doctree[0][1], nodes.image) + assert doctree[0][1]['candidates'] == {'*': 'subdir/rimg.png'} + assert doctree[0][1]['uri'] == 'subdir/rimg.png' + + assert isinstance(doctree[0][2], nodes.image) + assert doctree[0][2]['candidates'] == {'application/pdf': 'subdir/svgimg.pdf', + 'image/svg+xml': 'subdir/svgimg.svg'} + assert doctree[0][2]['uri'] == 'subdir/svgimg.*' + + assert isinstance(doctree[0][3], nodes.figure) + assert isinstance(doctree[0][3][0], nodes.image) + 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_builders/test_build_changes.py b/tests/test_builders/test_build_changes.py new file mode 100644 index 0000000..b537b87 --- /dev/null +++ b/tests/test_builders/test_build_changes.py @@ -0,0 +1,34 @@ +"""Test the ChangesBuilder class.""" + +import pytest + + +@pytest.mark.sphinx('changes', testroot='changes') +def test_build(app): + app.build() + + # TODO: Use better checking of html content + htmltext = (app.outdir / 'changes.html').read_text(encoding='utf8') + 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 + + path_html = ( + '<b>Path</b>: <i>deprecated:</i> Deprecated since version 0.6:' + ' So, that was a bad idea it turns out.') + assert path_html in htmltext + + malloc_html = ( + '<b>void *Test_Malloc(size_t n)</b>: <i>changed:</i> Changed in version 0.6:' + ' Can now be replaced with a different allocator.</a>') + assert malloc_html in htmltext + + +@pytest.mark.sphinx( + 'changes', testroot='changes', srcdir='changes-none', + confoverrides={'version': '0.7', 'release': '0.7b1'}) +def test_no_changes(app, status): + app.build() + + assert 'no changes in version 0.7.' in status.getvalue() + assert not (app.outdir / 'changes.html').exists() diff --git a/tests/test_builders/test_build_dirhtml.py b/tests/test_builders/test_build_dirhtml.py new file mode 100644 index 0000000..dc5ab86 --- /dev/null +++ b/tests/test_builders/test_build_dirhtml.py @@ -0,0 +1,40 @@ +"""Test dirhtml builder.""" + +import posixpath + +import pytest + +from sphinx.util.inventory import InventoryFile + + +@pytest.mark.sphinx(buildername='dirhtml', testroot='builder-dirhtml') +def test_dirhtml(app, status, warning): + app.build() + + assert (app.outdir / 'index.html').exists() + assert (app.outdir / 'foo/index.html').exists() + assert (app.outdir / 'foo/foo_1/index.html').exists() + assert (app.outdir / 'foo/foo_2/index.html').exists() + assert (app.outdir / 'bar/index.html').exists() + + content = (app.outdir / 'index.html').read_text(encoding='utf8') + assert 'href="foo/"' in content + assert 'href="foo/foo_1/"' in content + assert 'href="foo/foo_2/"' in content + assert 'href="bar/"' in content + + # objects.inv (refs: #7095) + with (app.outdir / 'objects.inv').open('rb') as f: + invdata = InventoryFile.load(f, 'path/to', posixpath.join) + + assert 'index' in invdata.get('std:doc') + assert invdata['std:doc']['index'] == ('Python', '', 'path/to/', '-') + + assert 'foo/index' in invdata.get('std:doc') + assert invdata['std:doc']['foo/index'] == ('Python', '', 'path/to/foo/', '-') + + assert 'index' in invdata.get('std:label') + assert invdata['std:label']['index'] == ('Python', '', 'path/to/#index', '-') + + assert 'foo' in invdata.get('std:label') + assert invdata['std:label']['foo'] == ('Python', '', 'path/to/foo/#foo', 'foo/index') diff --git a/tests/test_builders/test_build_epub.py b/tests/test_builders/test_build_epub.py new file mode 100644 index 0000000..6829f22 --- /dev/null +++ b/tests/test_builders/test_build_epub.py @@ -0,0 +1,417 @@ +"""Test the HTML builder and check output against XPath.""" + +import os +import subprocess +from pathlib import Path +from subprocess import CalledProcessError +from xml.etree import ElementTree + +import pytest + +from sphinx.builders.epub3 import _XML_NAME_PATTERN + + +# check given command is runnable +def runnable(command): + try: + subprocess.run(command, capture_output=True, check=True) + return True + except (OSError, CalledProcessError): + return False # command not found or exit with non-zero + + +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/', + 'ibooks': 'http://vocabulary.itunes.apple.com/rdf/ibooks/vocabulary-extensions-1.0/', + 'ncx': 'http://www.daisy.org/z3986/2005/ncx/', + 'xhtml': 'http://www.w3.org/1999/xhtml', + 'epub': 'http://www.idpf.org/2007/ops', + } + + def __init__(self, tree): + self.tree = tree + + @classmethod + def fromstring(cls, string): + tree = ElementTree.fromstring(string) # NoQA: S314 # using known data in tests + return cls(tree) + + def find(self, match): + ret = self.tree.find(match, namespaces=self.namespaces) + if ret is not None: + return self.__class__(ret) + else: + return ret + + def findall(self, match): + ret = self.tree.findall(match, namespaces=self.namespaces) + return [self.__class__(e) for e in ret] + + def __getattr__(self, name): + return getattr(self.tree, name) + + def __iter__(self): + for child in self.tree: + yield self.__class__(child) + + +@pytest.mark.sphinx('epub', testroot='basic') +def test_build_epub(app): + app.build(force_all=True) + assert (app.outdir / 'mimetype').read_text(encoding='utf8') == 'application/epub+zip' + assert (app.outdir / 'META-INF' / 'container.xml').exists() + + # toc.ncx + toc = EPUBElementTree.fromstring((app.outdir / 'toc.ncx').read_text(encoding='utf8')) + assert toc.find("./ncx:docTitle/ncx:text").text == 'Python' + + # toc.ncx / head + meta = list(toc.find("./ncx:head")) + assert meta[0].attrib == {'name': 'dtb:uid', 'content': 'unknown'} + assert meta[1].attrib == {'name': 'dtb:depth', 'content': '1'} + assert meta[2].attrib == {'name': 'dtb:totalPageCount', 'content': '0'} + assert meta[3].attrib == {'name': 'dtb:maxPageNumber', 'content': '0'} + + # toc.ncx / navMap + navpoints = toc.findall("./ncx:navMap/ncx:navPoint") + assert len(navpoints) == 1 + assert navpoints[0].attrib == {'id': 'navPoint1', 'playOrder': '1'} + assert navpoints[0].find("./ncx:content").attrib == {'src': 'index.xhtml'} + + navlabel = navpoints[0].find("./ncx:navLabel/ncx:text") + assert navlabel.text == 'The basic Sphinx documentation for testing' + + # content.opf + opf = EPUBElementTree.fromstring((app.outdir / 'content.opf').read_text(encoding='utf8')) + + # content.opf / metadata + metadata = opf.find("./idpf:metadata") + assert metadata.find("./dc:language").text == 'en' + assert metadata.find("./dc:title").text == 'Python' + assert metadata.find("./dc:description").text == 'unknown' + assert metadata.find("./dc:creator").text == 'unknown' + assert metadata.find("./dc:contributor").text == 'unknown' + assert metadata.find("./dc:publisher").text == 'unknown' + assert metadata.find("./dc:rights").text is None + assert metadata.find("./idpf:meta[@property='ibooks:version']").text is None + assert metadata.find("./idpf:meta[@property='ibooks:specified-fonts']").text == 'true' + assert metadata.find("./idpf:meta[@property='ibooks:binding']").text == 'true' + assert metadata.find("./idpf:meta[@property='ibooks:scroll-axis']").text == 'vertical' + + # content.opf / manifest + manifest = opf.find("./idpf:manifest") + items = list(manifest) + assert items[0].attrib == {'id': 'ncx', + 'href': 'toc.ncx', + 'media-type': 'application/x-dtbncx+xml'} + assert items[1].attrib == {'id': 'nav', + 'href': 'nav.xhtml', + 'media-type': 'application/xhtml+xml', + 'properties': 'nav'} + assert items[2].attrib == {'id': 'epub-0', + 'href': 'genindex.xhtml', + 'media-type': 'application/xhtml+xml'} + assert items[3].attrib == {'id': 'epub-1', + 'href': 'index.xhtml', + 'media-type': 'application/xhtml+xml'} + + for i, item in enumerate(items[2:]): + # items are named as epub-NN + assert item.get('id') == 'epub-%d' % i + + # content.opf / spine + spine = opf.find("./idpf:spine") + itemrefs = list(spine) + assert spine.get('toc') == 'ncx' + assert spine.get('page-progression-direction') == 'ltr' + assert itemrefs[0].get('idref') == 'epub-1' + assert itemrefs[1].get('idref') == 'epub-0' + + # content.opf / guide + reference = opf.find("./idpf:guide/idpf:reference") + assert reference.get('type') == 'toc' + assert reference.get('title') == 'Table of Contents' + assert reference.get('href') == 'index.xhtml' + + # nav.xhtml + nav = EPUBElementTree.fromstring((app.outdir / 'nav.xhtml').read_text(encoding='utf8')) + assert nav.attrib == {'lang': 'en', + '{http://www.w3.org/XML/1998/namespace}lang': 'en'} + assert nav.find("./xhtml:head/xhtml:title").text == 'Table of Contents' + + # nav.xhtml / nav + navlist = nav.find("./xhtml:body/xhtml:nav") + toc = navlist.findall("./xhtml:ol/xhtml:li") + assert navlist.find("./xhtml:h1").text == 'Table of Contents' + assert len(toc) == 1 + assert toc[0].find("./xhtml:a").get("href") == 'index.xhtml' + assert toc[0].find("./xhtml:a").text == 'The basic Sphinx documentation for testing' + + +@pytest.mark.sphinx('epub', testroot='footnotes', + confoverrides={'epub_cover': ('_images/rimg.png', None)}) +def test_epub_cover(app): + app.build() + + # content.opf / metadata + opf = EPUBElementTree.fromstring((app.outdir / 'content.opf').read_text(encoding='utf8')) + cover_image = opf.find("./idpf:manifest/idpf:item[@href='%s']" % app.config.epub_cover[0]) + cover = opf.find("./idpf:metadata/idpf:meta[@name='cover']") + assert cover + assert cover.get('content') == cover_image.get('id') + + +@pytest.mark.sphinx('epub', testroot='toctree') +def test_nested_toc(app): + app.build() + + # toc.ncx + toc = EPUBElementTree.fromstring((app.outdir / 'toc.ncx').read_bytes()) + assert toc.find("./ncx:docTitle/ncx:text").text == 'Python' + + # toc.ncx / navPoint + def navinfo(elem): + label = elem.find("./ncx:navLabel/ncx:text") + content = elem.find("./ncx:content") + return (elem.get('id'), elem.get('playOrder'), + content.get('src'), label.text) + + navpoints = toc.findall("./ncx:navMap/ncx:navPoint") + assert len(navpoints) == 4 + assert navinfo(navpoints[0]) == ('navPoint1', '1', 'index.xhtml', + "Welcome to Sphinx Tests’s documentation!") + assert navpoints[0].findall("./ncx:navPoint") == [] + + # toc.ncx / nested navPoints + assert navinfo(navpoints[1]) == ('navPoint2', '2', 'foo.xhtml', 'foo') + navchildren = navpoints[1].findall("./ncx:navPoint") + assert len(navchildren) == 4 + assert navinfo(navchildren[0]) == ('navPoint3', '2', 'foo.xhtml', 'foo') + assert navinfo(navchildren[1]) == ('navPoint4', '3', 'quux.xhtml', 'quux') + assert navinfo(navchildren[2]) == ('navPoint5', '4', 'foo.xhtml#foo-1', 'foo.1') + assert navinfo(navchildren[3]) == ('navPoint8', '6', 'foo.xhtml#foo-2', 'foo.2') + + # nav.xhtml / nav + def navinfo(elem): + anchor = elem.find("./xhtml:a") + return (anchor.get('href'), anchor.text) + + nav = EPUBElementTree.fromstring((app.outdir / 'nav.xhtml').read_bytes()) + toc = nav.findall("./xhtml:body/xhtml:nav/xhtml:ol/xhtml:li") + assert len(toc) == 4 + assert navinfo(toc[0]) == ('index.xhtml', + "Welcome to Sphinx Tests’s documentation!") + assert toc[0].findall("./xhtml:ol") == [] + + # nav.xhtml / nested toc + assert navinfo(toc[1]) == ('foo.xhtml', 'foo') + tocchildren = toc[1].findall("./xhtml:ol/xhtml:li") + assert len(tocchildren) == 3 + assert navinfo(tocchildren[0]) == ('quux.xhtml', 'quux') + assert navinfo(tocchildren[1]) == ('foo.xhtml#foo-1', 'foo.1') + assert navinfo(tocchildren[2]) == ('foo.xhtml#foo-2', 'foo.2') + + grandchild = tocchildren[1].findall("./xhtml:ol/xhtml:li") + assert len(grandchild) == 1 + assert navinfo(grandchild[0]) == ('foo.xhtml#foo-1-1', 'foo.1-1') + + +@pytest.mark.sphinx('epub', testroot='need-escaped') +def test_escaped_toc(app): + app.build() + + # toc.ncx + toc = EPUBElementTree.fromstring((app.outdir / 'toc.ncx').read_bytes()) + assert toc.find("./ncx:docTitle/ncx:text").text == 'need <b>"escaped"</b> project' + + # toc.ncx / navPoint + def navinfo(elem): + label = elem.find("./ncx:navLabel/ncx:text") + content = elem.find("./ncx:content") + return (elem.get('id'), elem.get('playOrder'), + content.get('src'), label.text) + + navpoints = toc.findall("./ncx:navMap/ncx:navPoint") + assert len(navpoints) == 4 + assert navinfo(navpoints[0]) == ('navPoint1', '1', 'index.xhtml', + "Welcome to Sphinx Tests's documentation!") + assert navpoints[0].findall("./ncx:navPoint") == [] + + # toc.ncx / nested navPoints + assert navinfo(navpoints[1]) == ('navPoint2', '2', 'foo.xhtml', '<foo>') + navchildren = navpoints[1].findall("./ncx:navPoint") + assert len(navchildren) == 4 + assert navinfo(navchildren[0]) == ('navPoint3', '2', 'foo.xhtml', '<foo>') + assert navinfo(navchildren[1]) == ('navPoint4', '3', 'quux.xhtml', 'quux') + assert navinfo(navchildren[2]) == ('navPoint5', '4', 'foo.xhtml#foo-1', 'foo “1”') + assert navinfo(navchildren[3]) == ('navPoint8', '6', 'foo.xhtml#foo-2', 'foo.2') + + # nav.xhtml / nav + def navinfo(elem): + anchor = elem.find("./xhtml:a") + return (anchor.get('href'), anchor.text) + + nav = EPUBElementTree.fromstring((app.outdir / 'nav.xhtml').read_bytes()) + toc = nav.findall("./xhtml:body/xhtml:nav/xhtml:ol/xhtml:li") + assert len(toc) == 4 + assert navinfo(toc[0]) == ('index.xhtml', + "Welcome to Sphinx Tests's documentation!") + assert toc[0].findall("./xhtml:ol") == [] + + # nav.xhtml / nested toc + assert navinfo(toc[1]) == ('foo.xhtml', '<foo>') + tocchildren = toc[1].findall("./xhtml:ol/xhtml:li") + assert len(tocchildren) == 3 + assert navinfo(tocchildren[0]) == ('quux.xhtml', 'quux') + assert navinfo(tocchildren[1]) == ('foo.xhtml#foo-1', 'foo “1”') + assert navinfo(tocchildren[2]) == ('foo.xhtml#foo-2', 'foo.2') + + grandchild = tocchildren[1].findall("./xhtml:ol/xhtml:li") + assert len(grandchild) == 1 + assert navinfo(grandchild[0]) == ('foo.xhtml#foo-1-1', 'foo.1-1') + + +@pytest.mark.sphinx('epub', testroot='basic') +def test_epub_writing_mode(app): + # horizontal (default) + app.build(force_all=True) + + # horizontal / page-progression-direction + opf = EPUBElementTree.fromstring((app.outdir / 'content.opf').read_text(encoding='utf8')) + assert opf.find("./idpf:spine").get('page-progression-direction') == 'ltr' + + # horizontal / ibooks:scroll-axis + metadata = opf.find("./idpf:metadata") + assert metadata.find("./idpf:meta[@property='ibooks:scroll-axis']").text == 'vertical' + + # horizontal / writing-mode (CSS) + css = (app.outdir / '_static' / 'epub.css').read_text(encoding='utf8') + assert 'writing-mode: horizontal-tb;' in css + + # vertical + app.config.epub_writing_mode = 'vertical' + (app.outdir / 'index.xhtml').unlink() # forcely rebuild + app.build() + + # vertical / page-progression-direction + opf = EPUBElementTree.fromstring((app.outdir / 'content.opf').read_text(encoding='utf8')) + assert opf.find("./idpf:spine").get('page-progression-direction') == 'rtl' + + # vertical / ibooks:scroll-axis + metadata = opf.find("./idpf:metadata") + assert metadata.find("./idpf:meta[@property='ibooks:scroll-axis']").text == 'horizontal' + + # vertical / writing-mode (CSS) + css = (app.outdir / '_static' / 'epub.css').read_text(encoding='utf8') + assert 'writing-mode: vertical-rl;' in css + + +@pytest.mark.sphinx('epub', testroot='epub-anchor-id') +def test_epub_anchor_id(app): + app.build() + + html = (app.outdir / 'index.xhtml').read_text(encoding='utf8') + assert ('<p id="std-setting-STATICFILES_FINDERS">' + 'blah blah blah</p>' in html) + assert ('<span id="std-setting-STATICFILES_SECTION"></span>' + '<h1>blah blah blah</h1>' in html) + assert 'see <a class="reference internal" href="#std-setting-STATICFILES_FINDERS">' in html + + +@pytest.mark.sphinx('epub', testroot='html_assets') +def test_epub_assets(app): + app.build(force_all=True) + + # epub_sytlesheets (same as html_css_files) + content = (app.outdir / 'index.xhtml').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) + + +@pytest.mark.sphinx('epub', testroot='html_assets', + confoverrides={'epub_css_files': ['css/epub.css']}) +def test_epub_css_files(app): + app.build(force_all=True) + + # epub_css_files + content = (app.outdir / 'index.xhtml').read_text(encoding='utf8') + assert '<link rel="stylesheet" type="text/css" href="_static/css/epub.css" />' in content + + # files in html_css_files are not outputted + assert ('<link rel="stylesheet" type="text/css" href="_static/css/style.css" />' + not in content) + assert ('<link media="print" rel="stylesheet" title="title" type="text/css" ' + 'href="https://example.com/custom.css" />' not in content) + + +@pytest.mark.sphinx('epub', testroot='roles-download') +def test_html_download_role(app, status, warning): + app.build() + assert not (app.outdir / '_downloads' / 'dummy.dat').exists() + + content = (app.outdir / 'index.xhtml').read_text(encoding='utf8') + assert ('<li><p><code class="xref download docutils literal notranslate">' + '<span class="pre">dummy.dat</span></code></p></li>' 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><code class="xref download docutils literal notranslate">' + '<span class="pre">Sphinx</span> <span class="pre">logo</span></code>' + '<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.build(force_all=True) + assert 'WARNING: duplicated ToC entry found: foo.xhtml' in warning.getvalue() + + +@pytest.mark.skipif('DO_EPUBCHECK' not in os.environ, + reason='Skipped because DO_EPUBCHECK is not set') +@pytest.mark.sphinx('epub') +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 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(): + assert _XML_NAME_PATTERN.match('id-pub') + assert _XML_NAME_PATTERN.match('webpage') + assert not _XML_NAME_PATTERN.match('1bfda21') + + +@pytest.mark.sphinx('epub', 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('*')} + images.discard('python-logo.png') + assert images == { + 'img.png', + 'rimg.png', + 'rimg1.png', + 'svgimg.svg', + 'testimäge.png', + } diff --git a/tests/test_builders/test_build_gettext.py b/tests/test_builders/test_build_gettext.py new file mode 100644 index 0000000..ddc6d30 --- /dev/null +++ b/tests/test_builders/test_build_gettext.py @@ -0,0 +1,235 @@ +"""Test the build process with gettext builder with the test root.""" + +import gettext +import os +import re +import subprocess +import sys +from subprocess import CalledProcessError + +import pytest + +from sphinx.builders.gettext import Catalog, MsgOrigin + +if sys.version_info[:2] >= (3, 11): + from contextlib import chdir +else: + from sphinx.util.osutil import _chdir as chdir + +_MSGID_PATTERN = re.compile(r'msgid "(.*)"') + + +def msgid_getter(msgid): + if m := _MSGID_PATTERN.search(msgid): + return m[1] + return None + + +def test_Catalog_duplicated_message(): + catalog = Catalog() + catalog.add('hello', MsgOrigin('/path/to/filename', 1)) + catalog.add('hello', MsgOrigin('/path/to/filename', 1)) + catalog.add('hello', MsgOrigin('/path/to/filename', 2)) + catalog.add('hello', MsgOrigin('/path/to/yetanother', 1)) + catalog.add('world', MsgOrigin('/path/to/filename', 1)) + + assert len(list(catalog)) == 2 + + msg1, msg2 = list(catalog) + assert msg1.text == 'hello' + assert msg1.locations == [('/path/to/filename', 1), + ('/path/to/filename', 2), + ('/path/to/yetanother', 1)] + assert msg2.text == 'world' + assert msg2.locations == [('/path/to/filename', 1)] + + +@pytest.mark.sphinx('gettext', srcdir='root-gettext') +def test_build_gettext(app): + # Generic build; should fail only when the builder is horribly broken. + app.build(force_all=True) + + # Do messages end up in the correct location? + # top-level documents end up in a message catalog + assert (app.outdir / 'extapi.pot').is_file() + # directory items are grouped into sections + assert (app.outdir / 'subdir.pot').is_file() + + # regression test for issue #960 + catalog = (app.outdir / 'markup.pot').read_text(encoding='utf8') + assert 'msgid "something, something else, something more"' in catalog + + +@pytest.mark.sphinx('gettext', srcdir='root-gettext') +def test_msgfmt(app): + app.build(force_all=True) + + (app.outdir / 'en' / 'LC_MESSAGES').mkdir(parents=True, exist_ok=True) + with chdir(app.outdir): + try: + args = ['msginit', '--no-translator', '-i', 'markup.pot', '--locale', 'en_US'] + subprocess.run(args, capture_output=True, check=True) + except OSError: + pytest.skip() # most likely msginit was not found + except CalledProcessError as exc: + print(exc.stdout) + print(exc.stderr) + msg = f'msginit exited with return code {exc.returncode}' + raise AssertionError(msg) from exc + + assert (app.outdir / 'en_US.po').is_file(), 'msginit failed' + try: + args = ['msgfmt', 'en_US.po', + '-o', os.path.join('en', 'LC_MESSAGES', 'test_root.mo')] + subprocess.run(args, capture_output=True, check=True) + except OSError: + pytest.skip() # most likely msgfmt was not found + except CalledProcessError as exc: + print(exc.stdout) + print(exc.stderr) + msg = f'msgfmt exited with return code {exc.returncode}' + raise AssertionError(msg) from exc + + mo = app.outdir / 'en' / 'LC_MESSAGES' / 'test_root.mo' + assert mo.is_file(), 'msgfmt failed' + + _ = gettext.translation('test_root', app.outdir, languages=['en']).gettext + assert _("Testing various markup") == "Testing various markup" + + +@pytest.mark.sphinx( + 'gettext', testroot='intl', srcdir='gettext', + confoverrides={'gettext_compact': False}) +def test_gettext_index_entries(app): + # regression test for #976 + 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()))) + + assert msg_ids == [ + "i18n with index entries", + "index target section", + "this is :index:`Newsletter` target paragraph.", + "various index entries", + "That's all.", + "Mailing List", + "Newsletter", + "Recipients List", + "First", + "Second", + "Third", + "Entry", + "See", + ] + + +@pytest.mark.sphinx( + 'gettext', testroot='intl', srcdir='gettext', + confoverrides={'gettext_compact': False, + 'gettext_additional_targets': []}) +def test_gettext_disable_index_entries(app): + # regression test for #976 + app.env._pickled_doctree_cache.clear() # clear cache + 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()))) + + assert msg_ids == [ + "i18n with index entries", + "index target section", + "this is :index:`Newsletter` target paragraph.", + "various index entries", + "That's all.", + ] + + +@pytest.mark.sphinx('gettext', testroot='intl', srcdir='gettext') +def test_gettext_template(app): + app.build(force_all=True) + + assert (app.outdir / 'sphinx.pot').is_file() + + result = (app.outdir / 'sphinx.pot').read_text(encoding='utf8') + assert "Welcome" in result + assert "Sphinx %(version)s" in result + + +@pytest.mark.sphinx('gettext', testroot='gettext-template') +def test_gettext_template_msgid_order_in_sphinxpot(app): + app.build(force_all=True) + assert (app.outdir / 'sphinx.pot').is_file() + + result = (app.outdir / 'sphinx.pot').read_text(encoding='utf8') + assert re.search( + ('msgid "Template 1".*' + 'msgid "This is Template 1\\.".*' + 'msgid "Template 2".*' + 'msgid "This is Template 2\\.".*'), + result, + flags=re.DOTALL) + + +@pytest.mark.sphinx( + 'gettext', srcdir='root-gettext', + confoverrides={'gettext_compact': 'documentation'}) +def test_build_single_pot(app): + app.build(force_all=True) + + assert (app.outdir / 'documentation.pot').is_file() + + result = (app.outdir / 'documentation.pot').read_text(encoding='utf8') + assert re.search( + ('msgid "Todo".*' + 'msgid "Like footnotes.".*' + 'msgid "The minute.".*' + 'msgid "Generated section".*'), + result, + flags=re.DOTALL) + + +@pytest.mark.sphinx( + 'gettext', + testroot='intl_substitution_definitions', + srcdir='gettext-subst', + confoverrides={'gettext_compact': False, + 'gettext_additional_targets': ['image']}) +def test_gettext_prolog_epilog_substitution(app): + 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') + msg_ids = list(filter(None, map(msgid_getter, pot.splitlines()))) + + assert msg_ids == [ + "i18n with prologue and epilogue substitutions", + "This is content that contains |subst_prolog_1|.", + "Substituted image |subst_prolog_2| here.", + "subst_prolog_2", + ".. image:: /img.png", + "This is content that contains |subst_epilog_1|.", + "Substituted image |subst_epilog_2| here.", + "subst_epilog_2", + ".. image:: /i18n.png", + ] + + +@pytest.mark.sphinx( + 'gettext', + testroot='intl_substitution_definitions', + srcdir='gettext-subst', + confoverrides={'gettext_compact': False, + 'gettext_additional_targets': ['image']}) +def test_gettext_prolog_epilog_substitution_excluded(app): + # regression test for #9428 + 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') + msg_ids = list(filter(None, map(msgid_getter, pot.splitlines()))) + + assert msg_ids == [ + "i18n without prologue and epilogue substitutions", + "This is content that does not include prologue and epilogue substitutions.", + ] 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_builders/test_build_latex.py b/tests/test_builders/test_build_latex.py new file mode 100644 index 0000000..0776c74 --- /dev/null +++ b/tests/test_builders/test_build_latex.py @@ -0,0 +1,1759 @@ +"""Test the build process with LaTeX builder with the test root.""" + +import http.server +import os +import re +import subprocess +from pathlib import Path +from shutil import copyfile +from subprocess import CalledProcessError + +import pytest + +from sphinx.builders.latex import default_latex_documents +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.util.osutil import ensuredir +from sphinx.writers.latex import LaTeXTranslator + +from tests.utils import http_server + +try: + from contextlib import chdir +except ImportError: + from sphinx.util.osutil import _chdir as chdir + +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'] + + +# only run latex if all needed packages are there +def kpsetest(*filenames): + try: + 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 + + +# compile latex document with app.config.latex_engine +def compile_latex_document(app, filename='python.tex', docclass='manual'): + # now, try to run latex over it + try: + with chdir(app.outdir): + # name latex output-directory according to both engine and docclass + # to avoid reuse of auxiliary files by one docclass from another + latex_outputdir = app.config.latex_engine + docclass + ensuredir(latex_outputdir) + # keep a copy of latex file for this engine in case test fails + copyfile(filename, latex_outputdir + '/' + filename) + args = [app.config.latex_engine, + '--halt-on-error', + '--interaction=nonstopmode', + 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 + raise pytest.skip.Exception from exc + except CalledProcessError as exc: + print(exc.stdout.decode('utf8')) + print(exc.stderr.decode('utf8')) + msg = f'{app.config.latex_engine} exited with return code {exc.returncode}' + raise AssertionError(msg) from exc + + +def skip_if_requested(testfunc): + if 'SKIP_LATEX_BUILD' in os.environ: + msg = 'Skip LaTeX builds because SKIP_LATEX_BUILD is set' + return pytest.mark.skipif(True, reason=msg)(testfunc) + else: + return testfunc + + +def skip_if_stylefiles_notfound(testfunc): + if kpsetest(*STYLEFILES) is False: + msg = 'not running latex, the required styles do not seem to be installed' + return pytest.mark.skipif(True, reason=msg)(testfunc) + else: + 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( + ('engine', 'docclass', 'python_maximum_signature_line_length'), + # 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. + [ + ('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, 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), + } + intersphinx_setup(app) + app.config.latex_engine = engine + app.config.latex_documents = [app.config.latex_documents[0][:4] + (docclass,)] + if engine == 'xelatex': + app.config.latex_table_style = ['booktabs'] + elif engine == 'lualatex': + app.config.latex_table_style = ['colorrows'] + normalize_intersphinx_mapping(app, app.config) + load_mappings(app) + app.builder.init() + LaTeXTranslator.ignore_missing_images = True + with http_server(RemoteImageHandler): + app.build(force_all=True) + + # file from latex_additional_files + assert (app.outdir / 'svgimg.svg').is_file() + + compile_latex_document(app, 'sphinxtests.tex', docclass) + + +@pytest.mark.sphinx('latex') +def test_writer(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'sphinxtests.tex').read_text(encoding='utf8') + + assert ('\\begin{sphinxfigure-in-table}\n\\centering\n\\capstart\n' + '\\noindent\\sphinxincludegraphics{{img}.png}\n' + '\\sphinxfigcaption{figure in table}\\label{\\detokenize{markup:id8}}' + '\\end{sphinxfigure-in-table}\\relax' in result) + + assert ('\\begin{wrapfigure}{r}{0pt}\n\\centering\n' + '\\noindent\\sphinxincludegraphics{{rimg}.png}\n' + '\\caption{figure with align option}\\label{\\detokenize{markup:id9}}' + '\\end{wrapfigure}\n\n' + '\\mbox{}\\par\\vskip-\\dimexpr\\baselineskip+\\parskip\\relax' in result) + + assert ('\\begin{wrapfigure}{r}{0.500\\linewidth}\n\\centering\n' + '\\noindent\\sphinxincludegraphics{{rimg}.png}\n' + '\\caption{figure with align \\& figwidth option}' + '\\label{\\detokenize{markup:id10}}' + '\\end{wrapfigure}\n\n' + '\\mbox{}\\par\\vskip-\\dimexpr\\baselineskip+\\parskip\\relax' in result) + + assert ('\\begin{wrapfigure}{r}{3cm}\n\\centering\n' + '\\noindent\\sphinxincludegraphics[width=3cm]{{rimg}.png}\n' + '\\caption{figure with align \\& width option}' + '\\label{\\detokenize{markup:id11}}' + '\\end{wrapfigure}\n\n' + '\\mbox{}\\par\\vskip-\\dimexpr\\baselineskip+\\parskip\\relax' in result) + + assert 'Footnotes' not in result + + assert ('\\begin{sphinxseealso}{See also:}\n\n' + '\\sphinxAtStartPar\n' + 'something, something else, something more\n' + '\\begin{description}\n' + '\\sphinxlineitem{\\sphinxhref{https://www.google.com}{Google}}\n' + '\\sphinxAtStartPar\n' + 'For everything.\n' + '\n' + '\\end{description}\n' + '\n\n\\end{sphinxseealso}\n\n' in result) + + +@pytest.mark.sphinx('latex', testroot='basic') +def test_latex_basic(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'test.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert r'\title{The basic Sphinx documentation for testing}' in result + assert r'\release{}' in result + assert r'\renewcommand{\releasename}{}' in result + + +@pytest.mark.sphinx('latex', testroot='basic', + confoverrides={ + 'latex_documents': [('index', 'test.tex', 'title', 'author', 'manual')], + }) +def test_latex_basic_manual(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'test.tex').read_text(encoding='utf8') + print(result) + assert r'\def\sphinxdocclass{report}' in result + assert r'\documentclass[letterpaper,10pt,english]{sphinxmanual}' in result + + +@pytest.mark.sphinx('latex', testroot='basic', + confoverrides={ + 'latex_documents': [('index', 'test.tex', 'title', 'author', 'howto')], + }) +def test_latex_basic_howto(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'test.tex').read_text(encoding='utf8') + print(result) + assert r'\def\sphinxdocclass{article}' in result + assert r'\documentclass[letterpaper,10pt,english]{sphinxhowto}' in result + + +@pytest.mark.sphinx('latex', testroot='basic', + confoverrides={ + 'language': 'ja', + 'latex_documents': [('index', 'test.tex', 'title', 'author', 'manual')], + }) +def test_latex_basic_manual_ja(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'test.tex').read_text(encoding='utf8') + print(result) + assert r'\def\sphinxdocclass{ujbook}' in result + assert r'\documentclass[letterpaper,10pt,dvipdfmx]{sphinxmanual}' in result + + +@pytest.mark.sphinx('latex', testroot='basic', + confoverrides={ + 'language': 'ja', + 'latex_documents': [('index', 'test.tex', 'title', 'author', 'howto')], + }) +def test_latex_basic_howto_ja(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'test.tex').read_text(encoding='utf8') + print(result) + assert r'\def\sphinxdocclass{ujreport}' in result + assert r'\documentclass[letterpaper,10pt,dvipdfmx]{sphinxhowto}' in result + + +@pytest.mark.sphinx('latex', testroot='latex-theme') +def test_latex_theme(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + assert r'\def\sphinxdocclass{book}' in result + assert r'\documentclass[a4paper,12pt,english]{sphinxbook}' in result + + +@pytest.mark.sphinx('latex', testroot='latex-theme', + confoverrides={'latex_elements': {'papersize': 'b5paper', + 'pointsize': '9pt'}}) +def test_latex_theme_papersize(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + assert r'\def\sphinxdocclass{book}' in result + assert r'\documentclass[b5paper,9pt,english]{sphinxbook}' in result + + +@pytest.mark.sphinx('latex', testroot='latex-theme', + confoverrides={'latex_theme_options': {'papersize': 'b5paper', + 'pointsize': '9pt'}}) +def test_latex_theme_options(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + assert r'\def\sphinxdocclass{book}' in result + assert r'\documentclass[b5paper,9pt,english]{sphinxbook}' in result + + +@pytest.mark.sphinx('latex', testroot='basic', confoverrides={'language': 'zh'}) +def test_latex_additional_settings_for_language_code(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'test.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert r'\usepackage{xeCJK}' in result + + +@pytest.mark.sphinx('latex', testroot='basic', confoverrides={'language': 'el'}) +def test_latex_additional_settings_for_greek(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'test.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert '\\usepackage{polyglossia}\n\\setmainlanguage{greek}' in result + assert '\\newfontfamily\\greekfonttt{FreeMono}' in result + + +@pytest.mark.sphinx('latex', testroot='latex-title') +def test_latex_title_after_admonitions(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'test.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert '\\title{test\\sphinxhyphen{}latex\\sphinxhyphen{}title}' in result + + +@pytest.mark.sphinx('latex', testroot='basic', + confoverrides={'release': '1.0_0'}) +def test_latex_release(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'test.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert r'\release{1.0\_0}' in result + assert r'\renewcommand{\releasename}{Release}' in result + + +@pytest.mark.sphinx('latex', testroot='numfig', + confoverrides={'numfig': True}) +def test_numref(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert ('\\hyperref[\\detokenize{index:fig1}]' + '{Fig.\\@ \\ref{\\detokenize{index:fig1}}}') in result + assert ('\\hyperref[\\detokenize{baz:fig22}]' + '{Figure\\ref{\\detokenize{baz:fig22}}}') in result + assert ('\\hyperref[\\detokenize{index:table-1}]' + '{Table \\ref{\\detokenize{index:table-1}}}') in result + assert ('\\hyperref[\\detokenize{baz:table22}]' + '{Table:\\ref{\\detokenize{baz:table22}}}') in result + assert ('\\hyperref[\\detokenize{index:code-1}]' + '{Listing \\ref{\\detokenize{index:code-1}}}') in result + assert ('\\hyperref[\\detokenize{baz:code22}]' + '{Code\\sphinxhyphen{}\\ref{\\detokenize{baz:code22}}}') in result + assert ('\\hyperref[\\detokenize{foo:foo}]' + '{Section \\ref{\\detokenize{foo:foo}}}') in result + assert ('\\hyperref[\\detokenize{bar:bar-a}]' + '{Section \\ref{\\detokenize{bar:bar-a}}}') in result + assert ('\\hyperref[\\detokenize{index:fig1}]{Fig.\\ref{\\detokenize{index:fig1}} ' + '\\nameref{\\detokenize{index:fig1}}}') in result + assert ('\\hyperref[\\detokenize{foo:foo}]{Sect.\\ref{\\detokenize{foo:foo}} ' + '\\nameref{\\detokenize{foo:foo}}}') in result + + # sphinxmessages.sty + result = (app.outdir / 'sphinxmessages.sty').read_text(encoding='utf8') + print(result) + assert r'\addto\captionsenglish{\renewcommand{\figurename}{Fig.\@{} }}' in result + assert r'\addto\captionsenglish{\renewcommand{\tablename}{Table }}' in result + assert r'\addto\captionsenglish{\renewcommand{\literalblockname}{Listing}}' in result + + +@pytest.mark.sphinx( + 'latex', testroot='numfig', + confoverrides={'numfig': True, + 'numfig_format': {'figure': 'Figure:%s', + 'table': 'Tab_%s', + 'code-block': 'Code-%s', + 'section': 'SECTION-%s'}}) +def test_numref_with_prefix1(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert '\\ref{\\detokenize{index:fig1}}' in result + assert '\\ref{\\detokenize{baz:fig22}}' in result + assert '\\ref{\\detokenize{index:table-1}}' in result + assert '\\ref{\\detokenize{baz:table22}}' in result + assert '\\ref{\\detokenize{index:code-1}}' in result + assert '\\ref{\\detokenize{baz:code22}}' in result + assert ('\\hyperref[\\detokenize{index:fig1}]' + '{Figure:\\ref{\\detokenize{index:fig1}}}') in result + assert ('\\hyperref[\\detokenize{baz:fig22}]' + '{Figure\\ref{\\detokenize{baz:fig22}}}') in result + assert ('\\hyperref[\\detokenize{index:table-1}]' + '{Tab\\_\\ref{\\detokenize{index:table-1}}}') in result + assert ('\\hyperref[\\detokenize{baz:table22}]' + '{Table:\\ref{\\detokenize{baz:table22}}}') in result + assert ('\\hyperref[\\detokenize{index:code-1}]' + '{Code\\sphinxhyphen{}\\ref{\\detokenize{index:code-1}}}') in result + assert ('\\hyperref[\\detokenize{baz:code22}]' + '{Code\\sphinxhyphen{}\\ref{\\detokenize{baz:code22}}}') in result + assert ('\\hyperref[\\detokenize{foo:foo}]' + '{SECTION\\sphinxhyphen{}\\ref{\\detokenize{foo:foo}}}') in result + assert ('\\hyperref[\\detokenize{bar:bar-a}]' + '{SECTION\\sphinxhyphen{}\\ref{\\detokenize{bar:bar-a}}}') in result + assert ('\\hyperref[\\detokenize{index:fig1}]{Fig.\\ref{\\detokenize{index:fig1}} ' + '\\nameref{\\detokenize{index:fig1}}}') in result + assert ('\\hyperref[\\detokenize{foo:foo}]{Sect.\\ref{\\detokenize{foo:foo}} ' + '\\nameref{\\detokenize{foo:foo}}}') in result + + # sphinxmessages.sty + result = (app.outdir / 'sphinxmessages.sty').read_text(encoding='utf8') + print(result) + assert r'\addto\captionsenglish{\renewcommand{\figurename}{Figure:}}' in result + assert r'\addto\captionsenglish{\renewcommand{\tablename}{Tab\_}}' in result + assert r'\addto\captionsenglish{\renewcommand{\literalblockname}{Code-}}' in result + + +@pytest.mark.sphinx( + 'latex', testroot='numfig', + confoverrides={'numfig': True, + 'numfig_format': {'figure': 'Figure:%s.', + 'table': 'Tab_%s:', + 'code-block': 'Code-%s | ', + 'section': 'SECTION_%s_'}}) +def test_numref_with_prefix2(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert ('\\hyperref[\\detokenize{index:fig1}]' + '{Figure:\\ref{\\detokenize{index:fig1}}.\\@}') in result + assert ('\\hyperref[\\detokenize{baz:fig22}]' + '{Figure\\ref{\\detokenize{baz:fig22}}}') in result + assert ('\\hyperref[\\detokenize{index:table-1}]' + '{Tab\\_\\ref{\\detokenize{index:table-1}}:}') in result + assert ('\\hyperref[\\detokenize{baz:table22}]' + '{Table:\\ref{\\detokenize{baz:table22}}}') in result + assert ('\\hyperref[\\detokenize{index:code-1}]{Code\\sphinxhyphen{}\\ref{\\detokenize{index:code-1}} ' + '| }') in result + assert ('\\hyperref[\\detokenize{baz:code22}]' + '{Code\\sphinxhyphen{}\\ref{\\detokenize{baz:code22}}}') in result + assert ('\\hyperref[\\detokenize{foo:foo}]' + '{SECTION\\_\\ref{\\detokenize{foo:foo}}\\_}') in result + assert ('\\hyperref[\\detokenize{bar:bar-a}]' + '{SECTION\\_\\ref{\\detokenize{bar:bar-a}}\\_}') in result + assert ('\\hyperref[\\detokenize{index:fig1}]{Fig.\\ref{\\detokenize{index:fig1}} ' + '\\nameref{\\detokenize{index:fig1}}}') in result + assert ('\\hyperref[\\detokenize{foo:foo}]{Sect.\\ref{\\detokenize{foo:foo}} ' + '\\nameref{\\detokenize{foo:foo}}}') in result + + # sphinxmessages.sty + result = (app.outdir / 'sphinxmessages.sty').read_text(encoding='utf8') + print(result) + assert r'\addto\captionsenglish{\renewcommand{\figurename}{Figure:}}' in result + assert r'\def\fnum@figure{\figurename\thefigure{}.}' in result + assert r'\addto\captionsenglish{\renewcommand{\tablename}{Tab\_}}' in result + assert r'\def\fnum@table{\tablename\thetable{}:}' in result + assert r'\addto\captionsenglish{\renewcommand{\literalblockname}{Code-}}' in result + + +@pytest.mark.sphinx( + 'latex', testroot='numfig', + confoverrides={'numfig': True, 'language': 'ja'}) +def test_numref_with_language_ja(app, status, warning): + app.build() + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert ('\\hyperref[\\detokenize{index:fig1}]' + '{\u56f3 \\ref{\\detokenize{index:fig1}}}') in result + assert ('\\hyperref[\\detokenize{baz:fig22}]' + '{Figure\\ref{\\detokenize{baz:fig22}}}') in result + assert ('\\hyperref[\\detokenize{index:table-1}]' + '{\u8868 \\ref{\\detokenize{index:table-1}}}') in result + assert ('\\hyperref[\\detokenize{baz:table22}]' + '{Table:\\ref{\\detokenize{baz:table22}}}') in result + assert ('\\hyperref[\\detokenize{index:code-1}]' + '{\u30ea\u30b9\u30c8 \\ref{\\detokenize{index:code-1}}}') in result + assert ('\\hyperref[\\detokenize{baz:code22}]' + '{Code\\sphinxhyphen{}\\ref{\\detokenize{baz:code22}}}') in result + assert ('\\hyperref[\\detokenize{foo:foo}]' + '{\\ref{\\detokenize{foo:foo}} \u7ae0}') in result + assert ('\\hyperref[\\detokenize{bar:bar-a}]' + '{\\ref{\\detokenize{bar:bar-a}} \u7ae0}') in result + assert ('\\hyperref[\\detokenize{index:fig1}]{Fig.\\ref{\\detokenize{index:fig1}} ' + '\\nameref{\\detokenize{index:fig1}}}') in result + assert ('\\hyperref[\\detokenize{foo:foo}]{Sect.\\ref{\\detokenize{foo:foo}} ' + '\\nameref{\\detokenize{foo:foo}}}') in result + + # sphinxmessages.sty + result = (app.outdir / 'sphinxmessages.sty').read_text(encoding='utf8') + print(result) + assert '\\@iden{\\renewcommand{\\figurename}{図 }}' in result + assert '\\@iden{\\renewcommand{\\tablename}{表 }}' in result + assert '\\@iden{\\renewcommand{\\literalblockname}{リスト}}' in result + + +@pytest.mark.sphinx('latex', testroot='latex-numfig') +def test_latex_obey_numfig_is_false(app, status, warning): + app.build(force_all=True) + + result = (app.outdir / 'SphinxManual.tex').read_text(encoding='utf8') + assert '\\usepackage{sphinx}' in result + + result = (app.outdir / 'SphinxHowTo.tex').read_text(encoding='utf8') + assert '\\usepackage{sphinx}' in result + + +@pytest.mark.sphinx( + 'latex', testroot='latex-numfig', + confoverrides={'numfig': True, 'numfig_secnum_depth': 0}) +def test_latex_obey_numfig_secnum_depth_is_zero(app, status, warning): + app.build(force_all=True) + + result = (app.outdir / 'SphinxManual.tex').read_text(encoding='utf8') + assert '\\usepackage[,nonumfigreset,mathnumfig]{sphinx}' in result + + result = (app.outdir / 'SphinxHowTo.tex').read_text(encoding='utf8') + assert '\\usepackage[,nonumfigreset,mathnumfig]{sphinx}' in result + + +@pytest.mark.sphinx( + 'latex', testroot='latex-numfig', + confoverrides={'numfig': True, 'numfig_secnum_depth': 2}) +def test_latex_obey_numfig_secnum_depth_is_two(app, status, warning): + app.build(force_all=True) + + result = (app.outdir / 'SphinxManual.tex').read_text(encoding='utf8') + assert '\\usepackage[,numfigreset=2,mathnumfig]{sphinx}' in result + + result = (app.outdir / 'SphinxHowTo.tex').read_text(encoding='utf8') + assert '\\usepackage[,numfigreset=3,mathnumfig]{sphinx}' in result + + +@pytest.mark.sphinx( + 'latex', testroot='latex-numfig', + confoverrides={'numfig': True, 'math_numfig': False}) +def test_latex_obey_numfig_but_math_numfig_false(app, status, warning): + app.build(force_all=True) + + result = (app.outdir / 'SphinxManual.tex').read_text(encoding='utf8') + assert '\\usepackage[,numfigreset=1]{sphinx}' in result + + result = (app.outdir / 'SphinxHowTo.tex').read_text(encoding='utf8') + assert '\\usepackage[,numfigreset=2]{sphinx}' in result + + +@pytest.mark.sphinx('latex', testroot='basic') +def test_latex_add_latex_package(app, status, warning): + app.add_latex_package('foo') + app.add_latex_package('bar', 'baz') + 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 + + +@pytest.mark.sphinx('latex', testroot='latex-babel') +def test_babel_with_no_language_settings(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert '\\documentclass[letterpaper,10pt,english]{sphinxmanual}' in result + assert '\\usepackage{babel}' in result + assert '\\usepackage{tgtermes}' in result + assert '\\usepackage[Bjarne]{fncychap}' in result + assert ('\\addto\\captionsenglish{\\renewcommand{\\contentsname}{Table of content}}\n' + in result) + assert '\\shorthandoff{"}' in result + + # sphinxmessages.sty + result = (app.outdir / 'sphinxmessages.sty').read_text(encoding='utf8') + print(result) + assert r'\def\pageautorefname{page}' in result + assert r'\addto\captionsenglish{\renewcommand{\figurename}{Fig.\@{} }}' in result + assert r'\addto\captionsenglish{\renewcommand{\tablename}{Table.\@{} }}' in result + + +@pytest.mark.sphinx( + 'latex', testroot='latex-babel', + confoverrides={'language': 'de'}) +def test_babel_with_language_de(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert '\\documentclass[letterpaper,10pt,ngerman]{sphinxmanual}' in result + assert '\\usepackage{babel}' in result + assert '\\usepackage{tgtermes}' in result + assert '\\usepackage[Sonny]{fncychap}' in result + assert ('\\addto\\captionsngerman{\\renewcommand{\\contentsname}{Table of content}}\n' + in result) + assert '\\shorthandoff{"}' in result + + # sphinxmessages.sty + result = (app.outdir / 'sphinxmessages.sty').read_text(encoding='utf8') + print(result) + assert r'\def\pageautorefname{Seite}' in result + assert r'\addto\captionsngerman{\renewcommand{\figurename}{Fig.\@{} }}' in result + assert r'\addto\captionsngerman{\renewcommand{\tablename}{Table.\@{} }}' in result + + +@pytest.mark.sphinx( + 'latex', testroot='latex-babel', + confoverrides={'language': 'ru'}) +def test_babel_with_language_ru(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert '\\documentclass[letterpaper,10pt,russian]{sphinxmanual}' in result + assert '\\usepackage{babel}' in result + assert '\\usepackage{tgtermes}' not in result + assert '\\usepackage[Sonny]{fncychap}' in result + assert ('\\addto\\captionsrussian{\\renewcommand{\\contentsname}{Table of content}}\n' + in result) + assert '\\shorthandoff{"}' in result + + # sphinxmessages.sty + result = (app.outdir / 'sphinxmessages.sty').read_text(encoding='utf8') + print(result) + assert r'\def\pageautorefname{страница}' in result + assert r'\addto\captionsrussian{\renewcommand{\figurename}{Fig.\@{} }}' in result + assert r'\addto\captionsrussian{\renewcommand{\tablename}{Table.\@{} }}' in result + + +@pytest.mark.sphinx( + 'latex', testroot='latex-babel', + confoverrides={'language': 'tr'}) +def test_babel_with_language_tr(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert '\\documentclass[letterpaper,10pt,turkish]{sphinxmanual}' in result + assert '\\usepackage{babel}' in result + assert '\\usepackage{tgtermes}' in result + assert '\\usepackage[Sonny]{fncychap}' in result + assert ('\\addto\\captionsturkish{\\renewcommand{\\contentsname}{Table of content}}\n' + in result) + assert '\\shorthandoff{=}' in result + + # sphinxmessages.sty + result = (app.outdir / 'sphinxmessages.sty').read_text(encoding='utf8') + print(result) + assert r'\def\pageautorefname{sayfa}' in result + assert r'\addto\captionsturkish{\renewcommand{\figurename}{Fig.\@{} }}' in result + assert r'\addto\captionsturkish{\renewcommand{\tablename}{Table.\@{} }}' in result + + +@pytest.mark.sphinx( + 'latex', testroot='latex-babel', + confoverrides={'language': 'ja'}) +def test_babel_with_language_ja(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert '\\documentclass[letterpaper,10pt,dvipdfmx]{sphinxmanual}' in result + assert '\\usepackage{babel}' not in result + assert '\\usepackage{tgtermes}' in result + assert '\\usepackage[Sonny]{fncychap}' not in result + assert '\\renewcommand{\\contentsname}{Table of content}\n' in result + assert '\\shorthandoff' not in result + + # sphinxmessages.sty + result = (app.outdir / 'sphinxmessages.sty').read_text(encoding='utf8') + print(result) + assert r'\def\pageautorefname{ページ}' in result + assert '\\@iden{\\renewcommand{\\figurename}{Fig.\\@{} }}' in result + assert '\\@iden{\\renewcommand{\\tablename}{Table.\\@{} }}' in result + + +@pytest.mark.sphinx( + 'latex', testroot='latex-babel', + confoverrides={'language': 'unknown'}) +def test_babel_with_unknown_language(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert '\\documentclass[letterpaper,10pt,english]{sphinxmanual}' in result + assert '\\usepackage{babel}' in result + assert '\\usepackage{tgtermes}' in result + assert '\\usepackage[Sonny]{fncychap}' in result + assert ('\\addto\\captionsenglish{\\renewcommand{\\contentsname}{Table of content}}\n' + in result) + assert '\\shorthandoff' in result + + assert "WARNING: no Babel option known for language 'unknown'" in warning.getvalue() + + # sphinxmessages.sty + result = (app.outdir / 'sphinxmessages.sty').read_text(encoding='utf8') + print(result) + assert r'\def\pageautorefname{page}' in result + assert r'\addto\captionsenglish{\renewcommand{\figurename}{Fig.\@{} }}' in result + assert r'\addto\captionsenglish{\renewcommand{\tablename}{Table.\@{} }}' in result + + +@pytest.mark.sphinx( + 'latex', testroot='latex-babel', + confoverrides={'language': 'de', 'latex_engine': 'lualatex'}) +def test_polyglossia_with_language_de(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert '\\documentclass[letterpaper,10pt,german]{sphinxmanual}' in result + assert '\\usepackage{polyglossia}' in result + assert '\\setmainlanguage[spelling=new]{german}' in result + assert '\\usepackage{tgtermes}' not in result + assert '\\usepackage[Sonny]{fncychap}' in result + assert ('\\addto\\captionsgerman{\\renewcommand{\\contentsname}{Table of content}}\n' + in result) + assert '\\shorthandoff' not in result + + # sphinxmessages.sty + result = (app.outdir / 'sphinxmessages.sty').read_text(encoding='utf8') + print(result) + assert r'\def\pageautorefname{Seite}' in result + assert r'\addto\captionsgerman{\renewcommand{\figurename}{Fig.\@{} }}' in result + assert r'\addto\captionsgerman{\renewcommand{\tablename}{Table.\@{} }}' in result + + +@pytest.mark.sphinx( + 'latex', testroot='latex-babel', + confoverrides={'language': 'de-1901', 'latex_engine': 'lualatex'}) +def test_polyglossia_with_language_de_1901(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert '\\documentclass[letterpaper,10pt,german]{sphinxmanual}' in result + assert '\\usepackage{polyglossia}' in result + assert '\\setmainlanguage[spelling=old]{german}' in result + assert '\\usepackage{tgtermes}' not in result + assert '\\usepackage[Sonny]{fncychap}' in result + assert ('\\addto\\captionsgerman{\\renewcommand{\\contentsname}{Table of content}}\n' + in result) + assert '\\shorthandoff' not in result + + # sphinxmessages.sty + result = (app.outdir / 'sphinxmessages.sty').read_text(encoding='utf8') + print(result) + assert r'\def\pageautorefname{page}' in result + assert r'\addto\captionsgerman{\renewcommand{\figurename}{Fig.\@{} }}' in result + assert r'\addto\captionsgerman{\renewcommand{\tablename}{Table.\@{} }}' in result + + +@pytest.mark.sphinx('latex') +def test_footnote(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'sphinxtests.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert ('\\sphinxAtStartPar\n%\n\\begin{footnote}[1]\\sphinxAtStartFootnote\n' + 'numbered\n%\n\\end{footnote}') in result + assert ('\\begin{footnote}[2]\\sphinxAtStartFootnote\nauto numbered\n%\n' + '\\end{footnote}') in result + assert '\\begin{footnote}[3]\\sphinxAtStartFootnote\nnamed\n%\n\\end{footnote}' in result + assert '\\sphinxcite{footnote:bar}' in result + assert ('\\bibitem[bar]{footnote:bar}\n\\sphinxAtStartPar\ncite\n') in result + assert '\\sphinxcaption{Table caption \\sphinxfootnotemark[4]' in result + assert ('\\sphinxmidrule\n\\sphinxtableatstartofbodyhook%\n' + '\\begin{footnotetext}[4]\\sphinxAtStartFootnote\n' + 'footnote in table caption\n%\n\\end{footnotetext}\\ignorespaces %\n' + '\\begin{footnotetext}[5]\\sphinxAtStartFootnote\n' + 'footnote in table header\n%\n\\end{footnotetext}\\ignorespaces ' + '\n\\sphinxAtStartPar\n' + 'VIDIOC\\_CROPCAP\n&\n\\sphinxAtStartPar\n') in result + assert ('Information about VIDIOC\\_CROPCAP %\n' + '\\begin{footnote}[6]\\sphinxAtStartFootnote\n' + 'footnote in table not in header\n%\n\\end{footnote}\n\\\\\n' + '\\sphinxbottomrule\n\\end{tabulary}\n' + '\\sphinxtableafterendhook\\par\n\\sphinxattableend\\end{savenotes}\n') in result + + +@pytest.mark.sphinx('latex', testroot='footnotes') +def test_reference_in_caption_and_codeblock_in_footnote(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert ('\\caption{This is the figure caption with a reference to ' + '\\sphinxcite{index:authoryear}.}' in result) + assert '\\chapter{The section with a reference to {[}AuthorYear{]}}' in result + assert ('\\sphinxcaption{The table title with a reference' + ' to {[}AuthorYear{]}}' in result) + assert '\\subsubsection*{The rubric title with a reference to {[}AuthorYear{]}}' in result + assert ('\\chapter{The section with a reference to \\sphinxfootnotemark[6]}\n' + '\\label{\\detokenize{index:the-section-with-a-reference-to}}' + '%\n\\begin{footnotetext}[6]\\sphinxAtStartFootnote\n' + 'Footnote in section\n%\n\\end{footnotetext}') in result + assert ('\\caption{This is the figure caption with a footnote to ' + '\\sphinxfootnotemark[8].}\\label{\\detokenize{index:id35}}\\end{figure}\n' + '%\n\\begin{footnotetext}[8]\\sphinxAtStartFootnote\n' + 'Footnote in caption\n%\n\\end{footnotetext}') in result + assert ('\\sphinxcaption{footnote \\sphinxfootnotemark[9] in ' + 'caption of normal table}\\label{\\detokenize{index:id36}}') in result + assert ('\\caption{footnote \\sphinxfootnotemark[10] ' + 'in caption \\sphinxfootnotemark[11] of longtable\\strut}') in result + assert ('\\endlastfoot\n\\sphinxtableatstartofbodyhook\n%\n' + '\\begin{footnotetext}[10]\\sphinxAtStartFootnote\n' + 'Foot note in longtable\n%\n\\end{footnotetext}\\ignorespaces %\n' + '\\begin{footnotetext}[11]\\sphinxAtStartFootnote\n' + 'Second footnote in caption of longtable\n') in result + assert ('This is a reference to the code\\sphinxhyphen{}block in the footnote:\n' + '{\\hyperref[\\detokenize{index:codeblockinfootnote}]' + '{\\sphinxcrossref{\\DUrole{std,std-ref}{I am in a footnote}}}}') in result + assert ('&\n\\sphinxAtStartPar\nThis is one more footnote with some code in it %\n' + '\\begin{footnote}[12]\\sphinxAtStartFootnote\n' + 'Third footnote in longtable\n') in result + assert ('\\end{sphinxVerbatim}\n%\n\\end{footnote}.\n') in result + assert '\\begin{sphinxVerbatim}[commandchars=\\\\\\{\\}]' in result + + +@pytest.mark.sphinx('latex', testroot='footnotes') +def test_footnote_referred_multiple_times(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + + assert ('Explicitly numbered footnote: %\n' + '\\begin{footnote}[100]' + '\\sphinxAtStartFootnote\nNumbered footnote\n%\n' + '\\end{footnote} \\sphinxfootnotemark[100]\n' + in result) + assert ('Named footnote: %\n' + '\\begin{footnote}[13]' + '\\sphinxAtStartFootnote\nNamed footnote\n%\n' + '\\end{footnote} \\sphinxfootnotemark[13]\n' + in result) + + +@pytest.mark.sphinx( + 'latex', testroot='footnotes', + confoverrides={'latex_show_urls': 'inline'}) +def test_latex_show_urls_is_inline(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert ('Same footnote number %\n' + '\\begin{footnote}[1]\\sphinxAtStartFootnote\n' + 'footnote in bar\n%\n\\end{footnote} in bar.rst') in result + assert ('Auto footnote number %\n\\begin{footnote}[1]\\sphinxAtStartFootnote\n' + 'footnote in baz\n%\n\\end{footnote} in baz.rst') in result + assert ('\\phantomsection\\label{\\detokenize{index:id38}}' + '{\\hyperref[\\detokenize{index:the-section' + '-with-a-reference-to-authoryear}]' + '{\\sphinxcrossref{The section with a reference to ' + '\\sphinxcite{index:authoryear}}}}') in result + assert ('\\phantomsection\\label{\\detokenize{index:id39}}' + '{\\hyperref[\\detokenize{index:the-section-with-a-reference-to}]' + '{\\sphinxcrossref{The section with a reference to }}}' in result) + assert ('First footnote: %\n\\begin{footnote}[2]\\sphinxAtStartFootnote\n' + 'First\n%\n\\end{footnote}') in result + assert ('Second footnote: %\n' + '\\begin{footnote}[1]\\sphinxAtStartFootnote\n' + 'Second\n%\n\\end{footnote}\n') 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{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{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{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}' + '{sphinx\\sphinxhyphen{}dev@googlegroups.com}') in result + assert '\\begin{savenotes}\\begin{fulllineitems}' not in result + + +@pytest.mark.sphinx( + 'latex', testroot='footnotes', + confoverrides={'latex_show_urls': 'footnote'}) +def test_latex_show_urls_is_footnote(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert ('Same footnote number %\n' + '\\begin{footnote}[1]\\sphinxAtStartFootnote\n' + 'footnote in bar\n%\n\\end{footnote} in bar.rst') in result + assert ('Auto footnote number %\n\\begin{footnote}[2]\\sphinxAtStartFootnote\n' + 'footnote in baz\n%\n\\end{footnote} in baz.rst') in result + assert ('\\phantomsection\\label{\\detokenize{index:id38}}' + '{\\hyperref[\\detokenize{index:the-section-with-a-reference-to-authoryear}]' + '{\\sphinxcrossref{The section with a reference ' + 'to \\sphinxcite{index:authoryear}}}}') in result + assert ('\\phantomsection\\label{\\detokenize{index:id39}}' + '{\\hyperref[\\detokenize{index:the-section-with-a-reference-to}]' + '{\\sphinxcrossref{The section with a reference to }}}') in result + assert ('First footnote: %\n\\begin{footnote}[3]\\sphinxAtStartFootnote\n' + 'First\n%\n\\end{footnote}') in result + assert ('Second footnote: %\n' + '\\begin{footnote}[1]\\sphinxAtStartFootnote\n' + 'Second\n%\n\\end{footnote}') in result + assert ('\\sphinxhref{https://sphinx-doc.org/}{Sphinx}' + '%\n\\begin{footnote}[4]\\sphinxAtStartFootnote\n' + '\\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' + 'Footnote inside footnote\n%\n' + '\\end{footnotetext}\\ignorespaces') in result + assert ('Fourth footnote: %\n\\begin{footnote}[8]\\sphinxAtStartFootnote\n' + 'Fourth\n%\n\\end{footnote}\n') in result + assert ('\\sphinxhref{https://sphinx-doc.org/~test/}{URL including tilde}' + '%\n\\begin{footnote}[5]\\sphinxAtStartFootnote\n' + '\\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{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{https://sphinx-doc.org/}{Term in deflist}' + '\\sphinxfootnotemark[11]}%\n' + '\\begin{footnotetext}[11]' + '\\sphinxAtStartFootnote\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}' + '{sphinx\\sphinxhyphen{}dev@googlegroups.com}\n') in result + assert '\\begin{savenotes}\\begin{fulllineitems}' in result + + +@pytest.mark.sphinx( + 'latex', testroot='footnotes', + confoverrides={'latex_show_urls': 'no'}) +def test_latex_show_urls_is_no(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert ('Same footnote number %\n' + '\\begin{footnote}[1]\\sphinxAtStartFootnote\n' + 'footnote in bar\n%\n\\end{footnote} in bar.rst') in result + assert ('Auto footnote number %\n\\begin{footnote}[1]\\sphinxAtStartFootnote\n' + 'footnote in baz\n%\n\\end{footnote} in baz.rst') in result + assert ('\\phantomsection\\label{\\detokenize{index:id38}}' + '{\\hyperref[\\detokenize{index:the-section-with-a-reference-to-authoryear}]' + '{\\sphinxcrossref{The section with a reference ' + 'to \\sphinxcite{index:authoryear}}}}') in result + assert ('\\phantomsection\\label{\\detokenize{index:id39}}' + '{\\hyperref[\\detokenize{index:the-section-with-a-reference-to}]' + '{\\sphinxcrossref{The section with a reference to }}}' in result) + assert ('First footnote: %\n\\begin{footnote}[2]\\sphinxAtStartFootnote\n' + 'First\n%\n\\end{footnote}') in result + assert ('Second footnote: %\n' + '\\begin{footnote}[1]\\sphinxAtStartFootnote\n' + 'Second\n%\n\\end{footnote}') 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{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{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}' + '{sphinx\\sphinxhyphen{}dev@googlegroups.com}\n') in result + assert '\\begin{savenotes}\\begin{fulllineitems}' not in result + + +@pytest.mark.sphinx( + 'latex', testroot='footnotes', + confoverrides={'latex_show_urls': 'footnote', + '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) + + +@pytest.mark.sphinx('latex', testroot='image-in-section') +def test_image_in_section(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert ('\\chapter[Test section]{\\lowercase{\\sphinxincludegraphics' + '[width=15bp,height=15bp]}{{pic}.png} Test section}' + in result) + assert ('\\chapter[Other {[}blah{]} section]{Other {[}blah{]} ' + '\\lowercase{\\sphinxincludegraphics[width=15bp,height=15bp]}' + '{{pic}.png} section}' in result) + assert ('\\chapter{Another section}' in result) + + +@pytest.mark.sphinx('latex', testroot='basic', + confoverrides={'latex_logo': 'notfound.jpg'}) +def test_latex_logo_if_not_found(app, status, warning): + with pytest.raises(SphinxError): + app.build(force_all=True) + + +@pytest.mark.sphinx('latex', testroot='toctree-maxdepth') +def test_toctree_maxdepth_manual(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert '\\setcounter{tocdepth}{1}' in result + assert '\\setcounter{secnumdepth}' not in result + assert '\\chapter{Foo}' in result + + +@pytest.mark.sphinx( + 'latex', testroot='toctree-maxdepth', + confoverrides={'latex_documents': [ + ('index', 'python.tex', 'Sphinx Tests Documentation', + 'Georg Brandl', 'howto'), + ]}) +def test_toctree_maxdepth_howto(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert '\\setcounter{tocdepth}{2}' in result + assert '\\setcounter{secnumdepth}' not in result + assert '\\section{Foo}' in result + + +@pytest.mark.sphinx( + 'latex', testroot='toctree-maxdepth', + confoverrides={'root_doc': 'foo'}) +def test_toctree_not_found(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert '\\setcounter{tocdepth}' not in result + assert '\\setcounter{secnumdepth}' not in result + assert '\\chapter{Foo A}' in result + + +@pytest.mark.sphinx( + 'latex', testroot='toctree-maxdepth', + confoverrides={'root_doc': 'bar'}) +def test_toctree_without_maxdepth(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert '\\setcounter{tocdepth}' not in result + assert '\\setcounter{secnumdepth}' not in result + + +@pytest.mark.sphinx( + 'latex', testroot='toctree-maxdepth', + confoverrides={'root_doc': 'qux'}) +def test_toctree_with_deeper_maxdepth(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert '\\setcounter{tocdepth}{3}' in result + assert '\\setcounter{secnumdepth}{3}' in result + + +@pytest.mark.sphinx( + 'latex', testroot='toctree-maxdepth', + confoverrides={'latex_toplevel_sectioning': None}) +def test_latex_toplevel_sectioning_is_None(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert '\\chapter{Foo}' in result + + +@pytest.mark.sphinx( + 'latex', testroot='toctree-maxdepth', + confoverrides={'latex_toplevel_sectioning': 'part'}) +def test_latex_toplevel_sectioning_is_part(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert '\\part{Foo}' in result + assert '\\chapter{Foo A}' in result + assert '\\chapter{Foo B}' in result + + +@pytest.mark.sphinx( + 'latex', testroot='toctree-maxdepth', + confoverrides={'latex_toplevel_sectioning': 'part', + 'latex_documents': [ + ('index', 'python.tex', 'Sphinx Tests Documentation', + 'Georg Brandl', 'howto'), + ]}) +def test_latex_toplevel_sectioning_is_part_with_howto(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert '\\part{Foo}' in result + assert '\\section{Foo A}' in result + assert '\\section{Foo B}' in result + + +@pytest.mark.sphinx( + 'latex', testroot='toctree-maxdepth', + confoverrides={'latex_toplevel_sectioning': 'chapter'}) +def test_latex_toplevel_sectioning_is_chapter(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert '\\chapter{Foo}' in result + + +@pytest.mark.sphinx( + 'latex', testroot='toctree-maxdepth', + confoverrides={'latex_toplevel_sectioning': 'chapter', + 'latex_documents': [ + ('index', 'python.tex', 'Sphinx Tests Documentation', + 'Georg Brandl', 'howto'), + ]}) +def test_latex_toplevel_sectioning_is_chapter_with_howto(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert '\\section{Foo}' in result + + +@pytest.mark.sphinx( + 'latex', testroot='toctree-maxdepth', + confoverrides={'latex_toplevel_sectioning': 'section'}) +def test_latex_toplevel_sectioning_is_section(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + assert '\\section{Foo}' in result + + +@skip_if_stylefiles_notfound +@pytest.mark.sphinx('latex', testroot='maxlistdepth') +def test_maxlistdepth_at_ten(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + print(status.getvalue()) + print(warning.getvalue()) + compile_latex_document(app, 'python.tex') + + +@pytest.mark.sphinx('latex', testroot='latex-table', + confoverrides={'latex_table_style': []}) +@pytest.mark.test_params(shared_result='latex-table') +def test_latex_table_tabulars(app, status, warning): + 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:]: + sectname, content = chap.split('}', 1) + content = re.sub(r'\\sphinxstepscope', '', content) # filter a separator + tables[sectname] = content.strip() + + def get_expected(name): + return (app.srcdir / 'expects' / (name + '.tex')).read_text(encoding='utf8').strip() + + # simple_table + actual = tables['simple table'] + expected = get_expected('simple_table') + assert actual == expected + + # table having :widths: option + actual = tables['table having :widths: option'] + expected = get_expected('table_having_widths') + assert actual == expected + + # table having :align: option (tabulary) + actual = tables['table having :align: option (tabulary)'] + expected = get_expected('tabulary_having_widths') + assert actual == expected + + # table having :align: option (tabular) + actual = tables['table having :align: option (tabular)'] + expected = get_expected('tabular_having_widths') + assert actual == expected + + # table with tabularcolumn + actual = tables['table with tabularcolumn'] + expected = get_expected('tabularcolumn') + assert actual == expected + + # table with cell in first column having three paragraphs + actual = tables['table with cell in first column having three paragraphs'] + expected = get_expected('table_having_threeparagraphs_cell_in_first_col') + assert actual == expected + + # table having caption + actual = tables['table having caption'] + expected = get_expected('table_having_caption') + assert actual == expected + + # table having verbatim + actual = tables['table having verbatim'] + expected = get_expected('table_having_verbatim') + assert actual == expected + + # table having problematic cell + actual = tables['table having problematic cell'] + expected = get_expected('table_having_problematic_cell') + assert actual == expected + + # table having both :widths: and problematic cell + actual = tables['table having both :widths: and problematic cell'] + expected = get_expected('table_having_widths_and_problematic_cell') + assert actual == expected + + # table having both stub columns and problematic cell + actual = tables['table having both stub columns and problematic cell'] + expected = get_expected('table_having_stub_columns_and_problematic_cell') + assert actual == expected + + +@pytest.mark.sphinx('latex', testroot='latex-table', + confoverrides={'latex_table_style': []}) +@pytest.mark.test_params(shared_result='latex-table') +def test_latex_table_longtable(app, status, warning): + 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:]: + sectname, content = chap.split('}', 1) + content = re.sub(r'\\sphinxstepscope', '', content) # filter a separator + tables[sectname] = content.strip() + + def get_expected(name): + return (app.srcdir / 'expects' / (name + '.tex')).read_text(encoding='utf8').strip() + + # longtable + actual = tables['longtable'] + expected = get_expected('longtable') + assert actual == expected + + # longtable having :widths: option + actual = tables['longtable having :widths: option'] + expected = get_expected('longtable_having_widths') + assert actual == expected + + # longtable having :align: option + actual = tables['longtable having :align: option'] + expected = get_expected('longtable_having_align') + assert actual == expected + + # longtable with tabularcolumn + actual = tables['longtable with tabularcolumn'] + expected = get_expected('longtable_with_tabularcolumn') + assert actual == expected + + # longtable having caption + actual = tables['longtable having caption'] + expected = get_expected('longtable_having_caption') + assert actual == expected + + # longtable having verbatim + actual = tables['longtable having verbatim'] + expected = get_expected('longtable_having_verbatim') + assert actual == expected + + # longtable having problematic cell + actual = tables['longtable having problematic cell'] + expected = get_expected('longtable_having_problematic_cell') + assert actual == expected + + # longtable having both :widths: and problematic cell + actual = tables['longtable having both :widths: and problematic cell'] + expected = get_expected('longtable_having_widths_and_problematic_cell') + assert actual == expected + + # longtable having both stub columns and problematic cell + actual = tables['longtable having both stub columns and problematic cell'] + expected = get_expected('longtable_having_stub_columns_and_problematic_cell') + assert actual == expected + + +@pytest.mark.sphinx('latex', testroot='latex-table', + confoverrides={'latex_table_style': []}) +@pytest.mark.test_params(shared_result='latex-table') +def test_latex_table_complex_tables(app, status, warning): + 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:]: + sectname, content = chap.split('}', 1) + tables[sectname] = content.strip() + + def get_expected(name): + return (app.srcdir / 'expects' / (name + '.tex')).read_text(encoding='utf8').strip() + + # grid table + actual = tables['grid table'] + expected = get_expected('gridtable') + assert actual == expected + + # grid table with tabularcolumns + # MEMO: filename should end with tabularcolumns but tabularcolumn has been + # used in existing other cases + actual = tables['grid table with tabularcolumns having no vline'] + expected = get_expected('gridtable_with_tabularcolumn') + assert actual == expected + + # complex spanning cell + actual = tables['complex spanning cell'] + expected = get_expected('complex_spanning_cell') + assert actual == expected + + +@pytest.mark.sphinx('latex', testroot='latex-table') +def test_latex_table_with_booktabs_and_colorrows(app, status, warning): + 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 + # tabularcolumns + assert r'\begin{longtable}{|c|c|}' in result + # class: standard + assert r'\begin{tabulary}{\linewidth}[t]{|T|T|T|T|T|}' in result + assert r'\begin{longtable}{ll}' in result + assert r'\begin{tabular}[t]{*{2}{\X{1}{2}}}' in result + assert r'\begin{tabular}[t]{\X{30}{100}\X{70}{100}}' in result + + +@pytest.mark.sphinx('latex', testroot='latex-table', + confoverrides={'templates_path': ['_mytemplates/latex']}) +def test_latex_table_custom_template_caseA(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + assert 'SALUT LES COPAINS' in result + + +@pytest.mark.sphinx('latex', testroot='latex-table', + confoverrides={'templates_path': ['_mytemplates']}) +def test_latex_table_custom_template_caseB(app, status, warning): + 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='latex-table') +@pytest.mark.test_params(shared_result='latex-table') +def test_latex_table_custom_template_caseC(app, status, warning): + 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.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + + # standard case + assert 'standalone raw directive (HTML)' not in result + assert ('\\label{\\detokenize{index:id1}}\n' + 'standalone raw directive (LaTeX)' in result) + + # with substitution + assert 'HTML: abc ghi' in result + assert 'LaTeX: abc def ghi' in result + + +@pytest.mark.sphinx('latex', testroot='images') +def test_latex_images(app, status, warning): + 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{{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: ' + 'http://localhost:7777/NOT_EXIST.PNG [404]' in warning.getvalue()) + + # an image having target + assert ('\\sphinxhref{https://www.sphinx-doc.org/}' + '{\\sphinxincludegraphics{{rimg}.png}}\n\n' in result) + + # a centerized image having target + assert ('\\sphinxhref{https://www.python.org/}{{\\hspace*{\\fill}' + '\\sphinxincludegraphics{{rimg}.png}\\hspace*{\\fill}}}\n\n' in result) + + +@pytest.mark.sphinx('latex', testroot='latex-index') +def test_latex_index(app, status, warning): + app.build(force_all=True) + + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + assert ('A \\index{famous@\\spxentry{famous}}famous ' + '\\index{equation@\\spxentry{equation}}equation:\n' in result) + assert ('\n\\index{Einstein@\\spxentry{Einstein}}' + '\\index{relativity@\\spxentry{relativity}}' + '\\ignorespaces \n\\sphinxAtStartPar\nand') in result + assert ('\n\\index{main \\sphinxleftcurlybrace{}@\\spxentry{' + 'main \\sphinxleftcurlybrace{}}}\\ignorespaces ' in result) + + +@pytest.mark.sphinx('latex', testroot='latex-equations') +def test_latex_equations(app, status, warning): + 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() + + assert expected in result + + +@pytest.mark.sphinx('latex', testroot='image-in-parsed-literal') +def test_latex_image_in_parsed_literal(app, status, warning): + app.build(force_all=True) + + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + assert ('{\\sphinxunactivateextrasandspace \\raisebox{-0.5\\height}' + '{\\sphinxincludegraphics[height=2.00000cm]{{pic}.png}}' + '}AFTER') in result + + +@pytest.mark.sphinx('latex', testroot='nested-enumerated-list') +def test_latex_nested_enumerated_list(app, status, warning): + app.build(force_all=True) + + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + assert ('\\sphinxsetlistlabels{\\arabic}{enumi}{enumii}{}{.}%\n' + '\\setcounter{enumi}{4}\n' in result) + assert ('\\sphinxsetlistlabels{\\alph}{enumii}{enumiii}{}{.}%\n' + '\\setcounter{enumii}{3}\n' in result) + assert ('\\sphinxsetlistlabels{\\arabic}{enumiii}{enumiv}{}{)}%\n' + '\\setcounter{enumiii}{9}\n' in result) + assert ('\\sphinxsetlistlabels{\\arabic}{enumiv}{enumv}{(}{)}%\n' + '\\setcounter{enumiv}{23}\n' in result) + assert ('\\sphinxsetlistlabels{\\roman}{enumii}{enumiii}{}{.}%\n' + '\\setcounter{enumii}{2}\n' in result) + + +@pytest.mark.sphinx('latex', testroot='footnotes') +def test_latex_thebibliography(app, status, warning): + app.build(force_all=True) + + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + assert ('\\begin{sphinxthebibliography}{AuthorYe}\n' + '\\bibitem[AuthorYear]{index:authoryear}\n\\sphinxAtStartPar\n' + 'Author, Title, Year\n' + '\\end{sphinxthebibliography}\n' in result) + assert '\\sphinxcite{index:authoryear}' in result + + +@pytest.mark.sphinx('latex', testroot='glossary') +def test_latex_glossary(app, status, warning): + app.build(force_all=True) + + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + assert (r'\sphinxlineitem{ähnlich\index{ähnlich@\spxentry{ähnlich}|spxpagem}' + r'\phantomsection' + r'\label{\detokenize{index:term-ahnlich}}}' in result) + assert (r'\sphinxlineitem{boson\index{boson@\spxentry{boson}|spxpagem}\phantomsection' + r'\label{\detokenize{index:term-boson}}}' in result) + assert (r'\sphinxlineitem{\sphinxstyleemphasis{fermion}' + r'\index{fermion@\spxentry{fermion}|spxpagem}' + r'\phantomsection' + r'\label{\detokenize{index:term-fermion}}}' in result) + assert (r'\sphinxlineitem{tauon\index{tauon@\spxentry{tauon}|spxpagem}\phantomsection' + r'\label{\detokenize{index:term-tauon}}}' + r'\sphinxlineitem{myon\index{myon@\spxentry{myon}|spxpagem}\phantomsection' + r'\label{\detokenize{index:term-myon}}}' + r'\sphinxlineitem{electron\index{electron@\spxentry{electron}|spxpagem}\phantomsection' + r'\label{\detokenize{index:term-electron}}}' in result) + assert (r'\sphinxlineitem{über\index{über@\spxentry{über}|spxpagem}\phantomsection' + r'\label{\detokenize{index:term-uber}}}' in result) + + +@pytest.mark.sphinx('latex', testroot='latex-labels') +def test_latex_labels(app, status, warning): + app.build(force_all=True) + + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + + # figures + assert (r'\caption{labeled figure}' + r'\label{\detokenize{index:id1}}' + r'\label{\detokenize{index:figure2}}' + r'\label{\detokenize{index:figure1}}' + r'\end{figure}' in result) + assert (r'\caption{labeled figure}' + '\\label{\\detokenize{index:figure3}}\n' + '\\begin{sphinxlegend}\n\\sphinxAtStartPar\n' + 'with a legend\n\\end{sphinxlegend}\n' + r'\end{figure}' in result) + + # code-blocks + assert (r'\def\sphinxLiteralBlockLabel{' + r'\label{\detokenize{index:codeblock2}}' + r'\label{\detokenize{index:codeblock1}}}' in result) + assert (r'\def\sphinxLiteralBlockLabel{' + r'\label{\detokenize{index:codeblock3}}}' in result) + + # tables + assert (r'\sphinxcaption{table caption}' + r'\label{\detokenize{index:id2}}' + r'\label{\detokenize{index:table2}}' + r'\label{\detokenize{index:table1}}' in result) + assert (r'\sphinxcaption{table caption}' + r'\label{\detokenize{index:table3}}' in result) + + # sections + assert ('\\chapter{subsection}\n' + r'\label{\detokenize{index:subsection}}' + r'\label{\detokenize{index:section2}}' + r'\label{\detokenize{index:section1}}' in result) + assert ('\\section{subsubsection}\n' + r'\label{\detokenize{index:subsubsection}}' + r'\label{\detokenize{index:section3}}' in result) + assert ('\\subsection{otherdoc}\n' + r'\label{\detokenize{otherdoc:otherdoc}}' + r'\label{\detokenize{otherdoc::doc}}' in result) + + # Embedded standalone hyperlink reference (refs: #5948) + assert result.count(r'\label{\detokenize{index:section1}}') == 1 + + +@pytest.mark.sphinx('latex', testroot='latex-figure-in-admonition') +def test_latex_figure_in_admonition(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + assert r'\begin{figure}[H]' in result + + +def test_default_latex_documents(): + from sphinx.util import texescape + texescape.init() + config = Config({'root_doc': 'index', + 'project': 'STASI™ Documentation', + 'author': "Wolfgang Schäuble & G'Beckstein."}) + config.add('latex_engine', None, True, None) + config.add('latex_theme', 'manual', True, None) + expected = [('index', 'stasi.tex', 'STASI™ Documentation', + r"Wolfgang Schäuble \& G\textquotesingle{}Beckstein.\@{}", 'manual')] + assert default_latex_documents(config) == expected + + +@skip_if_requested +@skip_if_stylefiles_notfound +@pytest.mark.sphinx('latex', testroot='latex-includegraphics') +def test_includegraphics_oversized(app, status, warning): + app.build(force_all=True) + print(status.getvalue()) + print(warning.getvalue()) + compile_latex_document(app) + + +@pytest.mark.sphinx('latex', testroot='index_on_title') +def test_index_on_title(app, status, warning): + 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}}' + '\\index{index@\\spxentry{index}}\n' + in result) + + +@pytest.mark.sphinx('latex', testroot='latex-unicode', + confoverrides={'latex_engine': 'pdflatex'}) +def test_texescape_for_non_unicode_supported_engine(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + assert 'script small e: e' in result + assert 'double struck italic small i: i' in result + assert r'superscript: \(\sp{\text{0}}\), \(\sp{\text{1}}\)' in result + assert r'subscript: \(\sb{\text{0}}\), \(\sb{\text{1}}\)' in result + + +@pytest.mark.sphinx('latex', testroot='latex-unicode', + confoverrides={'latex_engine': 'xelatex'}) +def test_texescape_for_unicode_supported_engine(app, status, warning): + app.build(force_all=True) + result = (app.outdir / 'python.tex').read_text(encoding='utf8') + print(result) + assert 'script small e: e' in result + assert 'double struck italic small i: i' in result + assert 'superscript: ⁰, ¹' in result + assert 'subscript: ₀, ₁' in result + + +@pytest.mark.sphinx('latex', testroot='basic', + confoverrides={'latex_elements': {'extrapackages': r'\usepackage{foo}'}}) +def test_latex_elements_extrapackages(app, status, warning): + 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.build(force_all=True) + assert warning.getvalue() == '' + + +@pytest.mark.sphinx('latex', testroot='latex-container') +def test_latex_container(app, status, warning): + 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 + + +@pytest.mark.sphinx('latex', testroot='reST-code-role') +def test_latex_code_role(app): + app.build() + content = (app.outdir / 'python.tex').read_text(encoding='utf8') + + common_content = ( + r'\PYG{k}{def} ' + r'\PYG{n+nf}{foo}' + r'\PYG{p}{(}' + r'\PYG{l+m+mi}{1} ' + r'\PYG{o}{+} ' + r'\PYG{l+m+mi}{2} ' + r'\PYG{o}{+} ' + r'\PYG{k+kc}{None} ' + r'\PYG{o}{+} ' + r'\PYG{l+s+s2}{\PYGZdq{}}' + r'\PYG{l+s+s2}{abc}' + r'\PYG{l+s+s2}{\PYGZdq{}}' + r'\PYG{p}{)}' + r'\PYG{p}{:} ' + r'\PYG{k}{pass}') + assert (r'Inline \sphinxcode{\sphinxupquote{%' + '\n' + + common_content + '%\n}} code block') in content + assert (r'\begin{sphinxVerbatim}[commandchars=\\\{\}]' + + '\n' + common_content + '\n' + r'\end{sphinxVerbatim}') in content + + +@pytest.mark.sphinx('latex', testroot='images') +def test_copy_images(app, status, warning): + app.build() + + test_dir = Path(app.outdir) + images = { + image.name for image in test_dir.rglob('*') + if image.suffix in {'.gif', '.pdf', '.png', '.svg'} + } + images.discard('sphinx.png') + assert images == { + 'img.pdf', + 'rimg.png', + 'testimäge.png', + } + + +@pytest.mark.sphinx('latex', testroot='latex-labels-before-module') +def test_duplicated_labels_before_module(app, status, warning): + app.build() + content: str = (app.outdir / 'python.tex').read_text(encoding='utf8') + + def count_label(name): + text = r'\phantomsection\label{\detokenize{%s}}' % name + return content.count(text) + + pattern = r'\\phantomsection\\label\{\\detokenize\{index:label-(?:auto-)?\d+[a-z]*}}' + # labels found in the TeX output + output_labels = frozenset(match.group() for match in re.finditer(pattern, content)) + # labels that have been tested and occurring exactly once in the output + tested_labels = set() + + # iterate over the (explicit) labels in the corresponding index.rst + for rst_label_name in [ + 'label_1a', 'label_1b', 'label_2', 'label_3', + 'label_auto_1a', 'label_auto_1b', 'label_auto_2', 'label_auto_3', + ]: + tex_label_name = 'index:' + rst_label_name.replace('_', '-') + tex_label_code = r'\phantomsection\label{\detokenize{%s}}' % tex_label_name + assert content.count(tex_label_code) == 1, f'duplicated label: {tex_label_name!r}' + tested_labels.add(tex_label_code) + + # ensure that we did not forget any label to check + # and if so, report them nicely in case of failure + assert sorted(tested_labels) == sorted(output_labels) + + +@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.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? + # signature of 23 characters is too short to trigger one-param-per-line mark-up + assert ('\\pysiglinewithargsret{\\sphinxbfcode{\\sphinxupquote{hello}}}' in result) + + assert ('\\pysigwithonelineperarg{\\sphinxbfcode{\\sphinxupquote{foo}}}' in result) diff --git a/tests/test_builders/test_build_linkcheck.py b/tests/test_builders/test_build_linkcheck.py new file mode 100644 index 0000000..c8d8515 --- /dev/null +++ b/tests/test_builders/test_build_linkcheck.py @@ -0,0 +1,1040 @@ +"""Test the build process with manpage builder with the test root.""" + +from __future__ import annotations + +import json +import re +import sys +import textwrap +import time +import wsgiref.handlers +from base64 import b64encode +from http.server import BaseHTTPRequestHandler +from queue import Queue +from unittest import mock + +import docutils +import pytest +from urllib3.poolmanager import PoolManager + +import sphinx.util.http_date +from sphinx.builders.linkcheck import ( + CheckRequest, + Hyperlink, + HyperlinkAvailabilityCheckWorker, + RateLimit, + compile_linkcheck_allowed_redirects, +) +from sphinx.deprecation import RemovedInSphinx80Warning +from sphinx.util import requests +from sphinx.util.console import strip_colors + +from tests.utils import CERT_FILE, serve_application + +ts_re = re.compile(r".*\[(?P<ts>.*)\].*") + + +class DefaultsHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_HEAD(self): + if self.path[1:].rstrip() in {"", "anchor.html"}: + self.send_response(200, "OK") + self.send_header("Content-Length", "0") + self.end_headers() + else: + self.send_response(404, "Not Found") + self.send_header("Content-Length", "0") + self.end_headers() + + def do_GET(self): + if self.path[1:].rstrip() == "": + content = b"ok\n\n" + elif self.path[1:].rstrip() == "anchor.html": + doc = '<!DOCTYPE html><html><body><a id="found"></a></body></html>' + content = doc.encode("utf-8") + else: + content = b"" + + if content: + self.send_response(200, "OK") + self.send_header("Content-Length", str(len(content))) + self.end_headers() + self.wfile.write(content) + else: + self.send_response(404, "Not Found") + self.send_header("Content-Length", "0") + self.end_headers() + + +class ConnectionMeasurement: + """Measure the number of distinct host connections created during linkchecking""" + + def __init__(self): + self.connections = set() + self.urllib3_connection_from_url = PoolManager.connection_from_url + self.patcher = mock.patch.object( + target=PoolManager, + attribute='connection_from_url', + new=self._collect_connections(), + ) + + def _collect_connections(self): + def connection_collector(obj, url): + connection = self.urllib3_connection_from_url(obj, url) + self.connections.add(connection) + return connection + return connection_collector + + def __enter__(self): + self.patcher.start() + return self + + def __exit__(self, *args, **kwargs): + for connection in self.connections: + connection.close() + self.patcher.stop() + + @property + def connection_count(self): + return len(self.connections) + + +@pytest.mark.sphinx('linkcheck', testroot='linkcheck', freshenv=True) +def test_defaults(app): + with serve_application(app, DefaultsHandler) as address: + with ConnectionMeasurement() as m: + app.build() + assert m.connection_count <= 5 + + # Text output + assert (app.outdir / 'output.txt').exists() + content = (app.outdir / 'output.txt').read_text(encoding='utf8') + + # looking for '#top' and '#does-not-exist' not found should fail + assert "Anchor 'top' not found" in content + assert "Anchor 'does-not-exist' not found" in content + # images 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 + + # JSON output + assert (app.outdir / 'output.json').exists() + content = (app.outdir / 'output.json').read_text(encoding='utf8') + + rows = [json.loads(x) for x in content.splitlines()] + row = rows[0] + for attr in ("filename", "lineno", "status", "code", "uri", "info"): + assert attr in row + + assert len(content.splitlines()) == 10 + assert len(rows) == 10 + # the output order of the rows is not stable + # due to possible variance in network latency + rowsby = {row["uri"]: row for row in rows} + # 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': f'http://{address}#!bar', + 'info': '', + } + + 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[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 f"Not Found for url: http://{address}/image.png" in rowsby[f"http://{address}/image.png"]["info"] + # anchor should be found + assert rowsby[f'http://{address}/anchor.html#found'] == { + 'filename': 'links.rst', + 'lineno': 14, + 'status': 'working', + 'code': 0, + 'uri': f'http://{address}/anchor.html#found', + 'info': '', + } + + +@pytest.mark.sphinx( + 'linkcheck', testroot='linkcheck', freshenv=True, + confoverrides={'linkcheck_anchors': False}) +def test_check_link_response_only(app): + with serve_application(app, DefaultsHandler) as address: + app.build() + + # JSON output + assert (app.outdir / 'output.json').exists() + content = (app.outdir / 'output.json').read_text(encoding='utf8') + + rows = [json.loads(x) for x in content.splitlines()] + rowsby = {row["uri"]: row for row in rows} + 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 serve_application(app, DefaultsHandler) as address: + app.build() + + # Text output + assert (app.outdir / 'output.txt').exists() + content = (app.outdir / 'output.txt').read_text(encoding='utf8') + + # looking for non-existent URL should fail + assert " Max retries exceeded with url: /doesnotexist" in content + + # JSON output + assert (app.outdir / 'output.json').exists() + content = (app.outdir / 'output.json').read_text(encoding='utf8') + + assert len(content.splitlines()) == 1 + row = json.loads(content) + # the output order of the rows is not stable + # due to possible variance in network latency + + # looking for non-existent URL should fail + assert row['filename'] == 'index.rst' + assert row['lineno'] == 1 + assert row['status'] == 'broken' + assert row['code'] == 0 + assert row['uri'] == f'https://{address}/doesnotexist' + + +@pytest.mark.sphinx('linkcheck', testroot='linkcheck-raw-node', freshenv=True) +def test_raw_node(app): + 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 + assert (app.outdir / 'output.json').exists() + content = (app.outdir / 'output.json').read_text(encoding='utf8') + + assert len(content.splitlines()) == 1 + row = json.loads(content) + + # raw nodes' url should be checked too + assert row == { + 'filename': 'index.rst', + 'lineno': 1, + 'status': 'working', + 'code': 0, + 'uri': f'http://{address}/', # the received rST contains a link to its' own URL + 'info': '', + } + + +@pytest.mark.sphinx( + 'linkcheck', testroot='linkcheck-anchors-ignore', freshenv=True, + confoverrides={'linkcheck_anchors_ignore': ["^!", "^top$"]}) +def test_anchors_ignored(app): + with serve_application(app, OKHandler): + app.build() + + assert (app.outdir / 'output.txt').exists() + content = (app.outdir / 'output.txt').read_text(encoding='utf8') + + # expect all ok when excluding #top + assert not content + + +class AnchorsIgnoreForUrlHandler(BaseHTTPRequestHandler): + def do_HEAD(self): + if self.path in {'/valid', '/ignored'}: + self.send_response(200, "OK") + else: + self.send_response(404, "Not Found") + self.end_headers() + + def do_GET(self): + self.do_HEAD() + if self.path == '/valid': + self.wfile.write(b"<h1 id='valid-anchor'>valid anchor</h1>\n") + elif self.path == '/ignored': + self.wfile.write(b"no anchor but page exists\n") + + +@pytest.mark.sphinx('linkcheck', testroot='linkcheck-anchors-ignore-for-url', freshenv=True) +def test_anchors_ignored_for_url(app): + 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() + content = (app.outdir / 'output.json').read_text(encoding='utf8') + + attrs = ('filename', 'lineno', 'status', 'code', 'uri', 'info') + data = [json.loads(x) for x in content.splitlines()] + assert len(data) == 7 + assert all(all(attr in row for attr in attrs) for row in data) + + # rows may be unsorted due to network latency or + # the order the threads are processing the links + rows = {r['uri']: {'status': r['status'], 'info': r['info']} for r in data} + + 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[f'http://{address}/ignored']['status'] == 'working' + assert rows[f'http://{address}/ignored#invalid-anchor']['status'] == 'working' + + assert rows[f'http://{address}/invalid'] == { + 'status': 'broken', + 'info': f'404 Client Error: Not Found for url: http://{address}/invalid', + } + assert rows[f'http://{address}/invalid#anchor'] == { + 'status': 'broken', + '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(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_GET(self): + self.send_error(500, "Internal Server Error") + + with serve_application(app, InternalServerErrorHandler) as address: + app.build() + content = (app.outdir / 'output.txt').read_text(encoding='utf8') + assert content == ( + f"index.rst:1: [broken] http://{address}/#anchor: " + "500 Server Error: Internal Server Error " + f"for url: http://{address}/\n" + ) + + +def custom_handler(valid_credentials=(), success_criteria=lambda _: True): + """ + Returns an HTTP request handler that authenticates the client and then determines + an appropriate HTTP response code, based on caller-provided credentials and optional + success criteria, respectively. + """ + expected_token = None + if valid_credentials: + assert len(valid_credentials) == 2, "expected a pair of strings as credentials" + expected_token = b64encode(":".join(valid_credentials).encode()).decode("utf-8") + del valid_credentials + + class CustomHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def authenticated(method): + def method_if_authenticated(self): + 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") + self.send_header("Content-Length", "0") + self.end_headers() + + return method_if_authenticated + + @authenticated + def do_HEAD(self): + self.do_GET() + + @authenticated + def do_GET(self): + if success_criteria(self): + self.send_response(200, "OK") + self.send_header("Content-Length", "0") + else: + self.send_response(400, "Bad Request") + self.send_header("Content-Length", "0") + self.end_headers() + + return CustomHandler + + +@pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver', freshenv=True) +def test_auth_header_uses_first_match(app): + 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: + content = json.load(fp) + + assert content["status"] == "working" + + +@pytest.mark.filterwarnings('ignore::sphinx.deprecation.RemovedInSphinx80Warning') +@pytest.mark.sphinx( + 'linkcheck', testroot='linkcheck-localserver', freshenv=True, + 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) + + assert content["info"] == "unauthorized" + assert content["status"] == "broken" + + +@pytest.mark.sphinx( + 'linkcheck', testroot='linkcheck-localserver', freshenv=True, + 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 + return self.headers["Accept"] == "text/html" + + 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: + content = json.load(fp) + + assert content["status"] == "working" + + +@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 + return self.headers["Accept"] == "application/json" + + 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: + content = json.load(fp) + + assert content["status"] == "working" + + +@pytest.mark.sphinx( + 'linkcheck', testroot='linkcheck-localserver', freshenv=True, + confoverrides={'linkcheck_request_headers': { + "http://do.not.match.org": {"Accept": "application/json"}, + "*": {"X-Secret": "open sesami"}, + }}) +def test_linkcheck_request_headers_default(app): + def check_headers(self): + if self.headers["X-Secret"] != "open sesami": + return False + return self.headers["Accept"] != "application/json" + + with serve_application(app, custom_handler(success_criteria=check_headers)): + app.build() + + with open(app.outdir / "output.json", encoding="utf-8") as fp: + content = json.load(fp) + + assert content["status"] == "working" + + +def make_redirect_handler(*, support_head): + class RedirectOnceHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_HEAD(self): + if support_head: + self.do_GET() + else: + self.send_response(405, "Method Not Allowed") + self.send_header("Content-Length", "0") + self.end_headers() + + def do_GET(self): + if self.path == "/?redirected=1": + self.send_response(204, "No content") + else: + self.send_response(302, "Found") + self.send_header("Location", "/?redirected=1") + self.send_header("Content-Length", "0") + self.end_headers() + + def log_date_time_string(self): + """Strip date and time from logged messages for assertions.""" + return "" + + return RedirectOnceHandler + + +@pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver', freshenv=True) +def test_follows_redirects_on_HEAD(app, capsys, warning): + 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] " + f"http://{address}/ to http://{address}/?redirected=1\n" + ) + assert stderr == textwrap.dedent( + """\ + 127.0.0.1 - - [] "HEAD / HTTP/1.1" 302 - + 127.0.0.1 - - [] "HEAD /?redirected=1 HTTP/1.1" 204 - + """, + ) + assert warning.getvalue() == '' + + +@pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver', freshenv=True) +def test_follows_redirects_on_GET(app, capsys, warning): + 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] " + f"http://{address}/ to http://{address}/?redirected=1\n" + ) + assert stderr == textwrap.dedent( + """\ + 127.0.0.1 - - [] "HEAD / HTTP/1.1" 405 - + 127.0.0.1 - - [] "GET / HTTP/1.1" 302 - + 127.0.0.1 - - [] "GET /?redirected=1 HTTP/1.1" 204 - + """, + ) + assert warning.getvalue() == '' + + +@pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver-warn-redirects') +def test_linkcheck_allowed_redirects(app, warning): + 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] + + assert len(rows) == 2 + records = {row["uri"]: row for row in rows} + assert records[f"http://{address}/path1"]["status"] == "working" + assert records[f"http://{address}/path2"] == { + 'filename': 'index.rst', + 'lineno': 3, + 'status': 'redirected', + 'code': 302, + 'uri': f'http://{address}/path2', + 'info': f'http://{address}/?redirected=1', + } + + 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(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_HEAD(self): + self.send_response(200, "OK") + self.send_header("Content-Length", "0") + self.end_headers() + + def do_GET(self): + content = b"ok\n" + self.send_response(200, "OK") + self.send_header("Content-Length", str(len(content))) + self.end_headers() + self.wfile.write(content) + + +@mock.patch("sphinx.builders.linkcheck.requests.get", wraps=requests.get) +@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 serve_application(app, OKHandler) as address: + app.build() + assert not get_request.called + + with open(app.outdir / 'output.json', encoding='utf-8') as fp: + content = json.load(fp) + assert content["status"] == "broken" + assert content["filename"] == "index.rst" + assert content["lineno"] == 1 + 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 serve_application(app, OKHandler, tls_enabled=True) as address: + app.build() + + with open(app.outdir / 'output.json', encoding='utf-8') as fp: + content = json.load(fp) + assert content["status"] == "broken" + assert content["filename"] == "index.rst" + assert content["lineno"] == 1 + 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 serve_application(app, OKHandler, tls_enabled=True) as address: + app.build() + + with open(app.outdir / 'output.json', encoding='utf-8') as fp: + content = json.load(fp) + assert content == { + "code": 0, + "status": "working", + "filename": "index.rst", + "lineno": 1, + "uri": f'https://{address}/', + "info": "", + } + + +@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 serve_application(app, OKHandler, tls_enabled=True) as address: + app.build() + + with open(app.outdir / 'output.json', encoding='utf-8') as fp: + content = json.load(fp) + assert content == { + "code": 0, + "status": "working", + "filename": "index.rst", + "lineno": 1, + "uri": f'https://{address}/', + "info": "", + } + + +@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 serve_application(app, OKHandler, tls_enabled=True) as address: + app.build() + + with open(app.outdir / 'output.json', encoding='utf-8') as fp: + content = json.load(fp) + assert content == { + "code": 0, + "status": "working", + "filename": "index.rst", + "lineno": 1, + "uri": f'https://{address}/', + "info": "", + } + + +@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 serve_application(app, OKHandler, tls_enabled=True) as address: + app.build() + + with open(app.outdir / 'output.json', encoding='utf-8') as fp: + content = json.load(fp) + assert content == { + "code": 0, + "status": "broken", + "filename": "index.rst", + "lineno": 1, + "uri": f'https://{address}/', + "info": "Could not find a suitable TLS CA certificate bundle, invalid path: does/not/exist", + } + + +class InfiniteRedirectOnHeadHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_HEAD(self): + self.send_response(302, "Found") + self.send_header("Location", "/") + self.send_header("Content-Length", "0") + self.end_headers() + + def do_GET(self): + content = b"ok\n" + self.send_response(200, "OK") + self.send_header("Content-Length", str(len(content))) + self.end_headers() + self.wfile.write(content) + self.close_connection = True # we don't expect the client to read this response body + + +@pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver', freshenv=True) +def test_TooManyRedirects_on_HEAD(app, monkeypatch): + import requests.sessions + + monkeypatch.setattr(requests.sessions, "DEFAULT_REDIRECT_LIMIT", 5) + + with serve_application(app, InfiniteRedirectOnHeadHandler) as address: + app.build() + + with open(app.outdir / 'output.json', encoding='utf-8') as fp: + content = json.load(fp) + assert content == { + "code": 0, + "status": "working", + "filename": "index.rst", + "lineno": 1, + "uri": f'http://{address}/', + "info": "", + } + + +def make_retry_after_handler(responses): + class RetryAfterHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_HEAD(self): + status, retry_after = responses.pop(0) + self.send_response(status) + if retry_after: + self.send_header('Retry-After', retry_after) + self.send_header("Content-Length", "0") + self.end_headers() + + def log_date_time_string(self): + """Strip date and time from logged messages for assertions.""" + return "" + + return RetryAfterHandler + + +@pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver', freshenv=True) +def test_too_many_requests_retry_after_int_delay(app, capsys, status): + 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) == { + "filename": "index.rst", + "lineno": 1, + "status": "working", + "code": 0, + "uri": f'http://{address}/', + "info": "", + } + 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( + """\ + 127.0.0.1 - - [] "HEAD / HTTP/1.1" 429 - + 127.0.0.1 - - [] "HEAD / HTTP/1.1" 200 - + """, + ) + + +@pytest.mark.parametrize('tz', [None, 'GMT', 'GMT+3', 'GMT-3']) +@pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver', freshenv=True) +def test_too_many_requests_retry_after_HTTP_date(tz, app, monkeypatch, capsys): + retry_after = wsgiref.handlers.format_date_time(time.time()) + + with monkeypatch.context() as m: + if tz is not None: + m.setenv('TZ', tz) + if sys.platform != "win32": + time.tzset() + m.setattr(sphinx.util.http_date, '_GMT_OFFSET', + float(time.localtime().tm_gmtoff)) + + 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') + assert json.loads(content) == { + "filename": "index.rst", + "lineno": 1, + "status": "working", + "code": 0, + "uri": f'http://{address}/', + "info": "", + } + _stdout, stderr = capsys.readouterr() + assert stderr == textwrap.dedent( + """\ + 127.0.0.1 - - [] "HEAD / HTTP/1.1" 429 - + 127.0.0.1 - - [] "HEAD / HTTP/1.1" 200 - + """, + ) + + +@pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver', freshenv=True) +def test_too_many_requests_retry_after_without_header(app, capsys): + 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) == { + "filename": "index.rst", + "lineno": 1, + "status": "working", + "code": 0, + "uri": f'http://{address}/', + "info": "", + } + _stdout, stderr = capsys.readouterr() + assert stderr == textwrap.dedent( + """\ + 127.0.0.1 - - [] "HEAD / HTTP/1.1" 429 - + 127.0.0.1 - - [] "HEAD / HTTP/1.1" 200 - + """, + ) + + +@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 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) == { + "filename": "index.rst", + "lineno": 1, + "status": "broken", + "code": 0, + "uri": f'http://{address}/', + "info": f"429 Client Error: Too Many Requests for url: http://{address}/", + } + + +class FakeResponse: + headers: dict[str, str] = {} + url = "http://localhost/" + + +def test_limit_rate_default_sleep(app): + worker = HyperlinkAvailabilityCheckWorker(app.config, Queue(), Queue(), {}) + with mock.patch('time.time', return_value=0.0): + next_check = worker.limit_rate(FakeResponse.url, FakeResponse.headers.get("Retry-After")) + assert next_check == 60.0 + + +def test_limit_rate_user_max_delay(app): + app.config.linkcheck_rate_limit_timeout = 0.0 + worker = HyperlinkAvailabilityCheckWorker(app.config, Queue(), Queue(), {}) + next_check = worker.limit_rate(FakeResponse.url, FakeResponse.headers.get("Retry-After")) + assert next_check is None + + +def test_limit_rate_doubles_previous_wait_time(app): + rate_limits = {"localhost": RateLimit(60.0, 0.0)} + worker = HyperlinkAvailabilityCheckWorker(app.config, Queue(), Queue(), rate_limits) + with mock.patch('time.time', return_value=0.0): + next_check = worker.limit_rate(FakeResponse.url, FakeResponse.headers.get("Retry-After")) + assert next_check == 120.0 + + +def test_limit_rate_clips_wait_time_to_max_time(app): + app.config.linkcheck_rate_limit_timeout = 90.0 + rate_limits = {"localhost": RateLimit(60.0, 0.0)} + worker = HyperlinkAvailabilityCheckWorker(app.config, Queue(), Queue(), rate_limits) + with mock.patch('time.time', return_value=0.0): + next_check = worker.limit_rate(FakeResponse.url, FakeResponse.headers.get("Retry-After")) + assert next_check == 90.0 + + +def test_limit_rate_bails_out_after_waiting_max_time(app): + app.config.linkcheck_rate_limit_timeout = 90.0 + rate_limits = {"localhost": RateLimit(90.0, 0.0)} + worker = HyperlinkAvailabilityCheckWorker(app.config, Queue(), Queue(), rate_limits) + next_check = worker.limit_rate(FakeResponse.url, FakeResponse.headers.get("Retry-After")) + assert next_check is None + + +@mock.patch('sphinx.util.requests.requests.Session.get_adapter') +def test_connection_contention(get_adapter, app, capsys): + # Create a shared, but limited-size, connection pool + import requests + get_adapter.return_value = requests.adapters.HTTPAdapter(pool_maxsize=1) + + # Set an upper-bound on socket timeouts globally + import socket + socket.setdefaulttimeout(5) + + # Create parallel consumer threads + 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( + config=app.config, + rqueue=rqueue, + wqueue=wqueue, + rate_limits={}, + ) + for _ in range(10) + ] + for thread in threads: + thread.start() + while time.time() < begin + 5 and len(checked) < link_count: + checked.append(rqueue.get(timeout=5)) + for thread in threads: + thread.join(timeout=0) + + # Ensure that all items were consumed within the time limit + _, stderr = capsys.readouterr() + assert len(checked) == link_count + assert "TimeoutError" not in stderr + + +class ConnectionResetHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_HEAD(self): + self.close_connection = True + + def do_GET(self): + self.send_response(200, "OK") + self.send_header("Content-Length", "0") + self.end_headers() + + +@pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver', freshenv=True) +def test_get_after_head_raises_connection_error(app): + with serve_application(app, ConnectionResetHandler) as address: + app.build() + content = (app.outdir / 'output.txt').read_text(encoding='utf8') + assert not content + content = (app.outdir / 'output.json').read_text(encoding='utf8') + assert json.loads(content) == { + "filename": "index.rst", + "lineno": 1, + "status": "working", + "code": 0, + "uri": f'http://{address}/', + "info": "", + } + + +@pytest.mark.sphinx('linkcheck', testroot='linkcheck-documents_exclude', freshenv=True) +def test_linkcheck_exclude_documents(app): + 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 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_builders/test_build_manpage.py b/tests/test_builders/test_build_manpage.py new file mode 100644 index 0000000..7172281 --- /dev/null +++ b/tests/test_builders/test_build_manpage.py @@ -0,0 +1,104 @@ +"""Test the build process with manpage builder with the test root.""" + +import docutils +import pytest + +from sphinx.builders.manpage import default_man_pages +from sphinx.config import Config + + +@pytest.mark.sphinx('man') +def test_all(app, status, warning): + app.build(force_all=True) + assert (app.outdir / 'sphinxtests.1').exists() + + content = (app.outdir / 'sphinxtests.1').read_text(encoding='utf8') + assert r'\fBprint \fP\fIi\fP\fB\en\fP' in content + assert r'\fBmanpage\en\fP' in content + + # heading (title + description) + assert r'sphinxtests \- Sphinx <Tests> 0.6alpha1' in content + + # term of definition list including nodes.strong + assert '\n.B term1\n' in content + assert '\nterm2 (\\fBstronged partially\\fP)\n' in content + + # test samp with braces + assert '\n\\fIvariable_only\\fP\n' in content + assert '\n\\fIvariable\\fP\\fB and text\\fP\n' in content + assert '\n\\fBShow \\fP\\fIvariable\\fP\\fB in the middle\\fP\n' in content + + assert 'Footnotes' not in content + + +@pytest.mark.sphinx('man', testroot='basic', + confoverrides={'man_pages': [('index', 'title', None, [], 1)]}) +def test_man_pages_empty_description(app, status, warning): + app.build(force_all=True) + + content = (app.outdir / 'title.1').read_text(encoding='utf8') + assert r'title \-' not in content + + +@pytest.mark.sphinx('man', testroot='basic', + confoverrides={'man_make_section_directory': True}) +def test_man_make_section_directory(app, status, warning): + app.build() + assert (app.outdir / 'man1' / 'python.1').exists() + + +@pytest.mark.sphinx('man', testroot='directive-code') +def test_captioned_code_block(app, status, warning): + app.build(force_all=True) + content = (app.outdir / 'python.1').read_text(encoding='utf8') + + if docutils.__version_info__[:2] < (0, 21): + expected = """\ +.sp +caption \\fItest\\fP rb +.INDENT 0.0 +.INDENT 3.5 +.sp +.nf +.ft C +def ruby? + false +end +.ft P +.fi +.UNINDENT +.UNINDENT +""" + else: + expected = """\ +.sp +caption \\fItest\\fP rb +.INDENT 0.0 +.INDENT 3.5 +.sp +.EX +def ruby? + false +end +.EE +.UNINDENT +.UNINDENT +""" + + assert expected in content + + +def test_default_man_pages(): + config = Config({'project': 'STASI™ Documentation', + 'author': "Wolfgang Schäuble & G'Beckstein", + 'release': '1.0'}) + expected = [('index', 'stasi', 'STASI™ Documentation 1.0', + ["Wolfgang Schäuble & G'Beckstein"], 1)] + assert default_man_pages(config) == expected + + +@pytest.mark.sphinx('man', testroot='markup-rubric') +def test_rubric(app, status, warning): + app.build() + content = (app.outdir / 'python.1').read_text(encoding='utf8') + assert 'This is a rubric\n' in content diff --git a/tests/test_builders/test_build_texinfo.py b/tests/test_builders/test_build_texinfo.py new file mode 100644 index 0000000..f9effb2 --- /dev/null +++ b/tests/test_builders/test_build_texinfo.py @@ -0,0 +1,130 @@ +"""Test the build process with Texinfo builder with the test root.""" + +import re +import subprocess +from pathlib import Path +from subprocess import CalledProcessError +from unittest.mock import Mock + +import pytest + +from sphinx.builders.texinfo import default_texinfo_documents +from sphinx.config import Config +from sphinx.util.docutils import new_document +from sphinx.writers.texinfo import TexinfoTranslator + + +@pytest.mark.sphinx('texinfo') +def test_texinfo(app, status, warning): + TexinfoTranslator.ignore_missing_images = True + 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}' + '@anchor{markup testing-various-markup}@anchor{13}' in result) + assert 'Footnotes' not in result + # now, try to run makeinfo over it + try: + args = ['makeinfo', '--no-split', 'sphinxtests.texi'] + subprocess.run(args, capture_output=True, cwd=app.outdir, check=True) + except OSError as exc: + raise pytest.skip.Exception from exc # most likely makeinfo was not found + except CalledProcessError as exc: + print(exc.stdout) + print(exc.stderr) + msg = f'makeinfo exited with return code {exc.retcode}' + raise AssertionError(msg) from exc + + +@pytest.mark.sphinx('texinfo', testroot='markup-rubric') +def test_texinfo_rubric(app, status, warning): + app.build() + + output = (app.outdir / 'python.texi').read_text(encoding='utf8') + assert '@heading This is a rubric' in output + assert '@heading This is a multiline rubric' in output + + +@pytest.mark.sphinx('texinfo', testroot='markup-citation') +def test_texinfo_citation(app, status, warning): + 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 + assert ('@anchor{index cite1}@anchor{1}@w{(CITE1)} \n' + 'This is a citation\n') in output + assert ('@anchor{index cite2}@anchor{2}@w{(CITE2)} \n' + 'This is a multiline citation\n') in output + + +def test_default_texinfo_documents(): + config = Config({'project': 'STASI™ Documentation', + 'author': "Wolfgang Schäuble & G'Beckstein"}) + expected = [('index', 'stasi', 'STASI™ Documentation', + "Wolfgang Schäuble & G'Beckstein", 'stasi', + 'One line description of project', 'Miscellaneous')] + assert default_texinfo_documents(config) == expected + + +@pytest.mark.sphinx('texinfo') +def test_texinfo_escape_id(app, status, warning): + settings = Mock(title='', + texinfo_dir_entry='', + texinfo_elements={}) + document = new_document('', settings) + translator = app.builder.create_translator(document, app.builder) + + assert translator.escape_id('Hello world') == 'Hello world' + assert translator.escape_id('Hello world') == 'Hello world' + assert translator.escape_id('Hello Sphinx world') == 'Hello Sphinx world' + assert translator.escape_id('Hello:world') == 'Hello world' + assert translator.escape_id('Hello(world)') == 'Hello world' + assert translator.escape_id('Hello world.') == 'Hello world' + assert translator.escape_id('.') == '.' + + +@pytest.mark.sphinx('texinfo', testroot='footnotes') +def test_texinfo_footnote(app, status, warning): + app.build(force_all=True) + + output = (app.outdir / 'python.texi').read_text(encoding='utf8') + assert 'First footnote: @footnote{\nFirst\n}' in output + + +@pytest.mark.sphinx('texinfo') +def test_texinfo_xrefs(app, status, warning): + 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.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 + + +@pytest.mark.sphinx('texinfo', testroot='root') +def test_texinfo_samp_with_variable(app, status, warning): + app.build() + + output = (app.outdir / 'sphinxtests.texi').read_text(encoding='utf8') + + assert '@code{@var{variable_only}}' in output + assert '@code{@var{variable} and text}' in output + assert '@code{Show @var{variable} in the middle}' in output + + +@pytest.mark.sphinx('texinfo', testroot='images') +def test_copy_images(app, status, warning): + app.build() + + images_dir = Path(app.outdir) / 'python-figures' + images = {image.name for image in images_dir.rglob('*')} + images.discard('python-logo.png') + assert images == { + 'img.png', + 'rimg.png', + 'testimäge.png', + } diff --git a/tests/test_builders/test_build_text.py b/tests/test_builders/test_build_text.py new file mode 100644 index 0000000..6dc0d03 --- /dev/null +++ b/tests/test_builders/test_build_text.py @@ -0,0 +1,278 @@ +"""Test the build process with Text builder with the test root.""" + +import pytest +from docutils.utils import column_width + +from sphinx.writers.text import MAXWIDTH, Cell, Table + + +def with_text_app(*args, **kw): + default_kw = { + 'buildername': 'text', + 'testroot': 'build-text', + } + default_kw.update(kw) + return pytest.mark.sphinx(*args, **default_kw) + + +@with_text_app() +def test_maxwitdh_with_prefix(app, status, warning): + app.build() + result = (app.outdir / 'maxwidth.txt').read_text(encoding='utf8') + + lines = result.splitlines() + line_widths = [column_width(line) for line in lines] + assert max(line_widths) < MAXWIDTH + assert lines[0].startswith('See also:') + assert lines[1].startswith('') + assert lines[2].startswith(' ham') + assert lines[3].startswith(' ham') + assert lines[4] == '' + assert lines[5].startswith('* ham') + assert lines[6].startswith(' ham') + assert lines[7] == '' + assert lines[8].startswith('* ham') + assert lines[9].startswith(' ham') + assert lines[10] == '' + assert lines[11].startswith('spam egg') + + +@with_text_app() +def test_lineblock(app, status, warning): + # regression test for #1109: need empty line after line block + app.build() + result = (app.outdir / 'lineblock.txt').read_text(encoding='utf8') + expect = ( + "* one\n" + "\n" + " line-block 1\n" + " line-block 2\n" + "\n" + "followed paragraph.\n" + ) + assert result == expect + + +@with_text_app() +def test_nonascii_title_line(app, status, warning): + app.build() + result = (app.outdir / 'nonascii_title.txt').read_text(encoding='utf8') + expect_underline = '*********' + result_underline = result.splitlines()[1].strip() + assert expect_underline == result_underline + + +@with_text_app() +def test_nonascii_table(app, status, warning): + 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] + assert len(set(line_widths)) == 1 # same widths + + +@with_text_app() +def test_nonascii_maxwidth(app, status, warning): + 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] + assert max(line_widths) < MAXWIDTH + + +def test_table_builder(): + table = Table([6, 6]) + table.add_cell(Cell("foo")) + table.add_cell(Cell("bar")) + table_str = str(table).split("\n") + assert table_str[0] == "+--------+--------+" + assert table_str[1] == "| foo | bar |" + assert table_str[2] == "+--------+--------+" + assert repr(table).count("<Cell ") == 2 + + +def test_table_separator(): + table = Table([6, 6]) + table.add_cell(Cell("foo")) + table.add_cell(Cell("bar")) + table.set_separator() + table.add_row() + table.add_cell(Cell("FOO")) + table.add_cell(Cell("BAR")) + table_str = str(table).split("\n") + assert table_str[0] == "+--------+--------+" + assert table_str[1] == "| foo | bar |" + assert table_str[2] == "|========|========|" + assert table_str[3] == "| FOO | BAR |" + assert table_str[4] == "+--------+--------+" + assert repr(table).count("<Cell ") == 4 + + +def test_table_cell(): + cell = Cell("Foo bar baz") + cell.wrap(3) + assert "Cell" in repr(cell) + assert cell.wrapped == ["Foo", "bar", "baz"] + + +@with_text_app() +def test_table_with_empty_cell(app, status, warning): + 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] == "+-------+-------+" + assert lines[1] == "| XXX | XXX |" + assert lines[2] == "+-------+-------+" + assert lines[3] == "| | XXX |" + assert lines[4] == "+-------+-------+" + assert lines[5] == "| XXX | |" + assert lines[6] == "+-------+-------+" + + +@with_text_app() +def test_table_with_rowspan(app, status, warning): + 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] == "+-------+-------+" + assert lines[1] == "| XXXXXXXXX |" + assert lines[2] == "+-------+-------+" + assert lines[3] == "| | XXX |" + assert lines[4] == "+-------+-------+" + assert lines[5] == "| XXX | |" + assert lines[6] == "+-------+-------+" + + +@with_text_app() +def test_table_with_colspan(app, status, warning): + 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] == "+-------+-------+" + assert lines[1] == "| XXX | XXX |" + assert lines[2] == "+-------+-------+" + assert lines[3] == "| | XXX |" + assert lines[4] == "+-------+ |" + assert lines[5] == "| XXX | |" + assert lines[6] == "+-------+-------+" + + +@with_text_app() +def test_table_with_colspan_left(app, status, warning): + 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] == "+-------+-------+" + assert lines[1] == "| XXX | XXX |" + assert lines[2] == "+-------+-------+" + assert lines[3] == "| XXX | XXX |" + assert lines[4] == "| +-------+" + assert lines[5] == "| | |" + assert lines[6] == "+-------+-------+" + + +@with_text_app() +def test_table_with_colspan_and_rowspan(app, status, warning): + 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 + assert lines[0] == "+-------+-------+-------+" + assert lines[1] == "| AAA | BBB |" + assert lines[2] == "+-------+-------+ |" + assert lines[3] == "| DDD | XXX | |" + assert lines[4] == "| +-------+-------+" + assert lines[5] == "| | CCC |" + assert lines[6] == "+-------+-------+-------+" + + +@with_text_app() +def test_list_items_in_admonition(app, status, warning): + app.build() + result = (app.outdir / 'listitems.txt').read_text(encoding='utf8') + lines = [line.rstrip() for line in result.splitlines()] + assert lines[0] == "See also:" + assert lines[1] == "" + assert lines[2] == " * item 1" + assert lines[3] == "" + assert lines[4] == " * item 2" + + +@with_text_app() +def test_secnums(app, status, warning): + app.build(force_all=True) + index = (app.outdir / 'index.txt').read_text(encoding='utf8') + lines = index.splitlines() + assert lines[0] == "* 1. Section A" + assert lines[1] == "" + assert lines[2] == "* 2. Section B" + assert lines[3] == "" + assert lines[4] == " * 2.1. Sub Ba" + assert lines[5] == "" + assert lines[6] == " * 2.2. Sub Bb" + doc2 = (app.outdir / 'doc2.txt').read_text(encoding='utf8') + expect = ( + "2. Section B\n" + "************\n" + "\n" + "\n" + "2.1. Sub Ba\n" + "===========\n" + "\n" + "\n" + "2.2. Sub Bb\n" + "===========\n" + ) + assert doc2 == expect + + app.config.text_secnumber_suffix = " " + app.build(force_all=True) + index = (app.outdir / 'index.txt').read_text(encoding='utf8') + lines = index.splitlines() + assert lines[0] == "* 1 Section A" + assert lines[1] == "" + assert lines[2] == "* 2 Section B" + assert lines[3] == "" + assert lines[4] == " * 2.1 Sub Ba" + assert lines[5] == "" + assert lines[6] == " * 2.2 Sub Bb" + doc2 = (app.outdir / 'doc2.txt').read_text(encoding='utf8') + expect = ( + "2 Section B\n" + "***********\n" + "\n" + "\n" + "2.1 Sub Ba\n" + "==========\n" + "\n" + "\n" + "2.2 Sub Bb\n" + "==========\n" + ) + assert doc2 == expect + + app.config.text_add_secnumbers = False + app.build(force_all=True) + index = (app.outdir / 'index.txt').read_text(encoding='utf8') + lines = index.splitlines() + assert lines[0] == "* Section A" + assert lines[1] == "" + assert lines[2] == "* Section B" + assert lines[3] == "" + assert lines[4] == " * Sub Ba" + assert lines[5] == "" + assert lines[6] == " * Sub Bb" + doc2 = (app.outdir / 'doc2.txt').read_text(encoding='utf8') + expect = ( + "Section B\n" + "*********\n" + "\n" + "\n" + "Sub Ba\n" + "======\n" + "\n" + "\n" + "Sub Bb\n" + "======\n" + ) + assert doc2 == expect 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_builders/test_builder.py b/tests/test_builders/test_builder.py new file mode 100644 index 0000000..ee946a5 --- /dev/null +++ b/tests/test_builders/test_builder.py @@ -0,0 +1,44 @@ +"""Test the Builder class.""" + +import sys + +import pytest + + +@pytest.mark.sphinx('dummy', srcdir="test_builder", freshenv=True) +def test_incremental_reading(app): + # first reading + updated = app.builder.read() + assert set(updated) == app.env.found_docs == set(app.env.all_docs) + assert updated == sorted(updated) # sorted by alphanumeric + + # test if exclude_patterns works ok + assert 'subdir/excluded' not in app.env.found_docs + + # before second reading, add, modify and remove source files + (app.srcdir / 'new.txt').write_text('New file\n========\n', encoding='utf8') + app.env.all_docs['index'] = 0 # mark as modified + (app.srcdir / 'autodoc.txt').unlink() + + # second reading + updated = app.builder.read() + + assert set(updated) == {'index', 'new'} + assert 'autodoc' not in app.env.all_docs + assert 'autodoc' not in app.env.found_docs + + +@pytest.mark.sphinx('dummy', testroot='warnings', freshenv=True) +def test_incremental_reading_for_missing_files(app): + # first reading + updated = app.builder.read() + assert set(updated) == app.env.found_docs == set(app.env.all_docs) + + # second reading + updated = app.builder.read() + + # "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) |