diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-06-05 16:20:58 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-06-05 16:20:58 +0000 |
commit | 5bb0bb4be543fd5eca41673696a62ed80d493591 (patch) | |
tree | ad2c464f140e86c7f178a6276d7ea4a93e3e6c92 /sphinx/testing | |
parent | Adding upstream version 7.2.6. (diff) | |
download | sphinx-upstream.tar.xz sphinx-upstream.zip |
Adding upstream version 7.3.7.upstream/7.3.7upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'sphinx/testing')
-rw-r--r-- | sphinx/testing/fixtures.py | 65 | ||||
-rw-r--r-- | sphinx/testing/path.py | 2 | ||||
-rw-r--r-- | sphinx/testing/util.py | 195 |
3 files changed, 184 insertions, 78 deletions
diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index 0cc4882..6e1a122 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -7,26 +7,35 @@ import subprocess import sys from collections import namedtuple from io import StringIO -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING import pytest from sphinx.testing.util import SphinxTestApp, SphinxTestAppWrapperForSkipBuilding if TYPE_CHECKING: - from collections.abc import Generator + 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(builder, testroot=None, freshenv=False, confoverrides=None, tags=None,' - ' docutilsconf=None, parallel=0): arguments to initialize the sphinx test application.' + '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): +def pytest_configure(config: pytest.Config) -> None: """Register custom markers""" for marker in DEFAULT_ENABLED_MARKERS: config.addinivalue_line('markers', marker) @@ -44,8 +53,8 @@ class SharedResult: if key in self.cache: return data = { - 'status': app_._status.getvalue(), - 'warning': app_._warning.getvalue(), + 'status': app_.status.getvalue(), + 'warning': app_.warning.getvalue(), } self.cache[key] = data @@ -60,22 +69,25 @@ class SharedResult: @pytest.fixture() -def app_params(request: Any, test_params: dict, shared_result: SharedResult, - sphinx_test_tempdir: str, rootdir: str) -> _app_params: +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 = {} + pargs: dict[int, Any] = {} 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 + pargs |= dict(enumerate(info.args)) kwargs.update(info.kwargs) args = [pargs[i] for i in sorted(pargs.keys())] @@ -84,7 +96,7 @@ def app_params(request: Any, test_params: dict, shared_result: SharedResult, 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) + pytest.fail(msg) kwargs['srcdir'] = test_params['shared_result'] restore = shared_result.restore(test_params['shared_result']) kwargs.update(restore) @@ -130,8 +142,12 @@ def test_params(request: Any) -> dict: @pytest.fixture() -def app(test_params: dict, app_params: tuple[dict, dict], make_app: Callable, - shared_result: SharedResult) -> Generator[SphinxTestApp, None, None]: +def app( + test_params: dict, + app_params: tuple[dict, dict], + make_app: Callable, + shared_result: SharedResult, +) -> Iterator[SphinxTestApp]: """ Provides the 'sphinx.application.Sphinx' object """ @@ -155,7 +171,7 @@ def status(app: SphinxTestApp) -> StringIO: """ Back-compatibility for testing with previous @with_app decorator """ - return app._status + return app.status @pytest.fixture() @@ -163,20 +179,20 @@ def warning(app: SphinxTestApp) -> StringIO: """ Back-compatibility for testing with previous @with_app decorator """ - return app._warning + return app.warning @pytest.fixture() -def make_app(test_params: dict, monkeypatch: Any) -> Generator[Callable, None, None]: +def make_app(test_params: dict, monkeypatch: Any) -> Iterator[Callable]: """ 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[:] + syspath = sys.path.copy() - def make(*args, **kwargs): + def make(*args: Any, **kwargs: Any) -> SphinxTestApp: status, warning = StringIO(), StringIO() kwargs.setdefault('status', status) kwargs.setdefault('warning', warning) @@ -211,7 +227,8 @@ def if_graphviz_found(app: SphinxTestApp) -> None: # NoQA: PT004 graphviz_dot = getattr(app.config, 'graphviz_dot', '') try: if graphviz_dot: - subprocess.run([graphviz_dot, '-V'], capture_output=True) # show version + # 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 @@ -226,7 +243,7 @@ def sphinx_test_tempdir(tmp_path_factory: Any) -> Path: @pytest.fixture() -def rollback_sysmodules(): # NoQA: PT004 +def rollback_sysmodules() -> Iterator[None]: # NoQA: PT004 """ Rollback sys.modules to its value before testing to unload modules during tests. @@ -234,8 +251,8 @@ def rollback_sysmodules(): # NoQA: PT004 For example, used in test_ext_autosummary.py to permit unloading the target module to clear its cache. """ + sysmodules = list(sys.modules) try: - sysmodules = list(sys.modules) yield finally: for modname in list(sys.modules): diff --git a/sphinx/testing/path.py b/sphinx/testing/path.py index f4069ba..49e62ce 100644 --- a/sphinx/testing/path.py +++ b/sphinx/testing/path.py @@ -150,7 +150,7 @@ class path(str): os.utime(self, arg) def open(self, mode: str = 'r', **kwargs: Any) -> IO: - return open(self, mode, **kwargs) + return open(self, mode, **kwargs) # NoQA: SIM115 def write_text(self, text: str, encoding: str = 'utf-8', **kwargs: Any) -> None: """ diff --git a/sphinx/testing/util.py b/sphinx/testing/util.py index e76d401..d1de8ea 100644 --- a/sphinx/testing/util.py +++ b/sphinx/testing/util.py @@ -1,28 +1,34 @@ """Sphinx test suite utilities""" + from __future__ import annotations +__all__ = ('SphinxTestApp', 'SphinxTestAppWrapperForSkipBuilding') + import contextlib import os -import re import sys -import warnings -from typing import IO, TYPE_CHECKING, Any -from xml.etree import ElementTree +from io import StringIO +from types import MappingProxyType +from typing import TYPE_CHECKING +from defusedxml.ElementTree import parse as xml_parse from docutils import nodes from docutils.parsers.rst import directives, roles -from sphinx import application, locale -from sphinx.pycode import ModuleAnalyzer +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 io import StringIO + from collections.abc import Mapping from pathlib import Path + from typing import Any + from xml.etree.ElementTree import ElementTree from docutils.nodes import Node -__all__ = 'SphinxTestApp', 'SphinxTestAppWrapperForSkipBuilding' - def assert_node(node: Node, cls: Any = None, xpath: str = "", **kwargs: Any) -> None: if cls: @@ -64,38 +70,84 @@ def assert_node(node: Node, cls: Any = None, xpath: str = "", **kwargs: Any) -> 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 +# 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.""" + return xml_parse(path) -class SphinxTestApp(application.Sphinx): - """ - A subclass of :class:`Sphinx` that runs on the test root, with some - better default values for the initialization parameters. +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. """ - _status: StringIO - _warning: StringIO + + # 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, - freshenv: bool = False, - confoverrides: dict | None = None, - status: IO | None = None, - warning: IO | 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: list[str] | None = None, - docutilsconf: str | None = None, + 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 docutilsconf is not None: - self.docutils_conf_path.write_text(docutilsconf, encoding='utf8') + if docutils_conf is not None: + self.docutils_conf_path.write_text(docutils_conf, encoding='utf8') if builddir is None: builddir = srcdir / '_build' @@ -107,35 +159,40 @@ class SphinxTestApp(application.Sphinx): 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_')} + 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, status, warning, - freshenv, warningiserror, tags, parallel=parallel) + 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: - 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:]) + _clean_up_global_state() with contextlib.suppress(FileNotFoundError): os.remove(self.docutils_conf_path) @@ -148,10 +205,10 @@ class SphinxTestApp(application.Sphinx): 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. + """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 __init__(self, app_: SphinxTestApp) -> None: @@ -167,5 +224,37 @@ class SphinxTestAppWrapperForSkipBuilding: # otherwise, we can use built cache -def strip_escseq(text: str) -> str: - return re.sub('\x1b.*?m', '', text) +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 |