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.py243
-rw-r--r--sphinx/testing/path.py221
-rw-r--r--sphinx/testing/restructuredtext.py35
-rw-r--r--sphinx/testing/util.py171
5 files changed, 677 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..0cc4882
--- /dev/null
+++ b/sphinx/testing/fixtures.py
@@ -0,0 +1,243 @@
+"""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, Any, Callable
+
+import pytest
+
+from sphinx.testing.util import SphinxTestApp, SphinxTestAppWrapperForSkipBuilding
+
+if TYPE_CHECKING:
+ from collections.abc import Generator
+ from pathlib import Path
+
+DEFAULT_ENABLED_MARKERS = [
+ (
+ 'sphinx(builder, testroot=None, freshenv=False, confoverrides=None, tags=None,'
+ ' docutilsconf=None, parallel=0): arguments to initialize the sphinx test application.'
+ ),
+ 'test_params(shared_result=...): test parameters.',
+]
+
+
+def pytest_configure(config):
+ """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, 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 = {}
+ kwargs: dict[str, Any] = {}
+
+ # to avoid stacking positional args
+ for info in reversed(list(request.node.iter_markers("sphinx"))):
+ for i, a in enumerate(info.args):
+ pargs[i] = a
+ 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.'
+ raise pytest.Exception(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:
+ """
+ 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, app_params: tuple[dict, dict], make_app: Callable,
+ shared_result: SharedResult) -> Generator[SphinxTestApp, None, None]:
+ """
+ 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, monkeypatch: Any) -> Generator[Callable, None, None]:
+ """
+ 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[:]
+
+ def make(*args, **kwargs):
+ status, warning = StringIO(), StringIO()
+ kwargs.setdefault('status', status)
+ kwargs.setdefault('warning', warning)
+ app_: Any = SphinxTestApp(*args, **kwargs)
+ apps.append(app_)
+ if test_params['shared_result']:
+ app_ = SphinxTestAppWrapperForSkipBuilding(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:
+ subprocess.run([graphviz_dot, '-V'], capture_output=True) # show version
+ 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(): # 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.
+ """
+ try:
+ sysmodules = list(sys.modules)
+ 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..f4069ba
--- /dev/null
+++ b/sphinx/testing/path.py
@@ -0,0 +1,221 @@
+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 | 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:
+ return open(self, mode, **kwargs)
+
+ 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..e76d401
--- /dev/null
+++ b/sphinx/testing/util.py
@@ -0,0 +1,171 @@
+"""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)