summaryrefslogtreecommitdiffstats
path: root/tests/test_builders
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-06-05 16:20:58 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-06-05 16:20:58 +0000
commit5bb0bb4be543fd5eca41673696a62ed80d493591 (patch)
treead2c464f140e86c7f178a6276d7ea4a93e3e6c92 /tests/test_builders
parentAdding upstream version 7.2.6. (diff)
downloadsphinx-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')
-rw-r--r--tests/test_builders/__init__.py0
-rw-r--r--tests/test_builders/conftest.py28
-rw-r--r--tests/test_builders/test_build.py165
-rw-r--r--tests/test_builders/test_build_changes.py34
-rw-r--r--tests/test_builders/test_build_dirhtml.py40
-rw-r--r--tests/test_builders/test_build_epub.py417
-rw-r--r--tests/test_builders/test_build_gettext.py235
-rw-r--r--tests/test_builders/test_build_html.py378
-rw-r--r--tests/test_builders/test_build_html_5_output.py276
-rw-r--r--tests/test_builders/test_build_html_assets.py152
-rw-r--r--tests/test_builders/test_build_html_code.py46
-rw-r--r--tests/test_builders/test_build_html_download.py62
-rw-r--r--tests/test_builders/test_build_html_highlight.py61
-rw-r--r--tests/test_builders/test_build_html_image.py80
-rw-r--r--tests/test_builders/test_build_html_maths.py58
-rw-r--r--tests/test_builders/test_build_html_numfig.py487
-rw-r--r--tests/test_builders/test_build_html_tocdepth.py94
-rw-r--r--tests/test_builders/test_build_latex.py1759
-rw-r--r--tests/test_builders/test_build_linkcheck.py1040
-rw-r--r--tests/test_builders/test_build_manpage.py104
-rw-r--r--tests/test_builders/test_build_texinfo.py130
-rw-r--r--tests/test_builders/test_build_text.py278
-rw-r--r--tests/test_builders/test_build_warnings.py89
-rw-r--r--tests/test_builders/test_builder.py44
-rw-r--r--tests/test_builders/xpath_data.py8
-rw-r--r--tests/test_builders/xpath_util.py79
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">&#x2192;</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">&quot;abc&quot;</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)