diff options
Diffstat (limited to 'sphinx/testing')
-rw-r--r-- | sphinx/testing/__init__.py | 7 | ||||
-rw-r--r-- | sphinx/testing/fixtures.py | 262 | ||||
-rw-r--r-- | sphinx/testing/path.py | 225 | ||||
-rw-r--r-- | sphinx/testing/restructuredtext.py | 35 | ||||
-rw-r--r-- | sphinx/testing/util.py | 255 |
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 |