summaryrefslogtreecommitdiffstats
path: root/sphinx/testing
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-06-05 16:20:58 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-06-05 16:20:58 +0000
commit5bb0bb4be543fd5eca41673696a62ed80d493591 (patch)
treead2c464f140e86c7f178a6276d7ea4a93e3e6c92 /sphinx/testing
parentAdding upstream version 7.2.6. (diff)
downloadsphinx-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.py65
-rw-r--r--sphinx/testing/path.py2
-rw-r--r--sphinx/testing/util.py195
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