"""Sphinx test suite utilities"""
from __future__ import annotations
__all__ = ('SphinxTestApp', 'SphinxTestAppWrapperForSkipBuilding')
import sys
from io import StringIO
from types import MappingProxyType
from typing import TYPE_CHECKING
from docutils import nodes
from docutils.parsers.rst import directives, roles
import sphinx.application
import sphinx.locale
import sphinx.pycode
from sphinx.util.docutils import additional_nodes
if TYPE_CHECKING:
import os
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any
from xml.etree.ElementTree import ElementTree
from docutils.nodes import Node
def assert_node(node: Node, cls: Any = None, xpath: str = '', **kwargs: Any) -> None:
if cls:
if isinstance(cls, list):
assert_node(node, cls[0], xpath=xpath, **kwargs)
if cls[1:]:
if isinstance(cls[1], tuple):
assert_node(node, cls[1], xpath=xpath, **kwargs)
else:
assert (
isinstance(node, nodes.Element)
), f'The node{xpath} does not have any children' # fmt: skip
assert len(node) == 1, (
f'The node{xpath} has {len(node)} child nodes, not one'
)
assert_node(node[0], cls[1:], xpath=xpath + '[0]', **kwargs)
elif isinstance(cls, tuple):
assert (
isinstance(node, (list, nodes.Element))
), f'The node{xpath} does not have any items' # fmt: skip
assert (
len(node) == len(cls)
), f'The node{xpath} has {len(node)} child nodes, not {len(cls)!r}' # fmt: skip
for i, nodecls in enumerate(cls):
path = xpath + f'[{i}]'
assert_node(node[i], nodecls, xpath=path, **kwargs)
elif isinstance(cls, str):
assert node == cls, f'The node {xpath!r} is not {cls!r}: {node!r}'
else:
assert (
isinstance(node, cls)
), f'The node{xpath} is not subclass of {cls!r}: {node!r}' # fmt: skip
if kwargs:
assert (
isinstance(node, nodes.Element)
), f'The node{xpath} does not have any attributes' # fmt: skip
for key, value in kwargs.items():
if key not in node:
if (key := key.replace('_', '-')) not in node:
msg = f'The node{xpath} does not have {key!r} attribute: {node!r}'
raise AssertionError(msg)
assert node[key] == value, (
f'The node{xpath}[{key}] is not {value!r}: {node[key]!r}'
)
# keep this to restrict the API usage and to have a correct return type
def etree_parse(path: str | os.PathLike[str]) -> ElementTree:
"""Parse a file into a (safe) XML element tree."""
from defusedxml.ElementTree import parse as xml_parse
return xml_parse(path)
[docs]
class SphinxTestApp(sphinx.application.Sphinx):
"""A subclass of :class:`~sphinx.application.Sphinx` for tests.
The constructor uses some better default values for the initialization
parameters and supports arbitrary keywords stored in the :attr:`extras`
read-only mapping.
It is recommended to use::
@pytest.mark.sphinx('html', testroot='root')
def test(app):
app = ...
instead of::
def test():
app = SphinxTestApp('html', srcdir=srcdir)
In the former case, the 'app' fixture takes care of setting the source
directory, whereas in the latter, the user must provide it themselves.
"""
# see https://github.com/sphinx-doc/sphinx/pull/12089 for the
# discussion on how the signature of this class should be used
def __init__(
self,
/, # to allow 'self' as an extras
buildername: str = 'html',
srcdir: Path | None = None,
builddir: Path | None = None, # extra constructor argument
freshenv: bool = False, # argument is not in the same order as in the superclass
confoverrides: dict[str, Any] | None = None,
status: StringIO | None = None,
warning: StringIO | None = None,
tags: Sequence[str] = (),
docutils_conf: str | None = None, # extra constructor argument
parallel: int = 0,
# additional arguments at the end to keep the signature
verbosity: int = 0, # argument is not in the same order as in the superclass
warningiserror: bool = False, # argument is not in the same order as in the superclass
pdb: bool = False,
exception_on_warning: bool = False,
# unknown keyword arguments
**extras: Any,
) -> None:
self._builder_name = buildername
assert srcdir is not None
if verbosity == -1:
quiet = True
verbosity = 0
else:
quiet = False
if status is None:
# ensure that :attr:`status` is a StringIO and not sys.stdout
# but allow the stream to be /dev/null by passing verbosity=-1
status = None if quiet else StringIO()
elif not isinstance(status, StringIO):
err = f"'status' must be an io.StringIO object, got: {type(status)}"
raise TypeError(err)
if warning is None:
# ensure that :attr:`warning` is a StringIO and not sys.stderr
# but allow the stream to be /dev/null by passing verbosity=-1
warning = None if quiet else StringIO()
elif not isinstance(warning, StringIO):
err = f"'warning' must be an io.StringIO object, got: {type(warning)}"
raise TypeError(err)
self.docutils_conf_path = srcdir / 'docutils.conf'
if docutils_conf is not None:
self.docutils_conf_path.write_text(docutils_conf, encoding='utf8')
if builddir is None:
builddir = srcdir / '_build'
confdir = srcdir
outdir = builddir.joinpath(buildername)
outdir.mkdir(parents=True, exist_ok=True)
doctreedir = builddir.joinpath('doctrees')
doctreedir.mkdir(parents=True, exist_ok=True)
if confoverrides is None:
confoverrides = {}
self._saved_path = sys.path.copy()
self.extras: Mapping[str, Any] = MappingProxyType(extras)
"""Extras keyword arguments."""
try:
super().__init__(
srcdir,
confdir,
outdir,
doctreedir,
buildername,
confoverrides=confoverrides,
status=status,
warning=warning,
freshenv=freshenv,
warningiserror=warningiserror,
tags=tags,
verbosity=verbosity,
parallel=parallel,
pdb=pdb,
exception_on_warning=exception_on_warning,
)
except Exception:
self.cleanup()
raise
def _init_builder(self) -> None:
# override the default theme to 'basic' rather than 'alabaster'
# for test independence
if 'html_theme' in self.config._overrides:
pass # respect overrides
elif 'html_theme' in self.config and self.config.html_theme == 'alabaster':
self.config.html_theme = self.config._overrides.get('html_theme', 'basic')
super()._init_builder()
@property
def status(self) -> StringIO:
"""The in-memory text I/O for the application status messages."""
# sphinx.application.Sphinx uses StringIO for a quiet stream
assert isinstance(self._status, StringIO)
return self._status
@property
def warning(self) -> StringIO:
"""The in-memory text I/O for the application warning messages."""
# sphinx.application.Sphinx uses StringIO for a quiet stream
assert isinstance(self._warning, StringIO)
return self._warning
def cleanup(self, doctrees: bool = False) -> None:
sys.path[:] = self._saved_path
_clean_up_global_state()
try:
self.docutils_conf_path.unlink(missing_ok=True)
except OSError as exc:
if exc.errno != 30: # Ignore "read-only file system" errors
raise
def __repr__(self) -> str:
return f'<{self.__class__.__name__} buildername={self._builder_name!r}>'
def build(self, force_all: bool = False, filenames: Sequence[Path] = ()) -> None:
self.env._pickled_doctree_cache.clear()
super().build(force_all, filenames)
[docs]
class SphinxTestAppWrapperForSkipBuilding(SphinxTestApp):
"""A wrapper for SphinxTestApp.
This class is used to speed up the test by skipping ``app.build()``
if it has already been built and there are any output files.
"""
def build(self, force_all: bool = False, filenames: Sequence[Path] = ()) -> None:
if not list(self.outdir.iterdir()):
# if listdir is empty, do build.
super().build(force_all, filenames)
# otherwise, we can use built cache
def _clean_up_global_state() -> None:
# clean up Docutils global state
directives._directives.clear() # type: ignore[attr-defined]
roles._roles.clear() # type: ignore[attr-defined]
for node in additional_nodes:
delattr(nodes.GenericNodeVisitor, f'visit_{node.__name__}')
delattr(nodes.GenericNodeVisitor, f'depart_{node.__name__}')
delattr(nodes.SparseNodeVisitor, f'visit_{node.__name__}')
delattr(nodes.SparseNodeVisitor, f'depart_{node.__name__}')
additional_nodes.clear()
# clean up Sphinx global state
sphinx.locale.translators.clear()
# clean up autodoc global state
sphinx.pycode.ModuleAnalyzer.cache.clear()