From 5bb0bb4be543fd5eca41673696a62ed80d493591 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Wed, 5 Jun 2024 18:20:58 +0200 Subject: Adding upstream version 7.3.7. Signed-off-by: Daniel Baumann --- tests/test_builders/__init__.py | 0 tests/test_builders/conftest.py | 28 + tests/test_builders/test_build.py | 165 ++ tests/test_builders/test_build_changes.py | 34 + tests/test_builders/test_build_dirhtml.py | 40 + tests/test_builders/test_build_epub.py | 417 +++++ tests/test_builders/test_build_gettext.py | 235 +++ tests/test_builders/test_build_html.py | 378 +++++ tests/test_builders/test_build_html_5_output.py | 276 ++++ tests/test_builders/test_build_html_assets.py | 152 ++ tests/test_builders/test_build_html_code.py | 46 + tests/test_builders/test_build_html_download.py | 62 + tests/test_builders/test_build_html_highlight.py | 61 + tests/test_builders/test_build_html_image.py | 80 + tests/test_builders/test_build_html_maths.py | 58 + tests/test_builders/test_build_html_numfig.py | 487 ++++++ tests/test_builders/test_build_html_tocdepth.py | 94 ++ tests/test_builders/test_build_latex.py | 1759 ++++++++++++++++++++++ tests/test_builders/test_build_linkcheck.py | 1040 +++++++++++++ tests/test_builders/test_build_manpage.py | 104 ++ tests/test_builders/test_build_texinfo.py | 130 ++ tests/test_builders/test_build_text.py | 278 ++++ tests/test_builders/test_build_warnings.py | 89 ++ tests/test_builders/test_builder.py | 44 + tests/test_builders/xpath_data.py | 8 + tests/test_builders/xpath_util.py | 79 + 26 files changed, 6144 insertions(+) create mode 100644 tests/test_builders/__init__.py create mode 100644 tests/test_builders/conftest.py create mode 100644 tests/test_builders/test_build.py create mode 100644 tests/test_builders/test_build_changes.py create mode 100644 tests/test_builders/test_build_dirhtml.py create mode 100644 tests/test_builders/test_build_epub.py create mode 100644 tests/test_builders/test_build_gettext.py create mode 100644 tests/test_builders/test_build_html.py create mode 100644 tests/test_builders/test_build_html_5_output.py create mode 100644 tests/test_builders/test_build_html_assets.py create mode 100644 tests/test_builders/test_build_html_code.py create mode 100644 tests/test_builders/test_build_html_download.py create mode 100644 tests/test_builders/test_build_html_highlight.py create mode 100644 tests/test_builders/test_build_html_image.py create mode 100644 tests/test_builders/test_build_html_maths.py create mode 100644 tests/test_builders/test_build_html_numfig.py create mode 100644 tests/test_builders/test_build_html_tocdepth.py create mode 100644 tests/test_builders/test_build_latex.py create mode 100644 tests/test_builders/test_build_linkcheck.py create mode 100644 tests/test_builders/test_build_manpage.py create mode 100644 tests/test_builders/test_build_texinfo.py create mode 100644 tests/test_builders/test_build_text.py create mode 100644 tests/test_builders/test_build_warnings.py create mode 100644 tests/test_builders/test_builder.py create mode 100644 tests/test_builders/xpath_data.py create mode 100644 tests/test_builders/xpath_util.py (limited to 'tests/test_builders') diff --git a/tests/test_builders/__init__.py b/tests/test_builders/__init__.py new file mode 100644 index 0000000..e69de29 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 = ( + 'Path: deprecated: Deprecated since version 0.6:' + ' So, that was a bad idea it turns out.') + assert path_html in htmltext + + malloc_html = ( + 'void *Test_Malloc(size_t n): changed: Changed in version 0.6:' + ' Can now be replaced with a different allocator.') + 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 "escaped" 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', '') + navchildren = navpoints[1].findall("./ncx:navPoint") + assert len(navchildren) == 4 + assert navinfo(navchildren[0]) == ('navPoint3', '2', 'foo.xhtml', '') + 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', '') + 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 ('

' + 'blah blah blah

' in html) + assert ('' + '

blah blah blah

' in html) + assert 'see ' 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 ('' + in content) + assert ('' 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 '' in content + + # files in html_css_files are not outputted + assert ('' + not in content) + assert ('' 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 ('
  • ' + 'dummy.dat

  • ' in content) + assert ('
  • ' + 'not_found.dat

  • ' in content) + assert ('
  • ' + 'Sphinx logo' + ' [https://www.sphinx-doc.org/en/master' + '/_static/sphinx-logo.svg]

  • ' 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 ('
    \n

    The caption of pic' + '

    \n
    ' + 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 '

    HTML: abc def ghi

    ' in result + assert '

    LaTeX: abc ghi

    ' 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 '' in result + assert ('' + 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 ('