summaryrefslogtreecommitdiffstats
path: root/sphinx/testing
diff options
context:
space:
mode:
Diffstat (limited to 'sphinx/testing')
-rw-r--r--sphinx/testing/__init__.py7
-rw-r--r--sphinx/testing/fixtures.py262
-rw-r--r--sphinx/testing/path.py225
-rw-r--r--sphinx/testing/restructuredtext.py35
-rw-r--r--sphinx/testing/util.py255
5 files changed, 784 insertions, 0 deletions
diff --git a/sphinx/testing/__init__.py b/sphinx/testing/__init__.py
new file mode 100644
index 0000000..1cf074f
--- /dev/null
+++ b/sphinx/testing/__init__.py
@@ -0,0 +1,7 @@
+"""Sphinx test utilities
+
+You can require sphinx.testing pytest fixtures in a test module or a conftest
+file like this:
+
+ pytest_plugins = 'sphinx.testing.fixtures'
+"""
diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py
new file mode 100644
index 0000000..388e5f6
--- /dev/null
+++ b/sphinx/testing/fixtures.py
@@ -0,0 +1,262 @@
+"""Sphinx test fixtures for pytest"""
+
+from __future__ import annotations
+
+import shutil
+import subprocess
+import sys
+from collections import namedtuple
+from io import StringIO
+from typing import TYPE_CHECKING
+
+import pytest
+
+from sphinx.testing.util import SphinxTestApp, SphinxTestAppWrapperForSkipBuilding
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Iterator
+ from pathlib import Path
+ from typing import Any
+
+DEFAULT_ENABLED_MARKERS = [
+ # The marker signature differs from the constructor signature
+ # since the way it is processed assumes keyword arguments for
+ # the 'testroot' and 'srcdir'.
+ (
+ 'sphinx('
+ 'buildername="html", *, '
+ 'testroot="root", srcdir=None, '
+ 'confoverrides=None, freshenv=False, '
+ 'warningiserror=False, tags=None, verbosity=0, parallel=0, '
+ 'keep_going=False, builddir=None, docutils_conf=None'
+ '): arguments to initialize the sphinx test application.'
+ ),
+ 'test_params(shared_result=...): test parameters.',
+]
+
+
+def pytest_configure(config: pytest.Config) -> None:
+ """Register custom markers"""
+ for marker in DEFAULT_ENABLED_MARKERS:
+ config.addinivalue_line('markers', marker)
+
+
+@pytest.fixture(scope='session')
+def rootdir() -> str | None:
+ return None
+
+
+class SharedResult:
+ cache: dict[str, dict[str, str]] = {}
+
+ def store(self, key: str, app_: SphinxTestApp) -> Any:
+ if key in self.cache:
+ return
+ data = {
+ 'status': app_.status.getvalue(),
+ 'warning': app_.warning.getvalue(),
+ }
+ self.cache[key] = data
+
+ def restore(self, key: str) -> dict[str, StringIO]:
+ if key not in self.cache:
+ return {}
+ data = self.cache[key]
+ return {
+ 'status': StringIO(data['status']),
+ 'warning': StringIO(data['warning']),
+ }
+
+
+@pytest.fixture
+def app_params(
+ request: Any,
+ test_params: dict[str, Any],
+ shared_result: SharedResult,
+ sphinx_test_tempdir: str,
+ rootdir: str,
+) -> _app_params:
+ """
+ Parameters that are specified by 'pytest.mark.sphinx' for
+ sphinx.application.Sphinx initialization
+ """
+ # ##### process pytest.mark.sphinx
+
+ pargs: dict[int, Any] = {}
+ kwargs: dict[str, Any] = {}
+
+ # to avoid stacking positional args
+ for info in reversed(list(request.node.iter_markers("sphinx"))):
+ pargs |= dict(enumerate(info.args))
+ kwargs.update(info.kwargs)
+
+ args = [pargs[i] for i in sorted(pargs.keys())]
+
+ # ##### process pytest.mark.test_params
+ if test_params['shared_result']:
+ if 'srcdir' in kwargs:
+ msg = 'You can not specify shared_result and srcdir in same time.'
+ pytest.fail(msg)
+ kwargs['srcdir'] = test_params['shared_result']
+ restore = shared_result.restore(test_params['shared_result'])
+ kwargs.update(restore)
+
+ # ##### prepare Application params
+
+ testroot = kwargs.pop('testroot', 'root')
+ kwargs['srcdir'] = srcdir = sphinx_test_tempdir / kwargs.get('srcdir', testroot)
+
+ # special support for sphinx/tests
+ if rootdir and not srcdir.exists():
+ testroot_path = rootdir / ('test-' + testroot)
+ shutil.copytree(testroot_path, srcdir)
+
+ return _app_params(args, kwargs)
+
+
+_app_params = namedtuple('_app_params', 'args,kwargs')
+
+
+@pytest.fixture
+def test_params(request: Any) -> dict[str, Any]:
+ """
+ Test parameters that are specified by 'pytest.mark.test_params'
+
+ :param Union[str] shared_result:
+ If the value is provided, app._status and app._warning objects will be
+ shared in the parametrized test functions and/or test functions that
+ have same 'shared_result' value.
+ **NOTE**: You can not specify both shared_result and srcdir.
+ """
+ env = request.node.get_closest_marker('test_params')
+ kwargs = env.kwargs if env else {}
+ result = {
+ 'shared_result': None,
+ }
+ result.update(kwargs)
+
+ if result['shared_result'] and not isinstance(result['shared_result'], str):
+ msg = 'You can only provide a string type of value for "shared_result"'
+ raise pytest.Exception(msg)
+ return result
+
+
+@pytest.fixture
+def app(
+ test_params: dict[str, Any],
+ app_params: _app_params,
+ make_app: Callable[[], SphinxTestApp],
+ shared_result: SharedResult,
+) -> Iterator[SphinxTestApp]:
+ """
+ Provides the 'sphinx.application.Sphinx' object
+ """
+ args, kwargs = app_params
+ app_ = make_app(*args, **kwargs)
+ yield app_
+
+ print('# testroot:', kwargs.get('testroot', 'root'))
+ print('# builder:', app_.builder.name)
+ print('# srcdir:', app_.srcdir)
+ print('# outdir:', app_.outdir)
+ print('# status:', '\n' + app_.status.getvalue())
+ print('# warning:', '\n' + app_.warning.getvalue())
+
+ if test_params['shared_result']:
+ shared_result.store(test_params['shared_result'], app_)
+
+
+@pytest.fixture
+def status(app: SphinxTestApp) -> StringIO:
+ """
+ Back-compatibility for testing with previous @with_app decorator
+ """
+ return app.status
+
+
+@pytest.fixture
+def warning(app: SphinxTestApp) -> StringIO:
+ """
+ Back-compatibility for testing with previous @with_app decorator
+ """
+ return app.warning
+
+
+@pytest.fixture
+def make_app(test_params: dict[str, Any]) -> Iterator[Callable[[], SphinxTestApp]]:
+ """
+ Provides make_app function to initialize SphinxTestApp instance.
+ if you want to initialize 'app' in your test function. please use this
+ instead of using SphinxTestApp class directory.
+ """
+ apps = []
+ syspath = sys.path.copy()
+
+ def make(*args: Any, **kwargs: Any) -> SphinxTestApp:
+ status, warning = StringIO(), StringIO()
+ kwargs.setdefault('status', status)
+ kwargs.setdefault('warning', warning)
+ app_: SphinxTestApp
+ if test_params['shared_result']:
+ app_ = SphinxTestAppWrapperForSkipBuilding(*args, **kwargs)
+ else:
+ app_ = SphinxTestApp(*args, **kwargs)
+ apps.append(app_)
+ return app_
+ yield make
+
+ sys.path[:] = syspath
+ for app_ in reversed(apps): # clean up applications from the new ones
+ app_.cleanup()
+
+
+@pytest.fixture
+def shared_result() -> SharedResult:
+ return SharedResult()
+
+
+@pytest.fixture(scope='module', autouse=True)
+def _shared_result_cache() -> None:
+ SharedResult.cache.clear()
+
+
+@pytest.fixture
+def if_graphviz_found(app: SphinxTestApp) -> None: # NoQA: PT004
+ """
+ The test will be skipped when using 'if_graphviz_found' fixture and graphviz
+ dot command is not found.
+ """
+ graphviz_dot = getattr(app.config, 'graphviz_dot', '')
+ try:
+ if graphviz_dot:
+ # print the graphviz_dot version, to check that the binary is available
+ subprocess.run([graphviz_dot, '-V'], capture_output=True, check=False)
+ return
+ except OSError: # No such file or directory
+ pass
+
+ pytest.skip('graphviz "dot" is not available')
+
+
+@pytest.fixture(scope='session')
+def sphinx_test_tempdir(tmp_path_factory: Any) -> Path:
+ """Temporary directory."""
+ return tmp_path_factory.getbasetemp()
+
+
+@pytest.fixture
+def rollback_sysmodules() -> Iterator[None]: # NoQA: PT004
+ """
+ Rollback sys.modules to its value before testing to unload modules
+ during tests.
+
+ For example, used in test_ext_autosummary.py to permit unloading the
+ target module to clear its cache.
+ """
+ sysmodules = list(sys.modules)
+ try:
+ yield
+ finally:
+ for modname in list(sys.modules):
+ if modname not in sysmodules:
+ sys.modules.pop(modname)
diff --git a/sphinx/testing/path.py b/sphinx/testing/path.py
new file mode 100644
index 0000000..8244e69
--- /dev/null
+++ b/sphinx/testing/path.py
@@ -0,0 +1,225 @@
+from __future__ import annotations
+
+import os
+import shutil
+import sys
+import warnings
+from typing import IO, TYPE_CHECKING, Any, Callable
+
+from sphinx.deprecation import RemovedInSphinx90Warning
+
+if TYPE_CHECKING:
+ import builtins
+
+warnings.warn("'sphinx.testing.path' is deprecated. "
+ "Use 'os.path' or 'pathlib' instead.",
+ RemovedInSphinx90Warning, stacklevel=2)
+
+FILESYSTEMENCODING = sys.getfilesystemencoding() or sys.getdefaultencoding()
+
+
+def getumask() -> int:
+ """Get current umask value"""
+ umask = os.umask(0) # Note: Change umask value temporarily to obtain it
+ os.umask(umask)
+
+ return umask
+
+
+UMASK = getumask()
+
+
+class path(str):
+ """
+ Represents a path which behaves like a string.
+ """
+
+ __slots__ = ()
+
+ @property
+ def parent(self) -> path:
+ """
+ The name of the directory the file or directory is in.
+ """
+ return self.__class__(os.path.dirname(self))
+
+ def basename(self) -> str:
+ return os.path.basename(self)
+
+ def abspath(self) -> path:
+ """
+ Returns the absolute path.
+ """
+ return self.__class__(os.path.abspath(self))
+
+ def isabs(self) -> bool:
+ """
+ Returns ``True`` if the path is absolute.
+ """
+ return os.path.isabs(self)
+
+ def isdir(self) -> bool:
+ """
+ Returns ``True`` if the path is a directory.
+ """
+ return os.path.isdir(self)
+
+ def isfile(self) -> bool:
+ """
+ Returns ``True`` if the path is a file.
+ """
+ return os.path.isfile(self)
+
+ def islink(self) -> bool:
+ """
+ Returns ``True`` if the path is a symbolic link.
+ """
+ return os.path.islink(self)
+
+ def ismount(self) -> bool:
+ """
+ Returns ``True`` if the path is a mount point.
+ """
+ return os.path.ismount(self)
+
+ def rmtree(
+ self,
+ ignore_errors: bool = False,
+ onerror: Callable[[Callable[..., Any], str, Any], object] | None = None,
+ ) -> None:
+ """
+ Removes the file or directory and any files or directories it may
+ contain.
+
+ :param ignore_errors:
+ If ``True`` errors are silently ignored, otherwise an exception
+ is raised in case an error occurs.
+
+ :param onerror:
+ A callback which gets called with the arguments `func`, `path` and
+ `exc_info`. `func` is one of :func:`os.listdir`, :func:`os.remove`
+ or :func:`os.rmdir`. `path` is the argument to the function which
+ caused it to fail and `exc_info` is a tuple as returned by
+ :func:`sys.exc_info`.
+ """
+ shutil.rmtree(self, ignore_errors=ignore_errors, onerror=onerror)
+
+ def copytree(self, destination: str, symlinks: bool = False) -> None:
+ """
+ Recursively copy a directory to the given `destination`. If the given
+ `destination` does not exist it will be created.
+
+ :param symlinks:
+ If ``True`` symbolic links in the source tree result in symbolic
+ links in the destination tree otherwise the contents of the files
+ pointed to by the symbolic links are copied.
+ """
+ shutil.copytree(self, destination, symlinks=symlinks)
+ if os.environ.get('SPHINX_READONLY_TESTDIR'):
+ # If source tree is marked read-only (e.g. because it is on a read-only
+ # filesystem), `shutil.copytree` will mark the destination as read-only
+ # as well. To avoid failures when adding additional files/directories
+ # to the destination tree, ensure destination directories are not marked
+ # read-only.
+ for root, _dirs, files in os.walk(destination):
+ os.chmod(root, 0o755 & ~UMASK)
+ for name in files:
+ os.chmod(os.path.join(root, name), 0o644 & ~UMASK)
+
+ def movetree(self, destination: str) -> None:
+ """
+ Recursively move the file or directory to the given `destination`
+ similar to the Unix "mv" command.
+
+ If the `destination` is a file it may be overwritten depending on the
+ :func:`os.rename` semantics.
+ """
+ shutil.move(self, destination)
+
+ move = movetree
+
+ def unlink(self) -> None:
+ """
+ Removes a file.
+ """
+ os.unlink(self)
+
+ def stat(self) -> Any:
+ """
+ Returns a stat of the file.
+ """
+ return os.stat(self)
+
+ def utime(self, arg: Any) -> None:
+ os.utime(self, arg)
+
+ def open(self, mode: str = 'r', **kwargs: Any) -> IO[str]:
+ return open(self, mode, **kwargs) # NoQA: SIM115
+
+ def write_text(self, text: str, encoding: str = 'utf-8', **kwargs: Any) -> None:
+ """
+ Writes the given `text` to the file.
+ """
+ with open(self, 'w', encoding=encoding, **kwargs) as f:
+ f.write(text)
+
+ def read_text(self, encoding: str = 'utf-8', **kwargs: Any) -> str:
+ """
+ Returns the text in the file.
+ """
+ with open(self, encoding=encoding, **kwargs) as f:
+ return f.read()
+
+ def read_bytes(self) -> builtins.bytes:
+ """
+ Returns the bytes in the file.
+ """
+ with open(self, mode='rb') as f:
+ return f.read()
+
+ def write_bytes(self, bytes: str, append: bool = False) -> None:
+ """
+ Writes the given `bytes` to the file.
+
+ :param append:
+ If ``True`` given `bytes` are added at the end of the file.
+ """
+ if append:
+ mode = 'ab'
+ else:
+ mode = 'wb'
+ with open(self, mode=mode) as f:
+ f.write(bytes)
+
+ def exists(self) -> bool:
+ """
+ Returns ``True`` if the path exist.
+ """
+ return os.path.exists(self)
+
+ def lexists(self) -> bool:
+ """
+ Returns ``True`` if the path exists unless it is a broken symbolic
+ link.
+ """
+ return os.path.lexists(self)
+
+ def makedirs(self, mode: int = 0o777, exist_ok: bool = False) -> None:
+ """
+ Recursively create directories.
+ """
+ os.makedirs(self, mode, exist_ok=exist_ok)
+
+ def joinpath(self, *args: Any) -> path:
+ """
+ Joins the path with the argument given and returns the result.
+ """
+ return self.__class__(os.path.join(self, *map(self.__class__, args)))
+
+ def listdir(self) -> list[str]:
+ return os.listdir(self)
+
+ __div__ = __truediv__ = joinpath
+
+ def __repr__(self) -> str:
+ return f'{self.__class__.__name__}({super().__repr__()})'
diff --git a/sphinx/testing/restructuredtext.py b/sphinx/testing/restructuredtext.py
new file mode 100644
index 0000000..1f89336
--- /dev/null
+++ b/sphinx/testing/restructuredtext.py
@@ -0,0 +1,35 @@
+from os import path
+
+from docutils import nodes
+from docutils.core import publish_doctree
+
+from sphinx.application import Sphinx
+from sphinx.io import SphinxStandaloneReader
+from sphinx.parsers import RSTParser
+from sphinx.util.docutils import sphinx_domains
+
+
+def parse(app: Sphinx, text: str, docname: str = 'index') -> nodes.document:
+ """Parse a string as reStructuredText with Sphinx application."""
+ try:
+ app.env.temp_data['docname'] = docname
+ reader = SphinxStandaloneReader()
+ reader.setup(app)
+ parser = RSTParser()
+ parser.set_application(app)
+ with sphinx_domains(app.env):
+ return publish_doctree(
+ text,
+ path.join(app.srcdir, docname + '.rst'),
+ reader=reader,
+ parser=parser,
+ settings_overrides={
+ 'env': app.env,
+ 'gettext_compact': True,
+ 'input_encoding': 'utf-8',
+ 'output_encoding': 'unicode',
+ 'traceback': True,
+ },
+ )
+ finally:
+ app.env.temp_data.pop('docname', None)
diff --git a/sphinx/testing/util.py b/sphinx/testing/util.py
new file mode 100644
index 0000000..1cc6c4a
--- /dev/null
+++ b/sphinx/testing/util.py
@@ -0,0 +1,255 @@
+"""Sphinx test suite utilities"""
+
+from __future__ import annotations
+
+__all__ = ('SphinxTestApp', 'SphinxTestAppWrapperForSkipBuilding')
+
+import contextlib
+import os
+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.console import strip_colors
+from sphinx.util.docutils import additional_nodes
+
+if TYPE_CHECKING:
+ 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), \
+ '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}'
+
+
+# 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)
+
+
+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')
+ 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
+ keep_going: bool = False,
+ warningiserror: bool = False, # argument is not in the same order as in the superclass
+ # unknown keyword arguments
+ **extras: Any,
+ ) -> None:
+ 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 = "%r must be an io.StringIO object, got: %s" % ('status', 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 = '%r must be an io.StringIO object, got: %s' % ('warning', 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, keep_going=keep_going,
+ pdb=False,
+ )
+ except Exception:
+ self.cleanup()
+ raise
+
+ @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()
+ 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(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: list[str] | None = None) -> None:
+ if not os.listdir(self.outdir):
+ # 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()
+
+
+# deprecated name -> (object to return, canonical path or '', removal version)
+_DEPRECATED_OBJECTS: dict[str, tuple[Any, str, tuple[int, int]]] = {
+ 'strip_escseq': (strip_colors, 'sphinx.util.console.strip_colors', (9, 0)),
+}
+
+
+def __getattr__(name: str) -> Any:
+ if name not in _DEPRECATED_OBJECTS:
+ msg = f'module {__name__!r} has no attribute {name!r}'
+ raise AttributeError(msg)
+
+ from sphinx.deprecation import _deprecation_warning
+
+ deprecated_object, canonical_name, remove = _DEPRECATED_OBJECTS[name]
+ _deprecation_warning(__name__, name, canonical_name, remove=remove)
+ return deprecated_object