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