| """Sphinx test suite utilities""" |
| from __future__ import annotations |
| |
| import contextlib |
| import os |
| import re |
| import sys |
| import warnings |
| from typing import IO, TYPE_CHECKING, Any |
| from xml.etree import ElementTree |
| |
| from docutils import nodes |
| from docutils.parsers.rst import directives, roles |
| |
| from sphinx import application, locale |
| from sphinx.pycode import ModuleAnalyzer |
| |
| if TYPE_CHECKING: |
| from io import StringIO |
| from pathlib import Path |
| |
| from docutils.nodes import Node |
| |
| __all__ = 'SphinxTestApp', 'SphinxTestAppWrapperForSkipBuilding' |
| |
| |
| 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), \ |
| 'The node%s does not have any children' % xpath |
| assert len(node) == 1, \ |
| 'The node%s has %d child nodes, not one' % (xpath, len(node)) |
| assert_node(node[0], cls[1:], xpath=xpath + "[0]", **kwargs) |
| elif isinstance(cls, tuple): |
| assert isinstance(node, (list, nodes.Element)), \ |
| 'The node%s does not have any items' % xpath |
| assert len(node) == len(cls), \ |
| 'The node%s has %d child nodes, not %r' % (xpath, len(node), len(cls)) |
| for i, nodecls in enumerate(cls): |
| path = xpath + "[%d]" % 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}' |
| |
| if kwargs: |
| assert isinstance(node, nodes.Element), \ |
| 'The node%s does not have any attributes' % xpath |
| |
| 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}' |
| |
| |
| def etree_parse(path: str) -> Any: |
| with warnings.catch_warnings(record=False): |
| warnings.filterwarnings("ignore", category=DeprecationWarning) |
| return ElementTree.parse(path) # NoQA: S314 # using known data in tests |
| |
| |
| class SphinxTestApp(application.Sphinx): |
| """ |
| A subclass of :class:`Sphinx` that runs on the test root, with some |
| better default values for the initialization parameters. |
| """ |
| _status: StringIO |
| _warning: StringIO |
| |
| def __init__( |
| self, |
| buildername: str = 'html', |
| srcdir: Path | None = None, |
| builddir: Path | None = None, |
| freshenv: bool = False, |
| confoverrides: dict | None = None, |
| status: IO | None = None, |
| warning: IO | None = None, |
| tags: list[str] | None = None, |
| docutilsconf: str | None = None, |
| parallel: int = 0, |
| ) -> None: |
| assert srcdir is not None |
| |
| self.docutils_conf_path = srcdir / 'docutils.conf' |
| if docutilsconf is not None: |
| self.docutils_conf_path.write_text(docutilsconf, 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 = {} |
| warningiserror = False |
| |
| self._saved_path = sys.path[:] |
| self._saved_directives = directives._directives.copy() # type: ignore[attr-defined] |
| self._saved_roles = roles._roles.copy() # type: ignore[attr-defined] |
| |
| self._saved_nodeclasses = {v for v in dir(nodes.GenericNodeVisitor) |
| if v.startswith('visit_')} |
| |
| try: |
| super().__init__(srcdir, confdir, outdir, doctreedir, |
| buildername, confoverrides, status, warning, |
| freshenv, warningiserror, tags, parallel=parallel) |
| except Exception: |
| self.cleanup() |
| raise |
| |
| def cleanup(self, doctrees: bool = False) -> None: |
| ModuleAnalyzer.cache.clear() |
| locale.translators.clear() |
| sys.path[:] = self._saved_path |
| sys.modules.pop('autodoc_fodder', None) |
| directives._directives = self._saved_directives # type: ignore[attr-defined] |
| roles._roles = self._saved_roles # type: ignore[attr-defined] |
| for method in dir(nodes.GenericNodeVisitor): |
| if method.startswith('visit_') and \ |
| method not in self._saved_nodeclasses: |
| delattr(nodes.GenericNodeVisitor, 'visit_' + method[6:]) |
| delattr(nodes.GenericNodeVisitor, 'depart_' + method[6:]) |
| with contextlib.suppress(FileNotFoundError): |
| os.remove(self.docutils_conf_path) |
| |
| def __repr__(self) -> str: |
| return f'<{self.__class__.__name__} buildername={self.builder.name!r}>' |
| |
| def build(self, force_all: bool = False, filenames: list[str] | None = None) -> None: |
| self.env._pickled_doctree_cache.clear() |
| super().build(force_all, filenames) |
| |
| |
| class SphinxTestAppWrapperForSkipBuilding: |
| """ |
| This class is a wrapper for SphinxTestApp to speed up the test by skipping |
| `app.build` process if it is already built and there is even one output |
| file. |
| """ |
| |
| def __init__(self, app_: SphinxTestApp) -> None: |
| self.app = app_ |
| |
| def __getattr__(self, name: str) -> Any: |
| return getattr(self.app, name) |
| |
| def build(self, *args: Any, **kwargs: Any) -> None: |
| if not os.listdir(self.app.outdir): |
| # if listdir is empty, do build. |
| self.app.build(*args, **kwargs) |
| # otherwise, we can use built cache |
| |
| |
| def strip_escseq(text: str) -> str: |
| return re.sub('\x1b.*?m', '', text) |