From a4ef0a579046d1ee3e84ec3ca3eb8a7bf605b765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Thu, 14 Mar 2024 13:04:05 +0100 Subject: [PATCH 01/47] patch marker signatures --- CHANGES.rst | 18 ++++++ sphinx/testing/fixtures.py | 19 ++++--- sphinx/testing/util.py | 109 +++++++++++++++++++++++++++++-------- 3 files changed, 116 insertions(+), 30 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index cc0b0d26c0d..2dbaff064dc 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -13,6 +13,11 @@ Deprecated * #11693: Support for old-style :file:`Makefile` and :file:`make.bat` output in :program:`sphinx-quickstart`, and the associated options :option:`!-M`, :option:`!-m`, :option:`!--no-use-make-mode`, and :option:`!--use-make-mode`. +* #11285: Direct access to :attr:`!sphinx.testing.util.SphinxTestApp._status` + or :attr:`!sphinx.testing.util.SphinxTestApp._warning` is deprecated. Use + the public properties :attr:`!sphinx.testing.util.SphinxTestApp.status` + and :attr:`!sphinx.testing.util.SphinxTestApp.warning` instead. + Patch by Bénédikt Tran. Features added -------------- @@ -99,6 +104,19 @@ Bugs fixed Testing ------- +* #11285: :func:`!pytest.mark.sphinx` requires keyword arguments, except for + the builder name which can still be given as the first positional argument. + Patch by Bénédikt Tran. +* #11285: :func:`!pytest.mark.sphinx` accepts *warningiserror*, *keep_going* + and *verbosity* as additional keyword arguments. + Patch by Bénédikt Tran. +* #11285: :class:`!sphinx.testing.util.SphinxTestApp` *srcdir* argument is + now mandatory (previously, this was checked with an assertion). + Patch by Bénédikt Tran. +* #11285: :class:`!sphinx.testing.util.SphinxTestApp` *status* and *warning* + arguments are checked to be :class:`io.StringIO` objects (the public API + incorrectly assumed this without checking it). + Patch by Bénédikt Tran. Release 7.2.6 (released Sep 13, 2023) ===================================== diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index eaf76e28485..355ed7560ec 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -7,7 +7,7 @@ import sys from collections import namedtuple from io import StringIO -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING import pytest @@ -16,11 +16,16 @@ if TYPE_CHECKING: from collections.abc import Generator from pathlib import Path + from typing import Any, Callable DEFAULT_ENABLED_MARKERS = [ ( - 'sphinx(builder, testroot=None, freshenv=False, confoverrides=None, tags=None, ' - 'docutils_conf=None, parallel=0): arguments to initialize the sphinx test application.' + 'sphinx(' + 'buildername="html", /, *, ' + 'testroot="root", 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.', ] @@ -44,8 +49,8 @@ def store(self, key: str, app_: SphinxTestApp) -> Any: 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 @@ -153,7 +158,7 @@ def status(app: SphinxTestApp) -> StringIO: """ Back-compatibility for testing with previous @with_app decorator """ - return app._status + return app.status @pytest.fixture() @@ -161,7 +166,7 @@ def warning(app: SphinxTestApp) -> StringIO: """ Back-compatibility for testing with previous @with_app decorator """ - return app._warning + return app.warning @pytest.fixture() diff --git a/sphinx/testing/util.py b/sphinx/testing/util.py index e15a43b4b33..9de5ff8453e 100644 --- a/sphinx/testing/util.py +++ b/sphinx/testing/util.py @@ -1,4 +1,5 @@ """Sphinx test suite utilities""" + from __future__ import annotations import contextlib @@ -6,7 +7,9 @@ import re import sys import warnings -from typing import IO, TYPE_CHECKING, Any +from io import StringIO +from types import MappingProxyType +from typing import TYPE_CHECKING from xml.etree import ElementTree from docutils import nodes @@ -18,8 +21,9 @@ 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 docutils.nodes import Node @@ -73,28 +77,75 @@ def etree_parse(path: str) -> Any: class SphinxTestApp(sphinx.application.Sphinx): - """ - A subclass of :class:`Sphinx` that runs on the test root, with some - better default values for the initialization parameters. + """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 + # Allow the builder name to be passed as a keyword argument + # but only make it positional-only for ``pytest.mark.sphinx`` + # so that an exception can be raised if the constructor is + # directly called and multiple values for the builder name + # are given. def __init__( self, + /, buildername: str = 'html', - srcdir: Path | None = None, - builddir: Path | None = None, + *, + srcdir: Path, + confoverrides: dict[str, Any] | None = None, + status: StringIO | None = None, + warning: StringIO | None = None, freshenv: bool = False, - confoverrides: dict | None = None, - status: IO | None = None, - warning: IO | None = None, + warningiserror: bool = False, tags: list[str] | None = None, - docutils_conf: str | None = None, + verbosity: int = 0, parallel: int = 0, + keep_going: bool = False, + # extra constructor arguments + builddir: Path | None = None, + docutils_conf: str | None = None, + # 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: @@ -112,17 +163,35 @@ def __init__( 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, status, warning, freshenv, - warningiserror=False, tags=tags, parallel=parallel, + 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 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() @@ -138,12 +207,6 @@ def build(self, force_all: bool = False, filenames: list[str] | None = None) -> class SphinxTestAppWrapperForSkipBuilding: - """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: self.app = app_ From 793f45c838c3f439d1038032f24b2abc9c32dd43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Thu, 14 Mar 2024 13:05:30 +0100 Subject: [PATCH 02/47] update doc --- sphinx/testing/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx/testing/util.py b/sphinx/testing/util.py index 9de5ff8453e..e096b648827 100644 --- a/sphinx/testing/util.py +++ b/sphinx/testing/util.py @@ -180,7 +180,7 @@ def __init__( @property def status(self) -> StringIO: - """The in-memory I/O for the application status messages.""" + """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 From 4bc677ff4ab768143560774b72358290e759d196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Thu, 14 Mar 2024 13:12:29 +0100 Subject: [PATCH 03/47] revert suppression --- sphinx/testing/util.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sphinx/testing/util.py b/sphinx/testing/util.py index e096b648827..f403757047f 100644 --- a/sphinx/testing/util.py +++ b/sphinx/testing/util.py @@ -207,6 +207,12 @@ def build(self, force_all: bool = False, filenames: list[str] | None = None) -> class SphinxTestAppWrapperForSkipBuilding: + """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: self.app = app_ From c69f375a72e7c09070606361987d12ea8444a02c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Thu, 14 Mar 2024 15:31:04 +0100 Subject: [PATCH 04/47] add xdist dependency but disable it for now --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2293a3c6851..26dcdf45df8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,7 @@ lint = [ ] test = [ "pytest>=6.0", + "pytest-xdist==3.5.0", "html5lib", "cython>=3.0", "setuptools>=67.0", # for Cython compilation @@ -216,6 +217,7 @@ disallow_any_generics = false minversion = 6.0 addopts = [ "-ra", + "-p no:xdist", # disable xdist for now "--import-mode=prepend", # "--pythonwarnings=error", "--strict-config", From 42f60449d8a3e634d2882a545a993da1af4ccc05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Thu, 14 Mar 2024 15:37:28 +0100 Subject: [PATCH 05/47] fixup --- sphinx/testing/fixtures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index e18424c0dfa..2f85ccb0a38 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -7,7 +7,7 @@ import sys from collections import namedtuple from io import StringIO -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional import pytest From b5457e8125b391571de2ec72ae0de99af4d05ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Thu, 14 Mar 2024 16:52:35 +0100 Subject: [PATCH 06/47] implement new plugin --- sphinx/testing/_xdist_hooks.py | 30 + sphinx/testing/fixtures.py | 524 +++++++++++++----- sphinx/testing/internal/__init__.py | 6 + sphinx/testing/internal/cache.py | 61 ++ sphinx/testing/internal/isolation.py | 46 ++ sphinx/testing/internal/markers.py | 306 ++++++++++ sphinx/testing/internal/pytest_util.py | 417 ++++++++++++++ sphinx/testing/internal/pytest_xdist.py | 73 +++ sphinx/testing/internal/util.py | 101 ++++ sphinx/testing/internal/warnings.py | 31 ++ sphinx/testing/util.py | 60 +- tests/conftest.py | 173 +++++- tests/roots/test-minimal/conf.py | 3 + tests/roots/test-minimal/index.rst | 1 + tests/test_builders/test_build.py | 6 +- tests/test_builders/test_build_dirhtml.py | 2 +- tests/test_builders/test_build_html.py | 2 +- tests/test_builders/test_build_html_numfig.py | 2 + tests/test_builders/test_build_latex.py | 8 +- tests/test_builders/test_build_text.py | 9 +- tests/test_environment/test_environment.py | 6 +- .../test_ext_autodoc_configs.py | 7 + .../test_ext_inheritance_diagram.py | 2 +- tests/test_intl/test_intl.py | 4 +- tests/test_markup/test_smartquotes.py | 20 +- tests/test_testing/__init__.py | 0 tests/test_testing/_const.py | 24 + tests/test_testing/_util.py | 480 ++++++++++++++++ tests/test_testing/conftest.py | 73 +++ tests/test_testing/magico.py | 78 +++ tests/test_testing/test_magico.py | 87 +++ tests/test_testing/test_plugin_isolation.py | 121 ++++ tests/test_testing/test_plugin_markers.py | 60 ++ tests/test_testing/test_plugin_xdist.py | 349 ++++++++++++ tests/test_testing/test_testroot_finder.py | 180 ++++++ tests/test_toctree.py | 1 + 36 files changed, 3171 insertions(+), 182 deletions(-) create mode 100644 sphinx/testing/_xdist_hooks.py create mode 100644 sphinx/testing/internal/__init__.py create mode 100644 sphinx/testing/internal/cache.py create mode 100644 sphinx/testing/internal/isolation.py create mode 100644 sphinx/testing/internal/markers.py create mode 100644 sphinx/testing/internal/pytest_util.py create mode 100644 sphinx/testing/internal/pytest_xdist.py create mode 100644 sphinx/testing/internal/util.py create mode 100644 sphinx/testing/internal/warnings.py create mode 100644 tests/roots/test-minimal/conf.py create mode 100644 tests/roots/test-minimal/index.rst create mode 100644 tests/test_testing/__init__.py create mode 100644 tests/test_testing/_const.py create mode 100644 tests/test_testing/_util.py create mode 100644 tests/test_testing/conftest.py create mode 100644 tests/test_testing/magico.py create mode 100644 tests/test_testing/test_magico.py create mode 100644 tests/test_testing/test_plugin_isolation.py create mode 100644 tests/test_testing/test_plugin_markers.py create mode 100644 tests/test_testing/test_plugin_xdist.py create mode 100644 tests/test_testing/test_testroot_finder.py diff --git a/sphinx/testing/_xdist_hooks.py b/sphinx/testing/_xdist_hooks.py new file mode 100644 index 00000000000..c02fd0caaa7 --- /dev/null +++ b/sphinx/testing/_xdist_hooks.py @@ -0,0 +1,30 @@ +"""Hooks to register when the ``xdist`` plugin is active. + +Wen ``xdist`` is active, the controller node automatically loads +this module through :func:`sphinx.testing.plugin.pytest_addhooks`. +""" + +from __future__ import annotations + +__all__ = () + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import pytest + from xdist.workermanage import NodeManager, WorkerController + + +def pytest_configure_node(node: WorkerController) -> None: + node_config: pytest.Config = node.config + # the node's config is not the same as the controller's config + assert node_config.pluginmanager.has_plugin('xdist'), 'xdist is not loaded' + + manager: NodeManager = node.nodemanager + config: pytest.Config = manager.config + assert config.pluginmanager.has_plugin('xdist'), 'xdist is not loaded' + + # worker nodes do not inherit the 'config.option.dist' value + # when used by pytester, so we simply copy it from the main + # controller to the worker node + node.workerinput['sphinx_xdist_policy'] = config.option.dist diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index 2f85ccb0a38..00d0d3a5fb7 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -2,216 +2,429 @@ from __future__ import annotations +import dataclasses +import itertools +import os import shutil import subprocess import sys -from collections import namedtuple +import warnings from io import StringIO from typing import TYPE_CHECKING, Optional import pytest -from sphinx.testing.util import SphinxTestApp, SphinxTestAppWrapperForSkipBuilding +from sphinx.deprecation import RemovedInSphinx90Warning +from sphinx.testing.internal.cache import ModuleCache +from sphinx.testing.internal.isolation import Isolation +from sphinx.testing.internal.markers import ( + AppParams, + get_location_id, + process_isolate, + process_sphinx, + process_test_params, +) +from sphinx.testing.internal.pytest_util import TestRootFinder, find_context +from sphinx.testing.internal.pytest_xdist import is_pytest_xdist_enabled +from sphinx.testing.util import ( + SphinxTestApp, + SphinxTestAppLazyBuild, +) if TYPE_CHECKING: from collections.abc import Callable, Generator from pathlib import Path - from typing import Any + from typing import Any, Final -DEFAULT_ENABLED_MARKERS = [ + from sphinx.testing.internal.isolation import IsolationPolicy + from sphinx.testing.internal.markers import ( + TestParams, + ) + +DEFAULT_ENABLED_MARKERS: Final[list[str]] = [ ( 'sphinx(' 'buildername="html", /, *, ' - 'testroot="root", confoverrides=None, freshenv=False, ' - 'warningiserror=False, tags=None, verbosity=0, parallel=0, ' - 'keep_going=False, builddir=None, docutils_conf=None' + 'testroot="root", confoverrides=None, ' + 'freshenv=None, warningiserror=False, tags=None, ' + 'verbosity=0, parallel=0, keep_going=False, ' + 'docutils_conf=None, isolate=False' '): arguments to initialize the sphinx test application.' ), - 'test_params(shared_result=...): test parameters.', + 'test_params(*, shared_result=None): test configuration.', + 'isolate(policy=None, /): test isolation policy.', + 'sphinx_no_default_xdist(): disable the default xdist-group on tests', ] +############################################################################### +# pytest hooks +############################################################################### + + +def pytest_addhooks(pluginmanager: pytest.PytestPluginManager) -> None: + if pluginmanager.has_plugin('xdist'): + from sphinx.testing import _xdist_hooks + + pluginmanager.register(_xdist_hooks, name='sphinx-xdist-hooks') + def pytest_configure(config: pytest.Config) -> None: - """Register custom markers""" + """Register custom markers.""" for marker in DEFAULT_ENABLED_MARKERS: config.addinivalue_line('markers', marker) -@pytest.fixture(scope='session') -def rootdir() -> str | None: - return None +@pytest.hookimpl(tryfirst=True) +def pytest_collection_modifyitems( + session: pytest.Session, + config: pytest.Config, + items: list[pytest.Item], +) -> None: + if not is_pytest_xdist_enabled(config): + return + + # *** IMPORTANT *** + # + # This hook is executed by every xdist worker and the items + # are NOT shared across those workers. In particular, it is + # crucial that the xdist-group that we define later is the + # same across ALL workers. In other words, the group can + # only depend on xdist-agnostic data such as the physical + # location of a test item. + # + # In addition, custom plugins that can change the meaning + # of ``@pytest.mark.parametrize`` might break this plugin, + # so use them carefully! + + for item in items: + if ( + item.get_closest_marker('parametrize') + and item.get_closest_marker('sphinx_no_default_xdist') is None + ): + fspath, lineno, _ = item.location # this is xdist-agnostic + xdist_group = get_location_id((fspath, lineno or -1)) + item.add_marker(pytest.mark.xdist_group(xdist_group), append=True) + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_teardown(item: pytest.Item) -> Generator[None, None, None]: + yield # execute the fixtures teardowns + + # after tearing down the fixtures, we add some report sections + # for later; without ``xdist``, we would have printed whatever + # we wanted during the fixture teardown but since ``xdist`` is + # not print-friendly, we must use the report sections + + if _APP_INFO_KEY in item.stash: + info: _AppInfo = item.stash[_APP_INFO_KEY] + del item.stash[_APP_INFO_KEY] + + text = info.render() + + if ( + # do not duplicate the report info when using -rA + 'A' not in item.config.option.reportchars + and (item.config.option.capture == 'no' or item.config.get_verbosity() >= 2) + # see: https://pytest-xdist.readthedocs.io/en/stable/known-limitations.html + and not is_pytest_xdist_enabled(item.config) + ): + # use carriage returns to avoid being printed inside the progression bar + # and additionally show the node ID for visual purposes + print('\n\n', f'[{item.nodeid}]', '\n', text, sep='', end='') # NoQA: T201 + + item.add_report_section(f'teardown [{item.nodeid}]', 'fixture %r' % 'app', text) + +############################################################################### +# sphinx fixtures +############################################################################### -class SharedResult: - cache: dict[str, dict[str, str]] = {} +@pytest.fixture(scope='session') +def sphinx_test_tempdir(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Fixture for a temporary directory.""" + return tmp_path_factory.getbasetemp() - 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 sphinx_builder(request: pytest.FixtureRequest) -> str: + """Fixture for the default builder name.""" + return getattr(request, 'param', 'html') @pytest.fixture() -def app_params( - request: Any, - test_params: dict, - shared_result: SharedResult, - sphinx_test_tempdir: str, - rootdir: str, -) -> _app_params: +def sphinx_isolation() -> IsolationPolicy: + """Fixture for the default isolation policy. + + This fixture is ignored when using the legacy plugin. """ - Parameters that are specified by 'pytest.mark.sphinx' for - sphinx.application.Sphinx initialization + return False + + +@pytest.fixture() +def rootdir() -> str | os.PathLike[str] | None: + """Fixture for the directory containing the testroot directories.""" + return None + + +@pytest.fixture() +def testroot_prefix() -> str | None: + """Fixture for the testroot directories prefix. + + This fixture is ignored when using the legacy plugin. """ - # ##### process pytest.mark.sphinx + return 'test-' - 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) +@pytest.fixture() +def default_testroot() -> str | None: + """Dynamic fixture for the default testroot ID. - args = [pargs[i] for i in sorted(pargs.keys())] + This fixture is ignored when using the legacy plugin. + """ + return 'root' - # ##### 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 +@pytest.fixture() +def testroot_finder( + rootdir: str | os.PathLike[str] | None, + testroot_prefix: str | None, + default_testroot: str | None, +) -> TestRootFinder: + """Fixture for the testroot finder object.""" + return TestRootFinder(rootdir, testroot_prefix, default_testroot) - 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) +def _init_sources(src: str | None, dst: Path, isolation: Isolation) -> None: + if src is None or dst.exists(): + return - return _app_params(args, kwargs) + if not os.path.exists(src): + pytest.fail(f'no sources found at: {src!r}') + # make a copy of the testroot + shutil.copytree(src, dst) -_app_params = namedtuple('_app_params', 'args,kwargs') + # make the files read-only if isolation is not specified + # to protect the tests against some side-effects (not all + # side-effects will be prevented) + if isolation is Isolation.minimal: + for dirpath, _, filenames in os.walk(dst): + for filename in filenames: + os.chmod(os.path.join(dirpath, filename), 0o444) @pytest.fixture() -def test_params(request: Any) -> dict: +def app_params( + request: pytest.FixtureRequest, + test_params: TestParams, + module_cache: ModuleCache, + sphinx_test_tempdir: Path, + sphinx_builder: str, + sphinx_isolation: IsolationPolicy, + testroot_finder: TestRootFinder, +) -> AppParams: + """Parameters that are specified by ``pytest.mark.sphinx``. + + See :class:`sphinx.testing.util.SphinxTestApp` for the allowed parameters. """ - Test parameters that are specified by 'pytest.mark.test_params' + default_isolation = process_isolate(request.node, sphinx_isolation) + shared_result_id = test_params['shared_result'] + args, kwargs = process_sphinx( + request.node, + session_temp_dir=sphinx_test_tempdir, + testroot_finder=testroot_finder, + default_builder=sphinx_builder, + default_isolation=default_isolation, + shared_result=shared_result_id, + ) + assert shared_result_id == kwargs['shared_result'] + # restore the I/O stream values + if shared_result_id and (frame := module_cache.restore(shared_result_id)): + if kwargs.setdefault('status', frame['status']) is not frame['status']: + fmt = 'cannot use %r when %r is explicitly given' + pytest.fail(fmt % ('shared_result', 'status')) + if kwargs.setdefault('warning', frame['warning']) is not frame['warning']: + fmt = 'cannot use %r when %r is explicitly given' + pytest.fail(fmt % ('shared_result', 'warning')) + + # copy the testroot files to the test sources directory + _init_sources(kwargs['testroot_path'], kwargs['srcdir'], kwargs['isolate']) + return AppParams(args, kwargs) + + +@pytest.fixture() +def test_params(request: pytest.FixtureRequest) -> TestParams: + """Test parameters that are specified by ``pytest.mark.test_params``.""" + return process_test_params(request.node) - :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 +@dataclasses.dataclass +class _AppInfo: + """Report to render at the end of a test using the :func:`app` fixture.""" + + builder: str + """The builder name.""" + + testroot_path: str | None + """The absolute path to the sources directory (if any).""" + shared_result: str | None + """The user-defined shared result (if any).""" + + srcdir: str + """The absolute path to the application's sources directory.""" + outdir: str + """The absolute path to the application's output directory.""" + + # fields below are updated when tearing down :func:`app` + # or requesting :func:`app_test_info` (only *extras* is + # publicly exposed by the latter) + + messages: str = dataclasses.field(default='', init=False) + """The application's status messages.""" + warnings: str = dataclasses.field(default='', init=False) + """The application's warnings messages.""" + extras: dict[str, Any] = dataclasses.field(default_factory=dict, init=False) + """Attributes added by :func:`sphinx.testing.plugin.app_test_info`.""" + + def render(self) -> str: + """Format the report as a string to print or render.""" + config = [('builder', self.builder)] + if self.testroot_path: + config.append(('testroot path', self.testroot_path)) + config.extend([('srcdir', self.srcdir), ('outdir', self.outdir)]) + config.extend((name, repr(value)) for name, value in self.extras.items()) + + tw, _ = shutil.get_terminal_size() + kw = 8 + max(len(name) for name, _ in config) + + lines = itertools.chain( + [f'{" configuration ":-^{tw}}'], + (f'{name:{kw}s} {strvalue}' for name, strvalue in config), + [f'{" messages ":-^{tw}}', text] if (text := self.messages) else (), + [f'{" warnings ":-^{tw}}', text] if (text := self.warnings) else (), + ['=' * tw], + ) + return '\n'.join(lines) + + +_APP_INFO_KEY: pytest.StashKey[_AppInfo] = pytest.StashKey() + + +def _get_app_info( + request: pytest.FixtureRequest, + app: SphinxTestApp, + app_params: AppParams, +) -> _AppInfo: + # request.node.stash is not typed correctly in pytest + stash: pytest.Stash = request.node.stash + if _APP_INFO_KEY not in stash: + stash[_APP_INFO_KEY] = _AppInfo( + builder=app.builder.name, + testroot_path=app_params.kwargs['testroot_path'], + shared_result=app_params.kwargs['shared_result'], + srcdir=os.fsdecode(app.srcdir), + outdir=os.fsdecode(app.outdir), + ) + return stash[_APP_INFO_KEY] + + +@pytest.fixture() +def app_info_extras( + request: pytest.FixtureRequest, + # ``app`` is not used but is marked as a dependency + app: SphinxTestApp, + # ``app_params`` is already a dependency of ``app`` + app_params: AppParams, +) -> dict[str, Any]: + """Fixture to update the information to render at the end of a test. + + Use this fixture in a ``conftest.py`` file or in a test file as follows:: + + @pytest.fixture(autouse=True) + def _add_app_info_extras(app, app_info_extras): + app_info_extras.update(my_extra=1234) + app_info_extras.update(app_extras=app.extras) + """ + app_info = _get_app_info(request, app, app_params) + return app_info.extras @pytest.fixture() def app( - test_params: dict, - app_params: tuple[dict, dict], - make_app: Callable, - shared_result: SharedResult, + request: pytest.FixtureRequest, + app_params: AppParams, + make_app: Callable[..., SphinxTestApp], + module_cache: ModuleCache, ) -> Generator[SphinxTestApp, None, None]: - """ - Provides the 'sphinx.application.Sphinx' object - """ - args, kwargs = app_params - app_ = make_app(*args, **kwargs) - yield app_ + """A :class:`sphinx.application.Sphinx` object suitable for testing.""" + # the 'app_params' fixture already depends on the 'test_result' fixture + shared_result = app_params.kwargs['shared_result'] + app = make_app(*app_params.args, **app_params.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()) + info = _get_app_info(request, app, app_params) + # update the messages accordingly + info.messages = app.status.getvalue() + info.warnings = app.warning.getvalue() - if test_params['shared_result']: - shared_result.store(test_params['shared_result'], app_) + if shared_result is not None: + module_cache.store(shared_result, app) @pytest.fixture() def status(app: SphinxTestApp) -> StringIO: - """ - Back-compatibility for testing with previous @with_app decorator - """ + """Fixture for the :func:`~sphinx.testing.plugin.app` status stream.""" return app.status @pytest.fixture() def warning(app: SphinxTestApp) -> StringIO: - """ - Back-compatibility for testing with previous @with_app decorator - """ + """Fixture for the :func:`~sphinx.testing.plugin.app` warning stream.""" 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.copy() +def make_app(test_params: TestParams) -> Generator[Callable[..., SphinxTestApp], None, None]: + """Fixture to create :class:`~sphinx.testing.util.SphinxTestApp` objects.""" + stack: list[SphinxTestApp] = [] + allow_rebuild = test_params['shared_result'] is None def make(*args: Any, **kwargs: Any) -> SphinxTestApp: - 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 + if allow_rebuild: + app = SphinxTestApp(*args, **kwargs) + else: + app = SphinxTestAppLazyBuild(*args, **kwargs) + stack.append(app) + return app + syspath = sys.path.copy() + yield make sys.path[:] = syspath - for app_ in reversed(apps): # clean up applications from the new ones - app_.cleanup() + + while stack: + stack.pop().cleanup() + + +_MODULE_CACHE_STASH_KEY: pytest.StashKey[ModuleCache] = pytest.StashKey() @pytest.fixture() -def shared_result() -> SharedResult: - return SharedResult() +def module_cache(request: pytest.FixtureRequest) -> ModuleCache: + """A :class:`ModuleStorage` object.""" + module = find_context(request.node, 'module') + return module.stash.setdefault(_MODULE_CACHE_STASH_KEY, ModuleCache()) @pytest.fixture(scope='module', autouse=True) -def _shared_result_cache() -> None: - SharedResult.cache.clear() +def _module_cache_clear(request: pytest.FixtureRequest) -> None: + """Cleanup the shared result cache for the test module. + + This fixture is automatically invoked. + """ + module = find_context(request.node, 'module') + cache = module.stash.get(_MODULE_CACHE_STASH_KEY, None) + if cache is not None: + cache.clear() @pytest.fixture() @@ -278,12 +491,6 @@ def test_if_host_is_online(): ... pytest.skip('host appears to be offline (%s)' % error) -@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() -> Generator[None, None, None]: # NoQA: PT004 """ @@ -293,10 +500,55 @@ def rollback_sysmodules() -> Generator[None, None, None]: # NoQA: PT004 For example, used in test_ext_autosummary.py to permit unloading the target module to clear its cache. """ - sysmodules = list(sys.modules) + sysmodules = frozenset(sys.modules) try: yield finally: for modname in list(sys.modules): if modname not in sysmodules: sys.modules.pop(modname) + + +############################################################################### +# sphinx deprecated fixtures +############################################################################### + + +# XXX: RemovedInSphinx90Warning +class SharedResult: + cache: dict[str, dict[str, str]] = {} + + def __init__(self) -> None: + warnings.warn("this object is deprecated and will be removed in the future", + RemovedInSphinx90Warning, stacklevel=2) + + 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 shared_result() -> SharedResult: + warnings.warn("this fixture is deprecated; use 'module_cache' instead", + RemovedInSphinx90Warning, stacklevel=2) + return SharedResult() + + +@pytest.fixture(scope='module', autouse=True) +def _shared_result_cache() -> None: + # XXX: RemovedInSphinx90Warning + SharedResult.cache.clear() diff --git a/sphinx/testing/internal/__init__.py b/sphinx/testing/internal/__init__.py new file mode 100644 index 00000000000..c87b0b8c613 --- /dev/null +++ b/sphinx/testing/internal/__init__.py @@ -0,0 +1,6 @@ +"""This package contains implementation details for the Sphinx testing plugin. + +All modules in this package are considered an implementation detail +and any provided functionality can be altered, removed or introduce +breaking changes without prior notice. +""" diff --git a/sphinx/testing/internal/cache.py b/sphinx/testing/internal/cache.py new file mode 100644 index 00000000000..d8526669f72 --- /dev/null +++ b/sphinx/testing/internal/cache.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +__all__ = () + +from io import StringIO +from typing import TYPE_CHECKING, TypedDict + +if TYPE_CHECKING: + from sphinx.testing.util import SphinxTestApp + + +class _CacheEntry(TypedDict): + """Cached entry in a :class:`SharedResult`.""" + + status: str + """The application's status output.""" + warning: str + """The application's warning output.""" + + +class _CacheFrame(TypedDict): + """The restored cached value.""" + + status: StringIO + """An I/O object initialized to the cached status output.""" + warning: StringIO + """An I/O object initialized to the cached warning output.""" + + +class ModuleCache: + __slots__ = ('_cache',) + + def __init__(self) -> None: + self._cache: dict[str, _CacheEntry] = {} + + def clear(self) -> None: + """Clear the cache.""" + self._cache.clear() + + def store(self, key: str, app: SphinxTestApp) -> None: + """Cache some attributes from *app* in the cache. + + :param key: The cache key (usually a ``shared_result``). + :param app: An application whose attributes are cached. + + The application's attributes being cached are: + + * The content of :attr:`~sphinx.testing.util.SphinxTestApp.status`. + * The content of :attr:`~sphinx.testing.util.SphinxTestApp.warning`. + """ + if key not in self._cache: + status, warning = app.status.getvalue(), app.warning.getvalue() + self._cache[key] = {'status': status, 'warning': warning} + + def restore(self, key: str) -> _CacheFrame | None: + """Reconstruct the cached attributes for *key*.""" + if key not in self._cache: + return None + + data = self._cache[key] + return {'status': StringIO(data['status']), 'warning': StringIO(data['warning'])} diff --git a/sphinx/testing/internal/isolation.py b/sphinx/testing/internal/isolation.py new file mode 100644 index 00000000000..e856b9569b1 --- /dev/null +++ b/sphinx/testing/internal/isolation.py @@ -0,0 +1,46 @@ +"""Private module containing isolation-related objects and functionalities. + +Use literal strings or booleans to indicate isolation policies instead of +directly using :class:`Isolation` objects, unless it is used internally. +""" + +from __future__ import annotations + +__all__ = () + +from enum import IntEnum +from enum import auto as _auto +from typing import Literal, Union + + +class Isolation(IntEnum): + """Isolation policy for the testing application.""" + + minimal = _auto() + """Minimal isolation mode.""" + grouped = _auto() + """Similar to :attr:`always` but for parametrized tests.""" + always = _auto() + """Copy the original testroot to a unique sources and build directory.""" + + +IsolationPolicy = Union[bool, Literal['minimal', 'grouped', 'always']] +"""Allowed values for the isolation policy.""" + +NormalizableIsolation = Union[IsolationPolicy, Isolation] +"""Normalizable isolation value.""" + + +def normalize_isolation_policy(policy: NormalizableIsolation) -> Isolation: + """Normalize isolation policy into a :class:`Isolation` object.""" + if isinstance(policy, Isolation): + return policy + + if isinstance(policy, bool): + return Isolation.always if policy else Isolation.minimal + + if isinstance(policy, str) and hasattr(Isolation, policy): + return getattr(Isolation, policy) + + msg = f'unknown isolation policy: {policy!r}' + raise TypeError(msg) diff --git a/sphinx/testing/internal/markers.py b/sphinx/testing/internal/markers.py new file mode 100644 index 00000000000..f77ac22380b --- /dev/null +++ b/sphinx/testing/internal/markers.py @@ -0,0 +1,306 @@ +"""Private utililty functions for markers in :mod:`sphinx.testing.plugin`. + +This module is an implementation detail and any provided function +or class can be altered, removed or moved without prior notice. +""" + +from __future__ import annotations + +__all__ = () + +from pathlib import Path +from typing import TYPE_CHECKING, NamedTuple, TypedDict, cast + +import pytest + +from sphinx.testing.internal.isolation import Isolation, normalize_isolation_policy +from sphinx.testing.internal.pytest_util import ( + check_mark_keywords, + check_mark_str_args, + format_mark_failure, + get_mark_parameters, + get_node_location, +) +from sphinx.testing.internal.util import ( + get_environ_checksum, + get_location_id, + get_namespace_id, + make_unique_id, +) + +if TYPE_CHECKING: + import os + from io import StringIO + from typing import Any + + from _pytest.nodes import Node as PytestNode + from typing_extensions import Required + + from sphinx.testing.internal.isolation import NormalizableIsolation + from sphinx.testing.internal.pytest_util import TestRootFinder + + +class SphinxMarkEnviron(TypedDict, total=False): + """Typed dictionary for the arguments of :func:`pytest.mark.sphinx`. + + Note that this class differs from :class:`SphinxInitKwargs` since it + reflects the signature of the :func:`pytest.mark.sphinx` marker, but + not of the :class:`~sphinx.testing.util.SphinxTestApp` constructor. + """ + + buildername: str + confoverrides: dict[str, Any] + # using freshenv=True will be treated as equivalent to use isolate=True + # but in the future, we might want to deprecate this marker keyword in + # favor of "isolate" (that way, we don't need to maintain it) + freshenv: bool + warningiserror: bool + tags: list[str] + verbosity: int + parallel: int + keep_going: bool + docutils_conf: str + + # added or updated fields + testroot: str | None + isolate: NormalizableIsolation + + +class SphinxInitKwargs(TypedDict, total=False): + """The type of the keyword arguments after processing. + + Such objects are constructed from :class:`SphinxMarkEnviron` objects. + """ + + # :class:`sphinx.application.Sphinx` positional arguments as keywords + buildername: Required[str] + """The deduced builder name.""" + # :class:`sphinx.application.Sphinx` required arguments + srcdir: Required[Path] + """Absolute path to the test sources directory. + + The uniqueness of this path depends on the isolation policy, + the location of the test and the application's configuration. + """ + # :class:`sphinx.application.Sphinx` optional arguments + confoverrides: dict[str, Any] | None + status: StringIO | None + warning: StringIO | None + freshenv: bool + warningiserror: bool + tags: list[str] | None + verbosity: int + parallel: int + keep_going: bool + # :class:`sphinx.testing.util.SphinxTestApp` optional arguments + docutils_conf: str | None + builddir: Path | None + # :class:`sphinx.testing.util.SphinxTestApp` extras arguments + isolate: Required[Isolation] + """The deduced isolation policy.""" + testroot: Required[str | None] + """The deduced testroot ID (possibly None if the default ID is not set).""" + testroot_path: Required[str | None] + """The absolute path to the testroot directory, if any.""" + shared_result: Required[str | None] + """The optional shared result ID.""" + + +class AppParams(NamedTuple): + """The processed arguments of :func:`pytest.mark.sphinx`. + + The *args* and *kwargs* values can be directly used as inputs + to the :class:`~sphinx.testing.util.SphinxTestApp` constructor. + """ + + args: list[Any] + """The constructor positional arguments, except ``buildername``.""" + kwargs: SphinxInitKwargs + """The constructor keyword arguments, including ``buildername``.""" + + +class TestParams(TypedDict): + """A view on the arguments of :func:`pytest.mark.test_params`.""" + + shared_result: str | None + + +def _get_sphinx_environ(node: PytestNode, default_builder: str) -> SphinxMarkEnviron: + args, kwargs = get_mark_parameters(node, 'sphinx') + + if len(args) > 1: + err = 'expecting at most one positional argument' + pytest.fail(format_mark_failure('sphinx', err)) + + env = cast(SphinxMarkEnviron, kwargs) + if env.pop('buildername', None) is not None: + err = '%r is a positional-only argument' % 'buildername' + pytest.fail(format_mark_failure('sphinx', err)) + + env['buildername'] = buildername = args[0] if args else default_builder + + if not buildername: + err = 'missing builder name, got: %r' % buildername + pytest.fail(format_mark_failure('sphinx', err)) + + check_mark_keywords('sphinx', SphinxMarkEnviron.__annotations__, env, node=node) + return env + + +def _get_test_srcdir(testroot: str | None, shared_result: str | None) -> str: + """Deduce the sources directory from the given arguments. + + :param testroot: An optional testroot ID to use. + :param shared_result: An optional shared result ID. + :return: The sources directory name *srcdir* (non-empty string). + """ + check_mark_str_args('sphinx', testroot=testroot) + check_mark_str_args('test_params', shared_result=shared_result) + + if shared_result is not None: + # include the testroot id for visual purposes (unless it is + # not specified, which only occurs when there is no rootdir) + return f'{testroot}-{shared_result}' if testroot else shared_result + + if testroot is None: + # neither an explicit nor the default testroot ID is given + pytest.fail('missing %r or %r parameter' % ('testroot', 'srcdir')) + return testroot + + +def process_sphinx( + node: PytestNode, + session_temp_dir: str | os.PathLike[str], + testroot_finder: TestRootFinder, + default_builder: str, + default_isolation: NormalizableIsolation, + shared_result: str | None, +) -> tuple[list[Any], SphinxInitKwargs]: + """Process the :func:`pytest.mark.sphinx` marker. + + :param node: The pytest node to parse. + :param session_temp_dir: The session temporary directory. + :param testroot_finder: The testroot finder object. + :param default_builder: The application default builder name. + :param default_isolation: The isolation default policy. + :param shared_result: An optional shared result ID. + :return: The application positional and keyword arguments. + """ + # 1. process pytest.mark.sphinx + env = _get_sphinx_environ(node, default_builder) + # 1.1a. deduce the isolation policy from freshenv if possible + freshenv: bool | None = env.pop('freshenv', None) + if freshenv is not None: + if 'isolate' in env: + err = '%r and %r are mutually exclusive' % ('freshenv', 'isolate') + pytest.fail(format_mark_failure('sphinx', err)) + + # If 'freshenv=True', we switch to a full isolation; otherwise, + # we keep 'freshenv=False' and use the default isolation (note + # that 'isolate' is not specified, so we would have still used + # the default isolation). + isolation = env['isolate'] = Isolation.always if freshenv else default_isolation + else: + freshenv = env['freshenv'] = False + + # 1.1b. deduce the final isolation policy + isolation = env.setdefault('isolate', default_isolation) + isolation = env['isolate'] = normalize_isolation_policy(isolation) + # 1.2. deduce the testroot ID + testroot_id = env['testroot'] = env.get('testroot', testroot_finder.default) + # 1.3. deduce the srcdir ID + srcdir = _get_test_srcdir(testroot_id, shared_result) + + # 2. process the srcdir ID according to the isolation policy + if isolation is Isolation.always: + srcdir = make_unique_id(srcdir) + elif isolation is Isolation.grouped: + if (location := get_node_location(node)) is None: + srcdir = make_unique_id(srcdir) + else: + # For a 'grouped' isolation, we want the same prefix (the deduced + # sources dierctory), but with a unique suffix based on the node + # location. In particular, parmetrized tests will have the same + # final ``srcdir`` value as they have the same location. + suffix = get_location_id(location) + srcdir = f'{srcdir}-{suffix}' + + # Do a somewhat hash on configuration values to give a minimal protection + # against side-effects (two tests with the same configuration should have + # the same output; if they mess up with their sources directory, then they + # should be isolated accordingly). If there is a bug in the test suite, we + # can reduce the number of tests that can have dependencies by adding some + # isolation safeguards. + testhash = get_namespace_id(node) + checksum = 0 if isolation is Isolation.always else get_environ_checksum( + env['buildername'], + # The default values must be kept in sync with the constructor + # default values of :class:`sphinx.testing.util.SphinxTestApp`. + env.get('confoverrides'), + env.get('freshenv', False), + env.get('warningiserror', False), + env.get('tags'), + env.get('verbosity', 0), + env.get('parallel', 0), + env.get('keep_going', False), + ) + + kwargs = cast(SphinxInitKwargs, env) + kwargs['srcdir'] = Path(session_temp_dir, testhash, str(checksum), srcdir) + kwargs['testroot_path'] = testroot_finder.find(testroot_id) + kwargs['shared_result'] = shared_result + return [], kwargs + + +def process_test_params(node: PytestNode) -> TestParams: + """Process the :func:`pytest.mark.test_params` marker. + + :param node: The pytest node to parse. + :return: The desired keyword arguments. + """ + ret = TestParams(shared_result=None) + if (m := node.get_closest_marker('test_params')) is None: + return ret + + if m.args: + pytest.fail(format_mark_failure('test_params', 'unexpected positional argument')) + + check_mark_keywords( + 'test_params', TestParams.__annotations__, + kwargs := m.kwargs, node=node, strict=True, + ) + + if (shared_result_id := kwargs.get('shared_result', None)) is None: + # generate a random shared_result for @pytest.mark.test_params() + # based on either the location of node (so that it is the same + # when using @pytest.mark.parametrize()) + if (location := get_node_location(node)) is None: + shared_result_id = make_unique_id() + else: + shared_result_id = get_location_id(location) + + ret['shared_result'] = shared_result_id + return ret + + +def process_isolate(node: PytestNode, default: NormalizableIsolation) -> NormalizableIsolation: + """Process the :func:`pytest.mark.isolate` marker. + + :param node: The pytest node to parse. + :param default: The default isolation policy given by an external fixture. + :return: The isolation policy given by the marker. + """ + # try to find an isolation policy from the 'isolate' marker + if m := node.get_closest_marker('isolate'): + # do not allow keyword arguments + check_mark_keywords('isolate', [], m.kwargs, node=node, strict=True) + if not m.args: + # isolate() is equivalent to a full isolation + return Isolation.always + + if len(m.args) == 1: + return normalize_isolation_policy(m.args[0]) + + err = 'expecting at most one positional argument' + pytest.fail(format_mark_failure('isolate', err)) + return default diff --git a/sphinx/testing/internal/pytest_util.py b/sphinx/testing/internal/pytest_util.py new file mode 100644 index 00000000000..047b83c9e94 --- /dev/null +++ b/sphinx/testing/internal/pytest_util.py @@ -0,0 +1,417 @@ +"""Internal utility functions for interacting with pytest. +""" + +from __future__ import annotations + +__all__ = () + +import os +import warnings +from contextlib import contextmanager +from typing import TYPE_CHECKING, Literal, TypeVar, overload + +import pytest +from _pytest.nodes import Node as PytestNode +from _pytest.nodes import get_fslocation_from_item + +from sphinx.testing.internal.warnings import MarkWarning, NodeWarning, SphinxTestingWarning + +if TYPE_CHECKING: + from collections.abc import Callable, Collection, Generator, Iterable + from typing import Any, ClassVar, Final, NoReturn + + T = TypeVar('T') + DT = TypeVar('DT') + NodeType = TypeVar('NodeType', bound="PytestNode") + + +class TestRootFinder: + """Object responsible for finding the testroot files in *rootdir*. + + For instance:: + + finder = TestRootFinder('/foo/bar', 'test-', 'default') + + describes a testroot root directory at ``/foo/bar/roots``. The name of the + directories in ``/foo/bar/roots`` consist of a *prefix* and an *ID* (in + this case, the prefix is ``test-`` and the default *ID* is ``default``). + + >>> finder = TestRootFinder('/foo/bar', 'test-', 'default') + >>> finder.find() + '/foo/bar/test-default' + >>> finder.find('abc') + '/foo/bar/test-abc' + """ + + # This is still needed even if sphinx.testing.internal.__test__ is False + # because when this class is imported by pytest, it is considered a test. + __test__: ClassVar[Literal[False]] = False + + def __init__( + self, + path: str | os.PathLike[str] | None = None, + prefix: str | None = None, + default: str | None = None, + ) -> None: + """Construct a :class:`TestRootFinder` object. + + :param path: Optional non-empty root path containing the testroots. + :param prefix: Optional prefix to prepend to a testroot ID. + :param default: Optional non-empty string for a default testroot ID. + :raise ValueError: Empty strings are given instead of ``None``. + """ + for arg, val in (('path', path), ('default', default)): + if not val and val is not None: + msg = 'expecting a non-empty string or None for %r' + raise ValueError(msg % arg) + + self.path: str | None = os.fsdecode(path) if path else None + + assert prefix is None or isinstance(prefix, str) + self.prefix: str = prefix or '' + + assert default is None or isinstance(default, str) + self.default: str | None = default + + def find(self, testroot_id: str | None = None) -> str | None: + """Find the sources directory for a named or the default testroot. + + :param testroot_id: A testroot ID (non-prefixed string). + :return: The path to the testroot directory, if any. + """ + if not (path := self.path): + return None + + if not (testroot_id := testroot_id or self.default): + return None + + # upon construction, we ensured that 'prefix' is empty if None + return os.path.join(path, f'{self.prefix}{testroot_id}') + + +ScopeName = Literal["session", "package", "module", "class", "function"] +"""Pytest scopes.""" + +_NODE_TYPE_BY_SCOPE: Final[dict[ScopeName, type[PytestNode]]] = { + 'session': pytest.Session, + 'package': pytest.Package, + 'module': pytest.Module, + 'class': pytest.Class, + 'function': pytest.Function, +} + + +# fmt: off +@overload +def get_node_type_by_scope(scope: Literal['session']) -> type[pytest.Session]: ... # NoQA: E501, E704 +@overload +def get_node_type_by_scope(scope: Literal['package']) -> type[pytest.Package]: ... # NoQA: E501, E704 +@overload +def get_node_type_by_scope(scope: Literal['module']) -> type[pytest.Module]: ... # NoQA: E704 +@overload +def get_node_type_by_scope(scope: Literal['class']) -> type[pytest.Class]: ... # NoQA: E704 +@overload +def get_node_type_by_scope(scope: Literal['function']) -> type[pytest.Function]: ... # NoQA: E501, E704 +# fmt: on +def get_node_type_by_scope(scope: ScopeName) -> type[PytestNode]: # NoQA: E302 + """Get a pytest node type by its scope. + + :param scope: The scope name. + :return: The corresponding pytest node type. + """ + return _NODE_TYPE_BY_SCOPE[scope] + + +# fmt: off +@overload +def find_context(node: PytestNode, cond: Literal['session'], /, *, include_self: bool = ...) -> pytest.Session: ... # NoQA: E501, E704 +@overload +def find_context(node: PytestNode, cond: Literal['package'], /, *, include_self: bool = ...) -> pytest.Package: ... # NoQA: E501, E704 +@overload +def find_context(node: PytestNode, cond: Literal['module'], /, *, include_self: bool = ...) -> pytest.Module: ... # NoQA: E501, E704 +@overload +def find_context(node: PytestNode, cond: Literal['class'], /, *, include_self: bool = ...) -> pytest.Class: ... # NoQA: E501, E704 +@overload +def find_context(node: PytestNode, cond: Literal['function'], /, *, include_self: bool = ...) -> pytest.Function: ... # NoQA: E501, E704 +@overload +def find_context(node: PytestNode, cond: ScopeName, /, *, include_self: bool = ...) -> PytestNode: ... # NoQA: E501, E704 +@overload +def find_context(node: PytestNode, cond: Literal['session'], default: DT, /, *, include_self: bool = ...) -> pytest.Session | DT: ... # NoQA: E501, E704 +@overload +def find_context(node: PytestNode, cond: Literal['package'], default: DT, /, *, include_self: bool = ...) -> pytest.Package | DT: ... # NoQA: E501, E704 +@overload +def find_context(node: PytestNode, cond: Literal['module'], default: DT, /, *, include_self: bool = ...) -> pytest.Module | DT: ... # NoQA: E501, E704 +@overload +def find_context(node: PytestNode, cond: Literal['class'], default: DT, /, *, include_self: bool = ...) -> pytest.Class | DT: ... # NoQA: E501, E704 +@overload +def find_context(node: PytestNode, cond: Literal['function'], default: DT, /, *, include_self: bool = ...) -> pytest.Function | DT: ... # NoQA: E501, E704 +@overload +def find_context(node: PytestNode, cond: ScopeName, default: DT, /, *, include_self: bool = ...) -> PytestNode | DT: ... # NoQA: E501, E704 +@overload +def find_context(node: PytestNode, cond: type[NodeType], /, *, include_self: bool = ...) -> NodeType: ... # NoQA: E501, E704 +@overload +def find_context(node: PytestNode, cond: type[NodeType], default: DT, /, *, include_self: bool = ...) -> NodeType | DT: ... # NoQA: E501, E704 +# fmt: on +def find_context( # NoQA: E302 + node: Any, + cond: ScopeName | type[PytestNode], + /, + *default: Any, + include_self: bool = True, +) -> Any: + """Get a parent node in the given scope. + + Use this function to have a correct typing of the returned object, + until ``pytest`` provides a better typing. + + :param node: The node to get an ancestor of. + :param cond: The ancestor type or scope. + :param default: A default value. + :param include_self: Include *node* if possible. + :return: A node in the ancestor chain (possibly *node*) of desired type. + """ + if isinstance(cond, str): + cond = get_node_type_by_scope(cond) + + parent = node.getparent(cond) + if parent is None or parent is node and not include_self: + if default: + return default[0] + msg = f'no parent of type {cond} for {node}' + raise AttributeError(msg) + return parent + + +TestNodeLocation = tuple[str, int] +"""The location ``(fspath, lineno)`` of a pytest node. + +* The *fspath* is relative to :attr:`pytest.Config.rootpath`. +* The line number is a 0-based integer. +""" + + +def get_node_location(node: PytestNode) -> TestNodeLocation | None: + """The node location ``(fspath, lineno)``, if any. + + If the path or the line number cannot be deduced, a warning is emitted. + + When deduced, the line number is a 0-based integer. + """ + path, lineno = get_fslocation_from_item(node) + if not (path := os.fsdecode(path)) or lineno == -1 or lineno is None: + msg = f'could not obtain node location for {node!r}' + warnings.warn_explicit(msg, category=NodeWarning, filename=path, lineno=-1) + return None + return path, lineno + + +def get_mark_parameters( + node: PytestNode, + marker: str, + /, + *default_args: Any, + **default_kwargs: Any, +) -> tuple[list[Any], dict[str, Any]]: + """Get the positional and keyword arguments of node. + + :param node: The pytest node to analyze. + :param marker: The name of the marker to extract the parameters of. + :param default_args: Optional default positional arguments. + :param default_kwargs: Optional default keyword arguments. + :return: The positional and keyword arguments. + + By convention, arguments are not stacked and are collected in + the *reverse* order the marker decorators are specified, e.g.:: + + @pytest.mark.foo('ignored', 2, a='ignored', b=2) + @pytest.mark.foo(1, a=1) + def test(request): + args, kwargs = get_mark_parameters(request.node, 'foo') + assert args == [1, 2] + assert kwargs == {'a': 1, 'b': 2} + """ + args, kwargs = list(default_args), default_kwargs + for info in reversed(list(node.iter_markers(marker))): + args[:len(info.args)] = info.args + kwargs |= info.kwargs + return args, kwargs + + +def check_mark_keywords( + mark: str, + expect: Collection[str], + actual: Iterable[str], + *, + node: PytestNode | None = None, + ignore_private: bool = False, + strict: bool = False, +) -> bool: + """Check the keyword arguments. + + :param mark: The name of the marker being checked. + :param expect: The marker expected keyword parameter names. + :param actual: The keyword arguments to check. + :param node: Optional node to emit warnings upon invalid arguments. + :param ignore_private: Ignore keyword arguments with leading underscores. + :param strict: If true, raises an exception instead of a warning. + :return: Indicate if the keyword arguments were recognized or not. + + >>> check_mark_keywords('_', ['a', 'b'], {'a': 1, 'b': 2, 'c': 3}) + False + >>> check_mark_keywords('_', ['a', 'b'], {'a': 1, 'b': 2, '_private': 3}, + ... ignore_private=True) + True + """ + extras = sorted( + key for key in set(actual).difference(expect) + if not (key.startswith('_') and ignore_private) + ) + if extras and node: + msg = 'unexpected keyword argument(s): %s' % ', '.join(sorted(extras)) + if strict: + pytest.fail(format_mark_failure(mark, msg)) + + issue_warning(node, MarkWarning(msg, mark)) + return False + return len(extras) == 0 + + +def check_mark_str_args(mark: str, /, **kwargs: Any) -> None: + """Check that marker string arguments are either None or non-empty. + + :param mark: The marker name. + :param kwargs: A mapping of marker argument names and their values. + :raise pytest.Failed: The validation failed. + """ + for argname, value in kwargs.items(): + if value and not isinstance(value, str) or not value and value is not None: + fmt = "expecting a non-empty string or None for %r, got: %r" + pytest.fail(format_mark_failure(mark, fmt % (argname, value))) + + +def stack_pytest_markers( + marker: pytest.MarkDecorator, /, *markers: pytest.MarkDecorator, +) -> Callable[[Callable[..., None]], Callable[..., None]]: + """Create a decorator stacking pytest markers.""" + stack = [marker, *markers] + stack.reverse() + + def wrapper(func: Callable[..., None]) -> Callable[..., None]: + for marker in stack: + func = marker(func) + return func + + return wrapper + + +@contextmanager +def pytest_not_raises(*exceptions: type[BaseException]) -> Generator[None, None, None]: + """Context manager asserting that no exception is raised.""" + try: + yield + except exceptions as exc: + pytest.fail(f'DID RAISE {exc.__class__}') + + +# fmt: off +@overload +def issue_warning(config: pytest.Config, warning: Warning, /) -> None: ... # NoQA: E704 +@overload +def issue_warning(config: pytest.Config, fmt: Any, /, *args: Any, category: type[Warning] | None = ...) -> None: ... # NoQA: E501, E704 +@overload +def issue_warning(request: pytest.FixtureRequest, warning: Warning, /) -> None: ... # NoQA: E501, E704 +@overload +def issue_warning(request: pytest.FixtureRequest, fmt: Any, /, *args: Any, category: type[Warning] | None = ...) -> None: ... # NoQA: E501, E704 +@overload +def issue_warning(node: PytestNode, warning: Warning, /) -> None: ... # NoQA: E704 +@overload +def issue_warning(node: PytestNode, fmt: Any, /, *args: Any, category: type[Warning] | None = ...) -> None: ... # NoQA: E501, E704 +# fmt: on +def issue_warning( # NoQA: E302 + ctx: Any, fmt: Any, /, *args: Any, category: type[Warning] | None = None, +) -> None: + """Public helper for emitting a warning on a pytest object. + + This is typically useful for debugging when plugins capturing ``print`` + such as ``xdist`` are active. Warnings are (apparently) always printed + on the console. + """ + if isinstance(fmt, Warning): + warning = fmt + else: + message = str(fmt) + if args: # allow str(fmt) to contain '%s' + message = message % args + warning = SphinxTestingWarning(message) if category is None else category(message) + + if isinstance(ctx, pytest.Config): + ctx.issue_config_time_warning(warning, stacklevel=2) + return + + node = ctx.node if isinstance(ctx, pytest.FixtureRequest) else ctx + if not isinstance(node, PytestNode): + err = f'expecting a session, a fixture request or a pytest node, got {node!r}' + raise TypeError(err) + + with warnings.catch_warnings(): + warnings.simplefilter('ignore', NodeWarning) + location = get_node_location(node) + + if location is None: + filename = os.fsdecode(node.path or 'unknown location') + lineno = -1 + else: + filename, lineno = location + lineno = lineno + 1 + + warnings.warn_explicit(warning, category=None, filename=filename, lineno=lineno) + + +def format_mark_failure(mark: str, message: str) -> str: + return f'pytest.mark.{mark}(): {message}' + + +############################################################################### +# _pytest.config.Config accessor +############################################################################### + + +def get_pytest_config( + subject: pytest.Config | pytest.FixtureRequest | PytestNode, /, +) -> pytest.Config: + """Get the underlying pytest configuration of the *subject*.""" + if isinstance(subject, pytest.Config): + return subject + + config = getattr(subject, 'config', None) + if config is None or not isinstance(config, pytest.Config): + msg = f'no configuration accessor for {type(subject)} objects' + raise TypeError(msg) + return config + + +############################################################################### +# _pytest.tempdir.TempPathFactory accessor +############################################################################### + +_DT = TypeVar('_DT') + + +# fmt: off +@overload +def get_tmp_path_factory(subject: Any, /) -> pytest.TempPathFactory: ... # NoQA: E704 +@overload +def get_tmp_path_factory(subject: Any, default: _DT, /) -> pytest.TempPathFactory | _DT: ... # NoQA: E501, E704 +# fmt: on +def get_tmp_path_factory(subject: Any, /, *default: Any) -> Any: # NoQA: E302 + """Get the optional underlying path factory of the *subject*.""" + config = get_pytest_config(subject) + factory = getattr(config, '_tmp_path_factory', None) + if factory is None: + if default: + return default[0] + + msg = f'cannot extract the underlying temporary path factory from {subject!r}' + raise AttributeError(msg) + assert isinstance(factory, pytest.TempPathFactory) + return factory diff --git a/sphinx/testing/internal/pytest_xdist.py b/sphinx/testing/internal/pytest_xdist.py new file mode 100644 index 00000000000..5ec5412af7e --- /dev/null +++ b/sphinx/testing/internal/pytest_xdist.py @@ -0,0 +1,73 @@ +"""Private utilities for the `pytest-xdist`_ plugin. + +.. _pytest-xdist: https://pytest-xdist.readthedocs.io + +All functions in this module have an undefined behaviour if they are +called before the ``pytest_cmdline_main`` hook. +""" + +from __future__ import annotations + +__all__ = () + +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + import pytest + +#: Scheduling policy for :mod:`xdist` specified by :option:`!--dist`. +Policy = Literal['no', 'each', 'load', 'loadscope', 'loadfile', 'loadgroup', 'worksteal'] + + +def get_xdist_policy(config: pytest.Config) -> Policy: + """Get the ``config.option.dist`` value even if :mod:`!xdist` is absent. + + Use ``get_xdist_policy(config) != 'no'`` to determine whether the plugin + is active and loaded or not. + """ + # On systems without the :mod:`!xdist` module, the ``dist`` option does + # not even exist in the first place and thus using ``config.option.dist`` + # would raise an :exc:`AttributeError`. + if config.pluginmanager.has_plugin('xdist'): + # worker nodes do not inherit the 'config.option.dist' value + # when used by pytester, but since we have a hook that adds + # them as a worker input, we can retrieve it correctly even + # if we are not in the controller node + if hasattr(config, 'workerinput'): + return config.workerinput['sphinx_xdist_policy'] + return config.option.dist + return 'no' + + +def is_pytest_xdist_enabled(config: pytest.Config) -> bool: + """Check that the :mod:`!xdist` plugin is loaded and active. + + :param config: A pytest configuration object. + """ + return get_xdist_policy(config) != 'no' + + +def is_pytest_xdist_controller(config: pytest.Config) -> bool: + """Check if the configuration is attached to the xdist controller. + + If the :mod:`!xdist` plugin is not active, this returns ``False``. + + .. important:: + + This function differs from :func:`xdist.is_xdist_worker` in the + sense that it works even if the :mod:`xdist` plugin is inactive. + """ + return is_pytest_xdist_enabled(config) and not is_pytest_xdist_worker(config) + + +def is_pytest_xdist_worker(config: pytest.Config) -> bool: + """Check if the configuration is attached to a xdist worker. + + If the :mod:`!xdist` plugin is not active, this returns ``False``. + + .. important:: + + This function differs from :func:`xdist.is_xdist_controller` in the + sense that it works even if the :mod:`xdist` plugin is inactive. + """ + return is_pytest_xdist_enabled(config) and hasattr(config, 'workerinput') diff --git a/sphinx/testing/internal/util.py b/sphinx/testing/internal/util.py new file mode 100644 index 00000000000..c165225f901 --- /dev/null +++ b/sphinx/testing/internal/util.py @@ -0,0 +1,101 @@ +"""Private utililty functions for :mod:`sphinx.testing.plugin`. + +This module is an implementation detail and any provided function +or class can be altered, removed or moved without prior notice. +""" + +from __future__ import annotations + +__all__ = () + +import binascii +import json +import os +import pickle +import uuid +from functools import lru_cache +from typing import TYPE_CHECKING, overload + +import pytest + +if TYPE_CHECKING: + from typing import Any + + from _pytest.nodes import Node as PytestNode + + from sphinx.testing.internal.pytest_util import TestNodeLocation + + +# fmt: off +@overload +def make_unique_id() -> str: ... # NoQA: E704 +@overload +def make_unique_id(prefix: str | os.PathLike[str]) -> str: ... # NoQA: E704 +# fmt: on +def make_unique_id(prefix: str | os.PathLike[str] | None = None) -> str: # NoQA: E302 + r"""Generate a unique identifier prefixed by *prefix*. + + :param prefix: An optional prefix to prepend to the unique identifier. + :return: A unique identifier. + + .. note:: + + The probability for generating two identical IDs is negligible + and happens with the same probability as + """ + suffix = uuid.uuid4().hex + return '-'.join((os.fsdecode(prefix), suffix)) if prefix else suffix + + +def get_environ_checksum(*args: Any) -> int: + """Compute a CRC-32 checksum of *args*.""" + def default_encoder(x: object) -> str: + try: + return pickle.dumps(x, protocol=pickle.HIGHEST_PROTOCOL).hex() + except (NotImplementedError, TypeError, ValueError): + return hex(id(x))[2:] + + # use the most compact JSON format + env = json.dumps(args, ensure_ascii=False, sort_keys=True, indent=None, + separators=(',', ':'), default=default_encoder) + # avoid using unique_object_id() since we do not really need SHA-1 entropy + return binascii.crc32(env.encode('utf-8', errors='backslashreplace')) + + +# Use a LRU cache to speed-up the generation of the UUID-5 value +# when generating the object ID for parametrized sub-tests (those +# sub-tests will be using the same "object id") since UUID-5 is +# based on SHA-1. +@lru_cache(maxsize=65536) +def unique_object_id(name: str) -> str: + """Get a unique hexadecimal identifier for an object name. + + :param name: The name of the object to get a unique ID of. + :return: A unique hexadecimal identifier for *name*. + """ + # ensure that non UTF-8 characters are supported and handled similarly + sanitized = name.encode('utf-8', errors='backslashreplace').decode('utf-8') + return uuid.uuid5(uuid.NAMESPACE_OID, sanitized).hex + + +def get_namespace_id(node: PytestNode) -> str: + """Get a unique hexadecimal identifier for the node's namespace. + + The node's namespace is defined by all the modules and classes + the node is part of. + """ + namespace = '@'.join(filter(None, ( + getattr(t.obj, '__name__', None) or None for t in node.listchain() + if isinstance(t, (pytest.Module, pytest.Class)) and t.obj + ))) or node.nodeid + return unique_object_id(namespace) + + +def get_location_id(location: TestNodeLocation) -> str: + """Make a unique ID out of a test node location. + + The ID is based on the physical node location (file and line number) + and is more precise than :func:`py_location_hash`. + """ + fspath, lineno = location + return unique_object_id(f'{fspath}:L{lineno}') diff --git a/sphinx/testing/internal/warnings.py b/sphinx/testing/internal/warnings.py new file mode 100644 index 00000000000..225c56e45b6 --- /dev/null +++ b/sphinx/testing/internal/warnings.py @@ -0,0 +1,31 @@ +"""Warnings emitted by the :mod:`sphinx.testing.plugin` plugin.""" + +from __future__ import annotations + +__all__ = () + +from _pytest.warning_types import PytestWarning + + +class SphinxTestingWarning(PytestWarning): + """Base class for warnings emitted during test configuration.""" + + +class NodeWarning(SphinxTestingWarning): + """A warning emitted when an operation on a pytest node failed.""" + + +class MarkWarning(NodeWarning): + """A warning emitted when parsing a marker.""" + + def __init__(self, message: str, markname: str | None = None) -> None: + message = f'@pytest.mark.{markname}(): {message}' if markname else message + super().__init__(message) + + +class FixtureWarning(NodeWarning): + """A warning emitted during a fixture configuration.""" + + def __init__(self, message: str, fixturename: str | None = None) -> None: + message = f'FIXTURE({fixturename!r}): {message}' if fixturename else message + super().__init__(message) diff --git a/sphinx/testing/util.py b/sphinx/testing/util.py index f403757047f..9156ae713a5 100644 --- a/sphinx/testing/util.py +++ b/sphinx/testing/util.py @@ -18,6 +18,7 @@ import sphinx.application import sphinx.locale import sphinx.pycode +from sphinx.deprecation import RemovedInSphinx90Warning from sphinx.util.docutils import additional_nodes if TYPE_CHECKING: @@ -27,6 +28,8 @@ from docutils.nodes import Node + from sphinx.environment import BuildEnvironment + __all__ = 'SphinxTestApp', 'SphinxTestAppWrapperForSkipBuilding' @@ -206,14 +209,65 @@ def build(self, force_all: bool = False, filenames: list[str] | None = None) -> super().build(force_all, filenames) +class SphinxTestAppLazyBuild(SphinxTestApp): + """Class to speed-up tests with common resources. + + 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. + + Note that it is incorrect to use ``app.build(force_all=True)`` since + this flag assumes that the sources must be read once again to generate + the output, e.g.:: + + @pytest.mark.sphinx('text', testroot='foo') + @pytest.mark.test_params(shared_result='foo') + def test_foo_project_text1(app): + app.build() + + @pytest.mark.sphinx('text', testroot='foo') + @pytest.mark.test_params(shared_result='foo') + def test_foo_project_text2(app): + # If we execute test_foo_project_text1() before, + # then we should assume that the build phase is + # a no-op. So "force_all" should have no effect. + app.build(force_all=True) # BAD + + Be careful not to use different values for *filenames* in a lazy build + since only the first set of filenames that produce an output would be + considered. + """ + + def _init_env(self, freshenv: bool) -> BuildEnvironment: + if freshenv: + raise ValueError('cannot use %r in lazy builds' % 'freshenv=True') + return super()._init_env(freshenv) + + def build(self, force_all: bool = False, filenames: list[str] | None = None) -> None: + if force_all: + raise ValueError('cannot use %r in lazy builds' % 'force_all=True') + + # see: https://docs.python.org/3/library/os.html#os.scandir + with os.scandir(self.outdir) as it: + has_files = next(it, None) is not None + + if not has_files: # build if no files were already built + super().build(force_all=False, filenames=filenames) + + +# for backward compatibility class SphinxTestAppWrapperForSkipBuilding: - """A wrapper for SphinxTestApp. + """Class to speed-up tests with common resources. - 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. + 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: + warnings.warn( + f'{self.__class__.__name__!r} is deprecated, use ' + f'{SphinxTestAppLazyBuild.__name__!r} instead', + category=RemovedInSphinx90Warning, stacklevel=2, + ) self.app = app_ def __getattr__(self, name: str) -> Any: diff --git a/tests/conftest.py b/tests/conftest.py index a722971b81a..ad67eb4caa6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,12 @@ +from __future__ import annotations + +import fnmatch import os +import re import sys +from functools import lru_cache from pathlib import Path +from typing import TYPE_CHECKING import docutils import pytest @@ -8,8 +14,19 @@ import sphinx import sphinx.locale import sphinx.pycode +from sphinx.testing.internal.pytest_util import get_tmp_path_factory, issue_warning +from sphinx.testing.internal.pytest_xdist import is_pytest_xdist_enabled +from sphinx.testing.internal.warnings import FixtureWarning from sphinx.testing.util import _clean_up_global_state +if TYPE_CHECKING: + from collections.abc import Generator, Sequence + + from _pytest.config import Config + from _pytest.fixtures import FixtureRequest + from _pytest.main import Session + from _pytest.nodes import Item + def _init_console(locale_dir=sphinx.locale._LOCALE_DIR, catalog='sphinx'): """Monkeypatch ``init_console`` to skip its action. @@ -23,30 +40,166 @@ def _init_console(locale_dir=sphinx.locale._LOCALE_DIR, catalog='sphinx'): sphinx.locale.init_console = _init_console -pytest_plugins = 'sphinx.testing.fixtures' +# for now, we do not enable the 'xdist' plugin +pytest_plugins = ['sphinx.testing.fixtures'] -# Exclude 'roots' dirs for pytest test collector -collect_ignore = ['roots'] +# Exclude resource directories for pytest test collector +collect_ignore = ['certs', 'roots'] os.environ['SPHINX_AUTODOC_RELOAD_MODULES'] = '1' +def pytest_configure(config: Config) -> None: + config.addinivalue_line('markers', 'serial(): mark a test as non-xdist friendly') + config.addinivalue_line('markers', 'unload(*pattern): unload matching modules') + config.addinivalue_line('markers', 'unload_modules(*names, raises=False): unload modules') + + config.addinivalue_line( + 'markers', + 'apidoc(*, coderoot="test-root", excludes=[], options=[]): ' + 'sphinx-apidoc command-line options (see test_ext_apidoc).', + ) + + +def pytest_report_header(config: Config) -> str: + headers = { + 'libraries': f'Sphinx-{sphinx.__display_version__}, docutils-{docutils.__version__}', + } + if (factory := get_tmp_path_factory(config, None)) is not None: + headers['base tmp_path'] = factory.getbasetemp() + return '\n'.join(f'{key}: {value}' for key, value in headers.items()) + + +# The test modules in which tests should not be executed in parallel mode, +# unless they are explicitly marked with ``@pytest.mark.parallel()``. +# +# The keys are paths relative to the project directory and values can +# be ``None`` to indicate all tests or a list of (non-parametrized) test +# names, e.g., for a test:: +# +# @pytest.mark.parametrize('value', [1, 2]) +# def test_foo(): ... +# +# the name is ``test_foo`` and not ``test_foo[1]`` or ``test_foo[2]``. +# +# Note that a test class or function should not have '[' in its name. +_SERIAL_TESTS: dict[str, Sequence[str] | None] = { + 'tests/test_builders/test_build_linkcheck.py': None, + 'tests/test_intl/test_intl.py': None, +} + + +@lru_cache(maxsize=512) +def _serial_matching(relfspath: str, pattern: str) -> bool: + return fnmatch.fnmatch(relfspath, pattern) + + +@lru_cache(maxsize=512) +def _findall_main_keys(relfspath: str) -> tuple[str, ...]: + return tuple(key for key in _SERIAL_TESTS if _serial_matching(relfspath, key)) + + +def _test_basename(name: str) -> str: + """Get the test name without the parametrization part from an item name.""" + if name.find('[') < name.find(']'): + # drop the parametrized part + return name[:name.find('[')] + return name + + +@pytest.hookimpl(tryfirst=True) +def pytest_itemcollected(item: Item) -> None: + if item.get_closest_marker('serial'): + return + + # check whether the item should be marked with ``@pytest.mark.serial()`` + relfspath, _, _ = item.location + for key in _findall_main_keys(relfspath): + names = _SERIAL_TESTS[key] + if names is None or _test_basename(item.name) in names: + item.add_marker(pytest.mark.serial()) + + +@pytest.hookimpl(trylast=True) +def pytest_collection_modifyitems(session: Session, config: Config, items: list[Item]) -> None: + if not is_pytest_xdist_enabled(config): + # ignore ``@pytest.mark.serial()`` when ``xdist`` is inactive + return + + # only select items that are marked (manually or automatically) with 'serial' + items[:] = [item for item in items if item.get_closest_marker('serial') is None] + + @pytest.fixture(scope='session') -def rootdir(): +def rootdir() -> Path: return Path(__file__).parent.resolve() / 'roots' -def pytest_report_header(config): - header = f"libraries: Sphinx-{sphinx.__display_version__}, docutils-{docutils.__version__}" - if hasattr(config, '_tmp_path_factory'): - header += f"\nbase tmp_path: {config._tmp_path_factory.getbasetemp()}" - return header +# TODO(picnixz): change this fixture to 'minimal' when all tests using 'root' +# have been found and explicitly changed +@pytest.fixture(scope='session') +def default_testroot() -> str: + return 'root' @pytest.fixture(autouse=True) -def _cleanup_docutils(): +def _cleanup_docutils() -> Generator[None, None, None]: saved_path = sys.path yield # run the test sys.path[:] = saved_path _clean_up_global_state() + + +@pytest.fixture(autouse=True) +def _do_unload(request: FixtureRequest) -> Generator[None, None, None]: + """Explicitly remove modules. + + The modules to remove can be specified as follows:: + + # remove any module matching one the regular expressions + @pytest.mark.unload('foo.*', 'bar.*') + def test(): ... + + # silently remove modules using exact module names + @pytest.mark.unload_modules('pkg.mod') + def test(): ... + + # remove using exact module names and fails if a module was not loaded + @pytest.mark.unload_modules('pkg.mod', raises=True) + def test(): ... + """ + # find the module names patterns + patterns: list[re.Pattern[str]] = [] + for marker in request.node.iter_markers('unload'): + patterns.extend(map(re.compile, marker.args)) + + # find the exact module names and the flag indicating whether + # to abort the test if unloading them is not possible + silent_targets: set[str] = set() + expect_targets: set[str] = set() + for marker in request.node.iter_markers('unload_modules'): + if marker.kwargs.get('raises', False): + silent_targets.update(marker.args) + else: + expect_targets.update(marker.args) + + yield # run the test + + # nothing to do + if not silent_targets and not expect_targets and not patterns: + return + + for modname in expect_targets - sys.modules.keys(): + warning = FixtureWarning(f'module was not loaded: {modname!r}', '_unload') + issue_warning(request, warning) + + # teardown by removing from the imported modules the requested modules + silent_targets.update(frozenset(sys.modules) & expect_targets) + # teardown by removing from the imported modules the matched modules + for modname in frozenset(sys.modules): + if modname in silent_targets: + silent_targets.remove(modname) + del sys.modules[modname] + elif any(p.match(modname) for p in patterns): + del sys.modules[modname] diff --git a/tests/roots/test-minimal/conf.py b/tests/roots/test-minimal/conf.py new file mode 100644 index 00000000000..89250616221 --- /dev/null +++ b/tests/roots/test-minimal/conf.py @@ -0,0 +1,3 @@ +# minimal test root +include_patterns = ['index.rst'] +exclude_patterns = ['_build'] diff --git a/tests/roots/test-minimal/index.rst b/tests/roots/test-minimal/index.rst new file mode 100644 index 00000000000..8260ebead4e --- /dev/null +++ b/tests/roots/test-minimal/index.rst @@ -0,0 +1 @@ +.. empty index \ No newline at end of file diff --git a/tests/test_builders/test_build.py b/tests/test_builders/test_build.py index 3f6d12c7c99..309afdb2dbf 100644 --- a/tests/test_builders/test_build.py +++ b/tests/test_builders/test_build.py @@ -67,7 +67,7 @@ def test_root_doc_not_found(tmp_path, make_app): app.build(force_all=True) # no index.rst -@pytest.mark.sphinx(buildername='text', testroot='circular') +@pytest.mark.sphinx('text', testroot='circular') def test_circular_toctree(app, status, warning): app.build(force_all=True) warnings = warning.getvalue() @@ -79,7 +79,7 @@ def test_circular_toctree(app, status, warning): 'index <- sub <- index') in warnings -@pytest.mark.sphinx(buildername='text', testroot='numbered-circular') +@pytest.mark.sphinx('text', testroot='numbered-circular') def test_numbered_circular_toctree(app, status, warning): app.build(force_all=True) warnings = warning.getvalue() @@ -91,7 +91,7 @@ def test_numbered_circular_toctree(app, status, warning): 'index <- sub <- index') in warnings -@pytest.mark.sphinx(buildername='dummy', testroot='images') +@pytest.mark.sphinx('dummy', testroot='images') def test_image_glob(app, status, warning): app.build(force_all=True) diff --git a/tests/test_builders/test_build_dirhtml.py b/tests/test_builders/test_build_dirhtml.py index dc5ab86031d..3e7d14a1a7b 100644 --- a/tests/test_builders/test_build_dirhtml.py +++ b/tests/test_builders/test_build_dirhtml.py @@ -7,7 +7,7 @@ from sphinx.util.inventory import InventoryFile -@pytest.mark.sphinx(buildername='dirhtml', testroot='builder-dirhtml') +@pytest.mark.sphinx('dirhtml', testroot='builder-dirhtml') def test_dirhtml(app, status, warning): app.build() diff --git a/tests/test_builders/test_build_html.py b/tests/test_builders/test_build_html.py index 0d88645d972..581fb4e790c 100644 --- a/tests/test_builders/test_build_html.py +++ b/tests/test_builders/test_build_html.py @@ -56,7 +56,7 @@ def test_html4_error(make_app, tmp_path): match='HTML 4 is no longer supported by Sphinx', ): make_app( - buildername='html', + 'html', srcdir=tmp_path, confoverrides={'html4_writer': True}, ) diff --git a/tests/test_builders/test_build_html_numfig.py b/tests/test_builders/test_build_html_numfig.py index 8170cd2ce2f..8cfe3f15513 100644 --- a/tests/test_builders/test_build_html_numfig.py +++ b/tests/test_builders/test_build_html_numfig.py @@ -60,6 +60,7 @@ def test_numfig_disabled(app, cached_etree_parse, fname, path, check, be_found): 'html', testroot='numfig', srcdir='test_numfig_without_numbered_toctree_warn', confoverrides={'numfig': True}) +@pytest.mark.isolate() # because we affect the sources def test_numfig_without_numbered_toctree_warn(app, warning): app.build() # remove :numbered: option @@ -144,6 +145,7 @@ def test_numfig_without_numbered_toctree_warn(app, warning): 'html', testroot='numfig', srcdir='test_numfig_without_numbered_toctree', confoverrides={'numfig': True}) +@pytest.mark.isolate('grouped') # because we affect the sources def test_numfig_without_numbered_toctree(app, cached_etree_parse, fname, path, check, be_found): # remove :numbered: option index = (app.srcdir / 'index.rst').read_text(encoding='utf8') diff --git a/tests/test_builders/test_build_latex.py b/tests/test_builders/test_build_latex.py index e9114b5c4de..4916d8d3118 100644 --- a/tests/test_builders/test_build_latex.py +++ b/tests/test_builders/test_build_latex.py @@ -1184,7 +1184,7 @@ def test_maxlistdepth_at_ten(app, status, warning): confoverrides={'latex_table_style': []}) @pytest.mark.test_params(shared_result='latex-table') def test_latex_table_tabulars(app, status, warning): - app.build(force_all=True) + app.build() result = (app.outdir / 'python.tex').read_text(encoding='utf8') tables = {} for chap in re.split(r'\\(?:section|chapter){', result)[1:]: @@ -1255,7 +1255,7 @@ def get_expected(name): confoverrides={'latex_table_style': []}) @pytest.mark.test_params(shared_result='latex-table') def test_latex_table_longtable(app, status, warning): - app.build(force_all=True) + app.build() result = (app.outdir / 'python.tex').read_text(encoding='utf8') tables = {} for chap in re.split(r'\\(?:section|chapter){', result)[1:]: @@ -1316,7 +1316,7 @@ def get_expected(name): confoverrides={'latex_table_style': []}) @pytest.mark.test_params(shared_result='latex-table') def test_latex_table_complex_tables(app, status, warning): - app.build(force_all=True) + app.build() result = (app.outdir / 'python.tex').read_text(encoding='utf8') tables = {} for chap in re.split(r'\\(?:section|renewcommand){', result)[1:]: @@ -1378,7 +1378,7 @@ def test_latex_table_custom_template_caseB(app, status, warning): @pytest.mark.sphinx('latex', testroot='latex-table') @pytest.mark.test_params(shared_result='latex-table') def test_latex_table_custom_template_caseC(app, status, warning): - app.build(force_all=True) + app.build() result = (app.outdir / 'python.tex').read_text(encoding='utf8') assert 'SALUT LES COPAINS' not in result diff --git a/tests/test_builders/test_build_text.py b/tests/test_builders/test_build_text.py index 6dc0d037533..59087338972 100644 --- a/tests/test_builders/test_build_text.py +++ b/tests/test_builders/test_build_text.py @@ -5,14 +5,7 @@ from sphinx.writers.text import MAXWIDTH, Cell, Table - -def with_text_app(*args, **kw): - default_kw = { - 'buildername': 'text', - 'testroot': 'build-text', - } - default_kw.update(kw) - return pytest.mark.sphinx(*args, **default_kw) +with_text_app = pytest.mark.sphinx('text', testroot='build-text').with_args @with_text_app() diff --git a/tests/test_environment/test_environment.py b/tests/test_environment/test_environment.py index 8a34457cb90..e2c7930ab9b 100644 --- a/tests/test_environment/test_environment.py +++ b/tests/test_environment/test_environment.py @@ -15,7 +15,7 @@ def test_config_status(make_app, app_params): args, kwargs = app_params # clean build - app1 = make_app(*args, freshenv=True, **kwargs) + app1 = make_app(*args, **dict(kwargs, freshenv=True)) assert app1.env.config_status == CONFIG_NEW app1.build() assert '[new config] 1 added' in app1._status.getvalue() @@ -27,7 +27,7 @@ def test_config_status(make_app, app_params): assert "0 added, 0 changed, 0 removed" in app2._status.getvalue() # incremental build (config entry changed) - app3 = make_app(*args, confoverrides={'root_doc': 'indexx'}, **kwargs) + app3 = make_app(*args, **dict(kwargs, confoverrides={'root_doc': 'indexx'})) fname = os.path.join(app3.srcdir, 'index.rst') assert os.path.isfile(fname) shutil.move(fname, fname[:-4] + 'x.rst') @@ -37,7 +37,7 @@ def test_config_status(make_app, app_params): assert "[config changed ('root_doc')] 1 added" in app3._status.getvalue() # incremental build (extension changed) - app4 = make_app(*args, confoverrides={'extensions': ['sphinx.ext.autodoc']}, **kwargs) + app4 = make_app(*args, **dict(kwargs, confoverrides={'extensions': ['sphinx.ext.autodoc']})) assert app4.env.config_status == CONFIG_EXTENSIONS_CHANGED app4.build() want_str = "[extensions changed ('sphinx.ext.autodoc')] 1 added" diff --git a/tests/test_extensions/test_ext_autodoc_configs.py b/tests/test_extensions/test_ext_autodoc_configs.py index a1e3c83f7fd..fa579c566ad 100644 --- a/tests/test_extensions/test_ext_autodoc_configs.py +++ b/tests/test_extensions/test_ext_autodoc_configs.py @@ -997,6 +997,7 @@ def test_autodoc_typehints_description(app): @pytest.mark.sphinx('text', testroot='ext-autodoc', confoverrides={'autodoc_typehints': "description", 'autodoc_typehints_description_target': 'documented'}) +@pytest.mark.isolate() # because we change the sources in-place def test_autodoc_typehints_description_no_undoc(app): # No :type: or :rtype: will be injected for `incr`, which does not have # a description for its parameters or its return. `tuple_args` does @@ -1041,6 +1042,7 @@ def test_autodoc_typehints_description_no_undoc(app): @pytest.mark.sphinx('text', testroot='ext-autodoc', confoverrides={'autodoc_typehints': "description", 'autodoc_typehints_description_target': 'documented_params'}) +@pytest.mark.isolate() # because we change the sources in-place def test_autodoc_typehints_description_no_undoc_doc_rtype(app): # No :type: will be injected for `incr`, which does not have a description # for its parameters or its return, just :rtype: will be injected due to @@ -1105,6 +1107,7 @@ def test_autodoc_typehints_description_no_undoc_doc_rtype(app): @pytest.mark.sphinx('text', testroot='ext-autodoc', confoverrides={'autodoc_typehints': "description"}) +@pytest.mark.isolate() # because we change the sources in-place def test_autodoc_typehints_description_with_documented_init(app): with overwrite_file(app.srcdir / 'index.rst', '.. autoclass:: target.typehints._ClassWithDocumentedInit\n' @@ -1142,6 +1145,7 @@ def test_autodoc_typehints_description_with_documented_init(app): @pytest.mark.sphinx('text', testroot='ext-autodoc', confoverrides={'autodoc_typehints': "description", 'autodoc_typehints_description_target': 'documented'}) +@pytest.mark.isolate() # because we change the sources in-place def test_autodoc_typehints_description_with_documented_init_no_undoc(app): with overwrite_file(app.srcdir / 'index.rst', '.. autoclass:: target.typehints._ClassWithDocumentedInit\n' @@ -1169,6 +1173,7 @@ def test_autodoc_typehints_description_with_documented_init_no_undoc(app): @pytest.mark.sphinx('text', testroot='ext-autodoc', confoverrides={'autodoc_typehints': "description", 'autodoc_typehints_description_target': 'documented_params'}) +@pytest.mark.isolate() # because we change the sources in-place def test_autodoc_typehints_description_with_documented_init_no_undoc_doc_rtype(app): # see test_autodoc_typehints_description_with_documented_init_no_undoc # returnvalue_and_documented_params should not change class or method @@ -1205,6 +1210,7 @@ def test_autodoc_typehints_description_for_invalid_node(app): @pytest.mark.sphinx('text', testroot='ext-autodoc', confoverrides={'autodoc_typehints': "both"}) +@pytest.mark.isolate() # because we change the sources in-place def test_autodoc_typehints_both(app): with overwrite_file(app.srcdir / 'index.rst', '.. autofunction:: target.typehints.incr\n' @@ -1390,6 +1396,7 @@ def test_autodoc_type_aliases(app): srcdir='autodoc_typehints_description_and_type_aliases', confoverrides={'autodoc_typehints': "description", 'autodoc_type_aliases': {'myint': 'myint'}}) +@pytest.mark.isolate() # because we change the sources in-place def test_autodoc_typehints_description_and_type_aliases(app): with overwrite_file(app.srcdir / 'autodoc_type_aliases.rst', '.. autofunction:: target.autodoc_type_aliases.sum'): diff --git a/tests/test_extensions/test_ext_inheritance_diagram.py b/tests/test_extensions/test_ext_inheritance_diagram.py index c13ccea9247..6392f3d268e 100644 --- a/tests/test_extensions/test_ext_inheritance_diagram.py +++ b/tests/test_extensions/test_ext_inheritance_diagram.py @@ -15,7 +15,7 @@ from sphinx.ext.intersphinx import load_mappings, normalize_intersphinx_mapping -@pytest.mark.sphinx(buildername="html", testroot="inheritance") +@pytest.mark.sphinx("html", testroot="inheritance") @pytest.mark.usefixtures('if_graphviz_found') def test_inheritance_diagram(app, status, warning): # monkey-patch InheritaceDiagram.run() so we can get access to its diff --git a/tests/test_intl/test_intl.py b/tests/test_intl/test_intl.py index cb6c7985131..8aaf4679b24 100644 --- a/tests/test_intl/test_intl.py +++ b/tests/test_intl/test_intl.py @@ -1635,13 +1635,13 @@ def test_gettext_disallow_fuzzy_translations(app): @pytest.mark.sphinx('html', testroot='basic', confoverrides={'language': 'de'}) -def test_customize_system_message(make_app, app_params, sphinx_test_tempdir): +def test_customize_system_message(make_app, app_params): try: # clear translators cache locale.translators.clear() # prepare message catalog (.po) - locale_dir = sphinx_test_tempdir / 'basic' / 'locales' / 'de' / 'LC_MESSAGES' + locale_dir = app_params.kwargs['srcdir'] / 'locales' / 'de' / 'LC_MESSAGES' locale_dir.mkdir(parents=True, exist_ok=True) with (locale_dir / 'sphinx.po').open('wb') as f: catalog = Catalog() diff --git a/tests/test_markup/test_smartquotes.py b/tests/test_markup/test_smartquotes.py index 1d4e8e1271a..0900bf2f3f7 100644 --- a/tests/test_markup/test_smartquotes.py +++ b/tests/test_markup/test_smartquotes.py @@ -4,7 +4,7 @@ from html5lib import HTMLParser -@pytest.mark.sphinx(buildername='html', testroot='smartquotes', freshenv=True) +@pytest.mark.sphinx('html', testroot='smartquotes', freshenv=True) def test_basic(app, status, warning): app.build() @@ -12,7 +12,7 @@ def test_basic(app, status, warning): assert '

– “Sphinx” is a tool that makes it easy …

' in content -@pytest.mark.sphinx(buildername='html', testroot='smartquotes', freshenv=True) +@pytest.mark.sphinx('html', testroot='smartquotes', freshenv=True) def test_literals(app, status, warning): app.build() @@ -30,7 +30,7 @@ def test_literals(app, status, warning): assert code_text == "literal with 'quotes'" -@pytest.mark.sphinx(buildername='text', testroot='smartquotes', freshenv=True) +@pytest.mark.sphinx('text', testroot='smartquotes', freshenv=True) def test_text_builder(app, status, warning): app.build() @@ -38,7 +38,7 @@ def test_text_builder(app, status, warning): assert '-- "Sphinx" is a tool that makes it easy ...' in content -@pytest.mark.sphinx(buildername='man', testroot='smartquotes', freshenv=True) +@pytest.mark.sphinx('man', testroot='smartquotes', freshenv=True) def test_man_builder(app, status, warning): app.build() @@ -46,7 +46,7 @@ def test_man_builder(app, status, warning): assert r'\-\- \(dqSphinx\(dq is a tool that makes it easy ...' in content -@pytest.mark.sphinx(buildername='latex', testroot='smartquotes', freshenv=True) +@pytest.mark.sphinx('latex', testroot='smartquotes', freshenv=True) def test_latex_builder(app, status, warning): app.build() @@ -54,7 +54,7 @@ def test_latex_builder(app, status, warning): assert '\\textendash{} “Sphinx” is a tool that makes it easy …' in content -@pytest.mark.sphinx(buildername='html', testroot='smartquotes', freshenv=True, +@pytest.mark.sphinx('html', testroot='smartquotes', freshenv=True, confoverrides={'language': 'ja'}) def test_ja_html_builder(app, status, warning): app.build() @@ -63,7 +63,7 @@ def test_ja_html_builder(app, status, warning): assert '

-- "Sphinx" is a tool that makes it easy ...

' in content -@pytest.mark.sphinx(buildername='html', testroot='smartquotes', freshenv=True, +@pytest.mark.sphinx('html', testroot='smartquotes', freshenv=True, confoverrides={'smartquotes': False}) def test_smartquotes_disabled(app, status, warning): app.build() @@ -72,7 +72,7 @@ def test_smartquotes_disabled(app, status, warning): assert '

-- "Sphinx" is a tool that makes it easy ...

' in content -@pytest.mark.sphinx(buildername='html', testroot='smartquotes', freshenv=True, +@pytest.mark.sphinx('html', testroot='smartquotes', freshenv=True, confoverrides={'smartquotes_action': 'q'}) def test_smartquotes_action(app, status, warning): app.build() @@ -81,7 +81,7 @@ def test_smartquotes_action(app, status, warning): assert '

-- “Sphinx” is a tool that makes it easy ...

' in content -@pytest.mark.sphinx(buildername='html', testroot='smartquotes', freshenv=True, +@pytest.mark.sphinx('html', testroot='smartquotes', freshenv=True, confoverrides={'language': 'ja', 'smartquotes_excludes': {}}) def test_smartquotes_excludes_language(app, status, warning): app.build() @@ -90,7 +90,7 @@ def test_smartquotes_excludes_language(app, status, warning): assert '

– 「Sphinx」 is a tool that makes it easy …

' in content -@pytest.mark.sphinx(buildername='man', testroot='smartquotes', freshenv=True, +@pytest.mark.sphinx('man', testroot='smartquotes', freshenv=True, confoverrides={'smartquotes_excludes': {}}) def test_smartquotes_excludes_builders(app, status, warning): app.build() diff --git a/tests/test_testing/__init__.py b/tests/test_testing/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_testing/_const.py b/tests/test_testing/_const.py new file mode 100644 index 00000000000..6096db45818 --- /dev/null +++ b/tests/test_testing/_const.py @@ -0,0 +1,24 @@ +"""Private constants.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +import sphinx + +if TYPE_CHECKING: + from typing import Final + +PROJECT_PATH: Final[str] = os.path.realpath(os.path.dirname(os.path.dirname(sphinx.__file__))) +"""Directory containing the current (local) sphinx's implementation.""" + +SPHINX_PLUGIN_NAME: Final[str] = 'sphinx.testing.fixtures' +MAGICO_PLUGIN_NAME: Final[str] = 'tests.test_testing.magico' +CORE_PLUGINS: Final[tuple[str, ...]] = (SPHINX_PLUGIN_NAME, MAGICO_PLUGIN_NAME) + +MAGICO: Final[str] = 'sphinx_magico' +"""Magical fixture name to use for writing a "debug" test message. + +See :mod:`test_magico` for usage. +""" diff --git a/tests/test_testing/_util.py b/tests/test_testing/_util.py new file mode 100644 index 00000000000..d52fe2a3720 --- /dev/null +++ b/tests/test_testing/_util.py @@ -0,0 +1,480 @@ +from __future__ import annotations + +import contextlib +import fnmatch +import os +import re +import uuid +from functools import lru_cache +from io import StringIO +from itertools import chain +from pathlib import Path +from threading import RLock +from typing import TYPE_CHECKING, TypedDict, TypeVar, final, overload + +import pytest + +from tests.test_testing._const import MAGICO_PLUGIN_NAME, SPHINX_PLUGIN_NAME + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator, Mapping, Sequence + from typing import Any, Final + + from _pytest.pytester import Pytester, RunResult + from typing_extensions import Unpack + + +def _parse_path(path: str) -> tuple[str, str, int, str]: + fspath = Path(path) + checksum = fspath.parent.stem + if not checksum or not checksum.isnumeric(): + pytest.fail(f'cannot extract configuration checksum from: {path!r}') + + basenode = fspath.parent.parent.stem + + try: + uuid.UUID(basenode, version=5) + except ValueError: + pytest.fail(f'cannot extract namespace hash from: {path!r}') + + return str(fspath), basenode, int(checksum), fspath.stem + + +@final +class SourceInfo(tuple[str, str, int, str]): + # We do not use a NamedTuple nor a dataclass since we we want an immutable + # class in which its constructor checks the format of its unique argument. + __slots__ = () + + def __new__(cls, path: str) -> SourceInfo: + return tuple.__new__(cls, _parse_path(path)) + + @property + def realpath(self) -> str: + """The absolute path to the sources directory.""" + return self[0] + + @property + def basenode(self) -> str: + """The test node namespace identifier.""" + return self[1] + + @property + def checksum(self) -> int: + """The Sphinx configuration checksum.""" + return self[2] + + @property + def filename(self) -> str: + """The sources directory name.""" + return self[3] + + +@final +class Outcome(TypedDict, total=False): + passed: int + skipped: int + failed: int + errors: int + xpassed: int + xfailed: int + warnings: int + deselected: int + + +def _assert_outcomes(actual: Mapping[str, int], expect: Outcome) -> None: + for status in ('passed', 'xpassed'): + # for successful tests, we do not care if the count is not given + obtained = actual.get(status, 0) + expected = expect.get(status, obtained) + assert obtained == expected, (status, actual, expect) + + for status in ('skipped', 'failed', 'errors', 'xfailed', 'warnings', 'deselected'): + obtained = actual.get(status, 0) + expected = expect.get(status, 0) + assert obtained == expected, (status, actual, expect) + + +def _make_testable_name(name: str) -> str: + return name if name.startswith('test_') else f'test_{name}' + + +def _make_testable_path(path: str | os.PathLike[str]) -> str: + return os.path.join(*map(_make_testable_name, Path(path).parts)) + + +@final +class E2E: + """End-to-end integration test interface.""" + + def __init__(self, pytester: Pytester) -> None: + self.__pytester = pytester + + def makepyfile(self, *args: Any, **kwargs: Any) -> Path: + """Delegate to :meth:`_pytest.pytester.Pytester.makepyfile`.""" + return self.__pytester.makepyfile(*args, **kwargs) + + def makepytest(self, *args: Any, **kwargs: Any) -> Path: + """Same as :meth:`makepyfile` but add ``test_`` prefixes to files if needed.""" + kwargs = {_make_testable_path(dest): source for dest, source in kwargs.items()} + return self.makepyfile(*args, **kwargs) + + def runpytest(self, *args: str, plugins: Sequence[str] = (), silent: bool = True) -> RunResult: + """Run the pytester in the same process. + + When *silent* is true, the pytester internal output is suprressed. + """ + # runpytest() does not accept 'plugins' if the method is 'subprocess' + plugins = (SPHINX_PLUGIN_NAME, MAGICO_PLUGIN_NAME, *plugins) + if silent: + with open(os.devnull, 'w') as NUL, contextlib.redirect_stdout(NUL): + return self.__pytester.runpytest_inprocess(*args, plugins=plugins) + else: + return self.__pytester.runpytest_inprocess(*args, plugins=plugins) + + # fmt: off + @overload + def write(self, main_case: str | Sequence[str], /) -> Path: ... # NoQA: E704 + @overload + def write(self, dest: str, /, *cases: str | Sequence[str]) -> Path: ... # NoQA: E704 + # fmt: on + def write(self, dest: Sequence[str], /, *cases: str | Sequence[str]) -> Path: # NoQA: E301 + """Write a Python test file. + + When *dest* is specified, it should indicate where the test file is to + be written, possibly omitting ``test_`` prefixes, e.g.:: + + e2e.write('pkg/foo', '...') # writes to 'test_pkg/test_foo.py' + + When *dest* is not specified, its default value is 'main'. + + :param dest: The destination identifier. + :param cases: The content parts to write. + :return: The path where the cases where written to. + """ + if not cases: + dest, cases = 'main', (dest,) + + assert isinstance(dest, str) + path = _make_testable_path(dest) + + sources = [[case] if isinstance(case, str) else case for case in cases] + lines = (self._getpysource(path), *chain.from_iterable(sources)) + suite = '\n'.join(filter(None, lines)).strip() + return self.makepyfile(**{path: suite}) + + def run(self, /, *, silent: bool = True, **outcomes: Unpack[Outcome]) -> MagicOutput: + """Run the internal pytester object without ``xdist``.""" + res = self.runpytest('-rA', plugins=['no:xdist'], silent=silent) + _assert_outcomes(res.parseoutcomes(), outcomes) + return MagicOutput(res) + + def xdist_run( + self, /, *, jobs: int = 2, silent: bool = True, **outcomes: Unpack[Outcome], + ) -> MagicOutput: + """Run the internal pytester object with ``xdist``.""" + # The :option:`!-r` pytest option is set to ``A`` since we need + # to intercept the report sections and the distribution policy + # is ``loadgroup`` to ensure that ``xdist_group`` is supported. + args = ('-rA', '--numprocesses', str(jobs), '--dist', 'loadgroup') + res = self.runpytest(*args, plugins=['xdist'], silent=silent) + _assert_outcomes(res.parseoutcomes(), outcomes) + return MagicOutput(res) + + def _getpysource(self, path: str) -> str: + curr = self.__pytester.path.joinpath(path).with_suffix('.py') + if curr.exists(): + return curr.read_text(encoding='utf-8').strip() + return '' + + +def e2e_run(t: Pytester, /, **outcomes: Unpack[Outcome]) -> MagicOutput: + """Shorthand for ``E2E(t).run(**outcomes)``.""" + return E2E(t).run(**outcomes) + + +def e2e_xdist_run(t: Pytester, /, *, jobs: int = 2, **outcomes: Unpack[Outcome]) -> MagicOutput: + """Shorthand for ``E2E(t).xdist_run(jobs=jobs, **outcomes)``.""" + return E2E(t).xdist_run(jobs=jobs, **outcomes) + + +############################################################################### +# magic I/O for xdist support +############################################################################### + +_CHANNEL_FOR_VALUE: Final[str] = '' +_CHANNEL_FOR_PRINT: Final[str] = '' + +_TXT_SECTION: Final[str] = 'txt' +_END_SECTION: Final[str] = 'end' +_END_CONTENT: Final[str] = '@EOM' + +_CAPTURE_STATE: Final[str] = 'teardown' + + +def _format_message(prefix: str, *args: Any, sep: str, end: str) -> str: + return f'{prefix} {sep.join(map(str, args))}{end}' + + +def _format_message_for_value_channel(varname: str, value: Any) -> str: + return _format_message(_CHANNEL_FOR_VALUE, varname, value, sep='=', end='\n') + + +def _format_message_for_print_channel(*args: Any, sep: str, end: str) -> str: + return _format_message(_CHANNEL_FOR_PRINT, *args, sep=sep, end=end) + + +@lru_cache(maxsize=128) +def _compile_pattern_for_value_channel(varname: str, pattern: str) -> re.Pattern[str]: + channel, varname = re.escape(_CHANNEL_FOR_VALUE), re.escape(varname) + return re.compile(rf'^{channel} {varname}=({pattern})$') + + +@lru_cache(maxsize=128) +def _compile_pattern_for_print_channel(pattern: str) -> re.Pattern[str]: + channel = re.escape(_CHANNEL_FOR_PRINT) + return re.compile(rf'^{channel} ({pattern})$') + + +def _magic_section(nodeid: str, channel: str, marker: str) -> str: + return f'{channel}@{marker} -- {nodeid}' + + +@lru_cache(maxsize=256) +def _compile_nodeid_pattern(nodeid: str) -> str: + return fnmatch.translate(nodeid).rstrip(r'\Z') # remove the \Z marker + + +@lru_cache(maxsize=256) +def _get_magic_patterns(nodeid: str, channel: str) -> tuple[re.Pattern[str], re.Pattern[str]]: + channel = re.escape(channel) + + def get_pattern(section_type: str) -> re.Pattern[str]: + title = _magic_section(nodeid, channel, re.escape(section_type)) + return re.compile(f'{title} {_CAPTURE_STATE}') + + return get_pattern(_TXT_SECTION), get_pattern(_END_SECTION) + + +def _create_magic_teardownsection(item: pytest.Item, channel: str, content: str) -> None: + if content: + txt_section = _magic_section(item.nodeid, channel, _TXT_SECTION) + item.add_report_section(_CAPTURE_STATE, txt_section, content) + # a fake section is added in order to know where to stop + end_section = _magic_section(item.nodeid, channel, _END_SECTION) + item.add_report_section(_CAPTURE_STATE, end_section, _END_CONTENT) + + +@final +class MagicWriter: + """I/O stream responsible for messages to include in a report section.""" + + _lock = RLock() + + def __init__(self) -> None: + self._vals = StringIO() + self._info = StringIO() + + def __call__(self, varname: str, value: Any, /) -> None: + """Store the value of a variable at the call site. + + .. seealso:: + + :meth:`MagicOutput.find` + :meth:`MagicOutput.findall` + """ + payload = _format_message_for_value_channel(varname, value) + self._write(self._vals, payload) + + def info(self, *args: Any, sep: str = ' ', end: str = '\n') -> None: + """Emulate a ``print()`` in a pytester test. + + .. seealso:: + + :meth:`MagicOutput.message` + :meth:`MagicOutput.messages` + """ + payload = _format_message_for_print_channel(*args, sep=sep, end=end) + self._write(self._info, payload) + + @classmethod + def _write(cls, dest: StringIO, line: str) -> None: + with cls._lock: + dest.write(line) + + def pytest_runtest_teardown(self, item: pytest.Item) -> None: + """Called when tearing down a pytest item. + + This is *not* registered as a pytest but the implementation is kept + here since :class:`MagicOutput` intimely depends on this class. + """ + _create_magic_teardownsection(item, _CHANNEL_FOR_VALUE, self._vals.getvalue()) + _create_magic_teardownsection(item, _CHANNEL_FOR_PRINT, self._info.getvalue()) + + +_T = TypeVar('_T') + + +class MagicOutput: + """The output of a :class:`_pytest.pytster.Pytester` execution.""" + + def __init__(self, res: RunResult) -> None: + self.res = res + self.lines = tuple(res.outlines) + + # fmt: off + @overload + def find(self, name: str, expr: str = ..., *, nodeid: str | None = ..., t: None = ...) -> str: ... # NoQA: E704 + @overload + def find(self, name: str, expr: str = ..., *, nodeid: str | None = ..., t: Callable[[str], _T]) -> _T: ... # NoQA: E704 + # fmt: on + def find(self, name: str, expr: str = r'.*', *, nodeid: str | None = None, t: Callable[[str], Any] | None = None) -> Any: # NoQA: E301 + """Find the first occurrence of a variable value. + + :param name: A variable name. + :param expr: A variable value pattern. + :param nodeid: Optional node ID to filter messages. + :param t: Optional adapter function. + :return: The variable value (possibly converted via *t*). + """ + values = self._findall(name, expr, nodeid=nodeid) + value = next(values, None) + assert value is not None, (name, expr, nodeid) + return value if t is None else t(value) + + # fmt: off + @overload + def findall(self, name: str, expr: str = ..., *, nodeid: str | None = ..., t: None = ...) -> list[str]: ... # NoQA: E704 + @overload + def findall(self, name: str, expr: str = ..., *, nodeid: str | None = ..., t: Callable[[str], _T]) -> list[_T]: ... # NoQA: E704 + # fmt: on + def findall(self, name: str, expr: str = r'.*', *, nodeid: str | None = None, t: Callable[[str], Any] | None = None) -> list[Any]: # NoQA: E301 + """Find the all occurrences of a variable value. + + :param name: A variable name. + :param expr: A variable value pattern. + :param nodeid: Optional node ID to filter messages. + :param t: Optional adapter function. + :return: The variable values (possibly converted via *t*). + """ + values = self._findall(name, expr, nodeid=nodeid) + return list(values) if t is None else list(map(t, values)) + + def _findall(self, name: str, expr: str, *, nodeid: str | None) -> Iterator[str]: + pattern = _compile_pattern_for_value_channel(name, expr) + yield from self._parselines(pattern, nodeid, _CHANNEL_FOR_VALUE) + + def message(self, expr: str = r'.*', *, nodeid: str | None = None) -> str | None: + """Find the first occurrence of a print-like message. + + Messages for printing variables are not included. + + :param expr: A message pattern. + :param nodeid: Optional node ID to filter messages. + :return: A message or ``None``. + """ + return next(self._messages(expr, nodeid=nodeid), None) + + def messages(self, expr: str = r'.*', *, nodeid: str | None = None) -> list[str]: + """Find all occurrences of print-like messages. + + Messages for printing variables are not included. + + :param expr: A message pattern. + :param nodeid: Optional node ID to filter messages. + :return: A list of messages. + """ + return list(self._messages(expr, nodeid=nodeid)) + + def _messages(self, expr: str, *, nodeid: str | None) -> Iterator[str]: + pattern = _compile_pattern_for_print_channel(expr) + yield from self._parselines(pattern, nodeid, _CHANNEL_FOR_PRINT) + + def _parselines(self, pattern: re.Pattern[str], nodeid: str | None, channel: str) -> Iterator[str]: + assert pattern.groups == 1 + + if nodeid is None: + lines_dict = self._find_magic_teardownsections(channel) + lines: Sequence[str] = list(chain.from_iterable(lines_dict.values())) + else: + lines = self._find_magic_teardownsection(nodeid, channel) + + for match in filter(None, map(pattern.match, lines)): + value = match.group(1) + assert isinstance(value, str), (pattern, nodeid, channel) + yield value + + def _find_magic_teardownsection(self, nodeid: str, channel: str) -> Sequence[str]: + nodeid = _compile_nodeid_pattern(nodeid) + main_pattern, stop_pattern = _get_magic_patterns(nodeid, channel) + + state = 0 + start, stop = None, None # type: (int | None, int | None) + for index, line in enumerate(self.res.outlines): + if state == 0 and main_pattern.search(line): + start = index + 1 # skip the header itself + state = 1 + + elif state == 1 and stop_pattern.search(line): + stop = index + state = 2 + + elif state == 2: + if stop == index - 1 and line == _END_CONTENT: + return self.lines[start:stop] + + state = 0 # try again + start, stop = None, None + + return [] + + def _find_magic_teardownsections(self, channel: str) -> dict[str, Sequence[str]]: + main_pattern, stop_pattern = _get_magic_patterns(r'(?P.+::.+)', channel) + + state, curid = 0, None + positions: dict[str, tuple[int | None, int | None]] = {} + index = 0 + while index < len(self.lines): + line = self.lines[index] + if state == 0 and (m := main_pattern.search(line)) is not None: + assert curid is None + curid = m.group(1) + assert curid is not None + assert curid not in positions + # we ignore the header in the output + positions[curid] = (index + 1, None) + state = 1 + elif state == 1 and (m := stop_pattern.search(line)) is not None: + assert curid is not None + nodeid = m.group(1) + if curid == nodeid: # found a corresponding section + positions[nodeid] = (positions[nodeid][0], index) + state = 2 # check that the content of the end section is correct + else: + # something went wrong :( + prev_top_index, _ = positions.pop(curid) + # reset the state and the ID we were looking for + state, curid = 0, None + # next loop iteration will retry the whole block + assert prev_top_index is not None + index = prev_top_index + elif state == 2: + assert curid is not None + assert curid in positions + _, prev_bot_index = positions[curid] + assert prev_bot_index == index - 1 + # check that the previous line was the header + if line != _END_CONTENT: + # we did not have the expected end content (note that + # this implementation does not support having end-markers + # inside another section) + del positions[curid] + # next loop iteration will retry the same line but in state 0 + index = prev_bot_index + + # reset the state and the ID we were looking for + state, curid = 0, None + + index += 1 + + return {n: self.lines[i:j] for n, (i, j) in positions.items() if j is not None} diff --git a/tests/test_testing/conftest.py b/tests/test_testing/conftest.py new file mode 100644 index 00000000000..dda84f9d379 --- /dev/null +++ b/tests/test_testing/conftest.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +import pytest + +from ._const import MAGICO_PLUGIN_NAME, PROJECT_PATH, SPHINX_PLUGIN_NAME +from ._util import E2E + +if TYPE_CHECKING: + from _pytest.config import Config + from _pytest.pytester import Pytester + +pytest_plugins = ['pytester'] +collect_ignore = [MAGICO_PLUGIN_NAME] + + +# change this fixture when the rest of the test suite is changed +@pytest.fixture(scope='package') +def default_testroot(): + return 'minimal' + + +@pytest.fixture() +def e2e(pytester: Pytester) -> E2E: + return E2E(pytester) + + +@pytest.fixture(autouse=True) +def _pytester_pyprojecttoml(pytester: Pytester) -> None: + # TL;DR: this is a patch to force pytester & xdist using the local plugin + # implementation and not a possibly out-of-date installed version. + # + # :mod:`xdist.plugin` contains a snapshot of ``sys.path`` which is then + # passed to the workers; however, the snapshot is created when the module + # is imported. Apparently, even if ``pytester.syspathinsert(...)`` is used, + # the ``xdist.plugin`` module is not reloaded and thus the snapshot is not + # correctly updated, meaning that when ``pytester`` effectively runs a test + # the ``xdist`` workers are not able to find the local implementation. + # + # In addition :mod:`xdist.remote` ignores a user-defined PYTHONPATH when + # setting its own ``sys.path``, leading to ``ImportError`` at runtime. + # + # Note that PyCharm (and probably other IDEs or tools) does not suffer from + # this since it can be configured to automatically extend ``sys.path`` with + # the project's sources. The issue seems to only appear when ``pytest`` is + # directly invoked from the CLI. + pytester.makepyprojecttoml(f''' +[tool.pytest.ini_options] +addopts = ["--import-mode=prepend", "--strict-config", "--strict-markers"] +pythonpath = [{PROJECT_PATH!r}] +xfail_strict = true +''') + + +@pytest.fixture(autouse=True) +def _pytester_conftest(pytestconfig: Config, pytester: Pytester) -> None: + testroot_dir = os.path.join(pytestconfig.rootpath, 'tests', 'roots') + pytester.makeconftest(f''' +import pytest + +pytest_plugins = [{SPHINX_PLUGIN_NAME!r}, {MAGICO_PLUGIN_NAME!r}] +collect_ignore = ['certs', 'roots'] + +@pytest.fixture(scope='session') +def rootdir(): + return {testroot_dir!r} + +@pytest.fixture(scope='session') +def default_testroot(): + return 'minimal' +''') diff --git a/tests/test_testing/magico.py b/tests/test_testing/magico.py new file mode 100644 index 00000000000..d19e33451c3 --- /dev/null +++ b/tests/test_testing/magico.py @@ -0,0 +1,78 @@ +r"""Interception plugin for checking our plugin. + +Testing plugins is achieved by :class:`_pytest.pytester.Pytester`. However, +when ``xdist`` is active, capturing support is limited and it is not possible +to print messages inside the tests being tested and check them outside, e.g.:: + + import textwrap + + def test_my_plugin(pytester): + pytester.makepyfile(textwrap.dedent(''' + def test_inner_1(): print("YAY") + def test_inner_2(): print("YAY") + '''.strip('\n'))) + + # this should capture the output but xdist does not like it! + res = pytester.runpytest('-s', '-n2', '-p', 'xdist') + res.assert_outcomes(passed=2) + res.stdout.fnmatch_lines_random(["*YAY*"]) # this fails! + +Nevertheless, it is possible to treat the (non-failure) report sections shown +when using ``-rA`` as "standard output" as well and parse their content. To +that end, ``test_inner_*`` should use a special fixture instead of ``print`` +as follows:: + + import textwrap + + from ._const import MAGICO + + def test_my_plugin(e2e): + e2e.makepyfile(textwrap.dedent(f''' + def test_inner_1({MAGICO}): {MAGICO}.info("YAY1") + def test_inner_2({MAGICO}): {MAGICO}.info("YAY2") + '''.strip('\n'))) + + output = e2e.xdist_run(passed=2) + assert output.messages() == ["YAY1", "YAY2"] +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from tests.test_testing._const import MAGICO +from tests.test_testing._util import MagicWriter + +if TYPE_CHECKING: + from collections.abc import Generator + +_MAGICAL_KEY: pytest.StashKey[MagicWriter] = pytest.StashKey() + + +@pytest.hookimpl(wrapper=True) +def pytest_runtest_setup(item: pytest.Item) -> Generator[None, None, None]: + """Initialize the magical buffer fixture for the item.""" + item.stash.setdefault(_MAGICAL_KEY, MagicWriter()) + yield + + +@pytest.hookimpl(wrapper=True) +def pytest_runtest_teardown(item: pytest.Item) -> Generator[None, None, None]: + """Write the magical buffer content as a report section.""" + # teardown of fixtures + yield + # now the fixtures have executed their teardowns + if (magicobject := item.stash.get(_MAGICAL_KEY, None)) is not None: + # must be kept in sync with the output extractor + magicobject.pytest_runtest_teardown(item) + del magicobject # be sure not to hold any reference + del item.stash[_MAGICAL_KEY] + + +@pytest.fixture(autouse=True, name=MAGICO) +def __magico_sphinx(request: pytest.FixtureRequest) -> MagicWriter: # NoQA: PT005 + # request.node.stash is not typed in pytest + stash: pytest.Stash = request.node.stash + return stash.setdefault(_MAGICAL_KEY, MagicWriter()) diff --git a/tests/test_testing/test_magico.py b/tests/test_testing/test_magico.py new file mode 100644 index 00000000000..76babe77e73 --- /dev/null +++ b/tests/test_testing/test_magico.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import textwrap + +import pytest +from _pytest.outcomes import Failed + +from ._const import MAGICO + + +def test_native_pytest_cannot_intercept(pytester): + pytester.makepyfile(textwrap.dedent(''' + def test_inner_1(): print("YAY") + def test_inner_2(): print("YAY") + '''.strip('\n'))) + + res = pytester.runpytest('-s', '-n2', '-p', 'xdist') + res.assert_outcomes(passed=2) + + with pytest.raises(Failed): + res.stdout.fnmatch_lines_random(["*YAY*"]) + + +@pytest.mark.serial() +def test_magic_buffer_can_intercept_vars(request, e2e): + e2e.makepyfile(textwrap.dedent(f''' + def test_inner_1({MAGICO}): + {MAGICO}("a", 1) + {MAGICO}("b", -1) + {MAGICO}("b", -2) + + def test_inner_2({MAGICO}): + {MAGICO}("a", 2) + {MAGICO}("b", -3) + {MAGICO}("b", -4) + '''.strip('\n'))) + output = e2e.xdist_run(passed=2) + + assert sorted(output.findall('a', t=int)) == [1, 2] + assert sorted(output.findall('b', t=int)) == [-4, -3, -2, -1] + + assert output.find('a', nodeid='*::test_inner_1', t=int) == 1 + assert output.findall('a', nodeid='*::test_inner_1', t=int) == [1] + + assert output.find('b', nodeid='*::test_inner_1', t=int) == -1 + assert output.findall('b', nodeid='*::test_inner_1', t=int) == [-1, -2] + + assert output.find('a', nodeid='*::test_inner_2', t=int) == 2 + assert output.findall('a', nodeid='*::test_inner_2', t=int) == [2] + + assert output.find('b', nodeid='*::test_inner_2', t=int) == -3 + assert output.findall('b', nodeid='*::test_inner_2', t=int) == [-3, -4] + + +@pytest.mark.serial() +def test_magic_buffer_can_intercept_info(e2e): + e2e.makepyfile(textwrap.dedent(f''' + def test_inner_1({MAGICO}): {MAGICO}.info("YAY1") + def test_inner_2({MAGICO}): {MAGICO}.info("YAY2") + '''.strip('\n'))) + output = e2e.xdist_run(passed=2) + + assert sorted(output.messages()) == ['YAY1', 'YAY2'] + assert output.message(nodeid='*::test_inner_1') == 'YAY1' + assert output.message(nodeid='*::test_inner_2') == 'YAY2' + + +@pytest.mark.serial() +def test_magic_buffer_e2e(e2e): + e2e.write('file1', textwrap.dedent(f''' + def test1({MAGICO}): + {MAGICO}("a", 1) + {MAGICO}("b", 2.5) + {MAGICO}("b", 5.8) + '''.strip('\n'))) + + e2e.write('file2', textwrap.dedent(f''' + def test2({MAGICO}): + {MAGICO}.info("result is:", 123) + {MAGICO}.info("another message") + '''.strip('\n'))) + + output = e2e.xdist_run(passed=2) + + assert output.findall('a', t=int) == [1] + assert output.findall('b', t=float) == [2.5, 5.8] + assert output.messages() == ["result is: 123", "another message"] diff --git a/tests/test_testing/test_plugin_isolation.py b/tests/test_testing/test_plugin_isolation.py new file mode 100644 index 00000000000..6984c7c20ef --- /dev/null +++ b/tests/test_testing/test_plugin_isolation.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import uuid + +import pytest + +from ._const import MAGICO +from ._util import SourceInfo + + +@pytest.fixture() +def random_uuid() -> str: + return uuid.uuid4().hex + + +def test_grouped_isolation_no_shared_result(e2e): + def gen(testid: str) -> str: + return f''' +@pytest.mark.parametrize('value', [1, 2]) +@pytest.mark.sphinx('dummy', testroot='basic') +@pytest.mark.isolate('grouped') +def test_group_{testid}({MAGICO}, app, value): + {MAGICO}({testid!r}, str(app.srcdir)) +''' + e2e.write(['import pytest', gen('a'), gen('b')]) + + output = e2e.run() + + srcs_a = output.findall('a', t=SourceInfo) + assert len(srcs_a) == 2 # two sub-tests + assert len(set(srcs_a)) == 1 + + srcs_b = output.findall('b', t=SourceInfo) + assert len(srcs_b) == 2 # two sub-tests + assert len(set(srcs_b)) == 1 + + srcinfo_a, srcinfo_b = srcs_a[0], srcs_b[0] + assert srcinfo_a.basenode == srcinfo_b.basenode # same namespace + assert srcinfo_a.checksum == srcinfo_b.checksum # same config + assert srcinfo_a.filename != srcinfo_b.filename # diff shared id + + +def test_shared_result(e2e, random_uuid): + def gen(testid: str) -> str: + return f''' +@pytest.mark.parametrize('value', [1, 2]) +@pytest.mark.sphinx('dummy', testroot='basic') +@pytest.mark.test_params(shared_result={random_uuid!r}) +def test_group_{testid}({MAGICO}, app, value): + {MAGICO}({testid!r}, str(app.srcdir)) +''' + e2e.write('import pytest') + e2e.write(gen('a')) + e2e.write(gen('b')) + output = e2e.run() + + srcs_a = output.findall('a', t=SourceInfo) + assert len(srcs_a) == 2 # two sub-tests + assert len(set(srcs_a)) == 1 + + srcs_b = output.findall('b', t=SourceInfo) + assert len(srcs_b) == 2 # two sub-tests + assert len(set(srcs_b)) == 1 + + assert srcs_a[0] == srcs_b[0] + + +def test_shared_result_different_config(e2e, random_uuid): + def gen(testid: str) -> str: + return f''' +@pytest.mark.parametrize('value', [1, 2]) +@pytest.mark.sphinx('dummy', testroot='basic', confoverrides={{"author": {testid!r}}}) +@pytest.mark.test_params(shared_result={random_uuid!r}) +def test_group_{testid}({MAGICO}, app, value): + {MAGICO}({testid!r}, str(app.srcdir)) +''' + e2e.write('import pytest') + e2e.write(gen('a')) + e2e.write(gen('b')) + output = e2e.run() + + srcs_a = output.findall('a', t=SourceInfo) + assert len(srcs_a) == 2 # two sub-tests + assert len(set(srcs_a)) == 1 + + srcs_b = output.findall('b', t=SourceInfo) + assert len(srcs_b) == 2 # two sub-tests + assert len(set(srcs_b)) == 1 + + srcinfo_a, srcinfo_b = srcs_a[0], srcs_b[0] + assert srcinfo_a.basenode == srcinfo_b.basenode # same namespace + assert srcinfo_a.checksum != srcinfo_b.checksum # diff config + assert srcinfo_a.filename == srcinfo_b.filename # same shared id + + +def test_shared_result_different_module(e2e, random_uuid): + def gen(testid: str) -> str: + return f''' +import pytest + +@pytest.mark.parametrize('value', [1, 2]) +@pytest.mark.sphinx('dummy', testroot='basic') +@pytest.mark.test_params(shared_result={random_uuid!r}) +def test_group_{testid}({MAGICO}, app, value): + {MAGICO}({testid!r}, str(app.srcdir)) +''' + e2e.makepytest(a=gen('a'), b=gen('b')) + output = e2e.run() + + srcs_a = output.findall('a', t=SourceInfo) + assert len(srcs_a) == 2 # two sub-tests + assert srcs_a[0] == srcs_a[1] + + srcs_b = output.findall('b', t=SourceInfo) + assert len(srcs_b) == 2 # two sub-tests + assert len(set(srcs_b)) == 1 + + srcinfo_a, srcinfo_b = srcs_a[0], srcs_b[0] + assert srcinfo_a.basenode != srcinfo_b.basenode # diff namespace + assert srcinfo_a.checksum == srcinfo_b.checksum # same config + assert srcinfo_a.filename == srcinfo_b.filename # same shared id diff --git a/tests/test_testing/test_plugin_markers.py b/tests/test_testing/test_plugin_markers.py new file mode 100644 index 00000000000..c8fe7160759 --- /dev/null +++ b/tests/test_testing/test_plugin_markers.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from enum import IntEnum +from pathlib import Path + +import pytest + + +@pytest.mark.sphinx() +def test_mark_sphinx_use_default_builder(app_params): + args, kwargs = app_params + assert not args + assert kwargs['buildername'] == 'html' + + +@pytest.mark.sphinx('dummy') +def test_mark_sphinx_with_builder(app_params): + args, kwargs = app_params + assert not args + + testroot_path = kwargs['testroot_path'] + assert testroot_path is None or isinstance(testroot_path, str) + assert kwargs['shared_result'] is None + + assert kwargs['buildername'] == 'dummy' + assert kwargs['testroot'] == 'minimal' + assert isinstance(kwargs['srcdir'], Path) + assert kwargs['srcdir'].name == 'minimal' + + +@pytest.mark.parametrize(('sphinx_isolation', 'policy'), [ + (False, 'minimal'), (True, 'always'), + ('minimal', 'minimal'), ('grouped', 'grouped'), ('always', 'always'), +]) +@pytest.mark.sphinx('dummy') +def test_mark_sphinx_with_isolation(app_params, sphinx_isolation, policy): + isolate = app_params.kwargs['isolate'] + assert isinstance(isolate, IntEnum) + assert isolate.name == policy + + +@pytest.mark.sphinx('dummy') +@pytest.mark.test_params() +def test_mark_sphinx_with_implicit_shared_result(app_params, test_params): + shared_result = app_params.kwargs['shared_result'] + assert shared_result == test_params['shared_result'] + + srcdir = app_params.kwargs['srcdir'] + assert srcdir.name == f'minimal-{shared_result}' + + +@pytest.mark.sphinx('dummy') +@pytest.mark.test_params(shared_result='abc123') +def test_mark_sphinx_with_explicit_shared_result(app_params, test_params): + shared_result = app_params.kwargs['shared_result'] + assert shared_result == test_params['shared_result'] + assert shared_result == 'abc123' + + srcdir = app_params.kwargs['srcdir'] + assert srcdir.name == 'minimal-abc123' diff --git a/tests/test_testing/test_plugin_xdist.py b/tests/test_testing/test_plugin_xdist.py new file mode 100644 index 00000000000..8328baceadf --- /dev/null +++ b/tests/test_testing/test_plugin_xdist.py @@ -0,0 +1,349 @@ +from __future__ import annotations + +import itertools +import uuid +from typing import TYPE_CHECKING, NamedTuple + +import pytest + +from sphinx.testing.internal.pytest_util import pytest_not_raises + +from ._const import MAGICO, MAGICO_PLUGIN_NAME, SPHINX_PLUGIN_NAME +from ._util import E2E, SourceInfo + +if TYPE_CHECKING: + from collections.abc import Sequence + from typing import Final, Literal + + from ._util import MagicOutput + + GroupPolicy = Literal['native', 'sphinx', 123] + + +@pytest.mark.serial() +def test_framework_no_xdist(pytester): + pytester.makepyfile(f''' +from sphinx.testing.internal.pytest_xdist import get_xdist_policy + +def test_check_setup(pytestconfig): + assert pytestconfig.pluginmanager.has_plugin({SPHINX_PLUGIN_NAME!r}) + assert pytestconfig.pluginmanager.has_plugin({MAGICO_PLUGIN_NAME!r}) + assert not pytestconfig.pluginmanager.has_plugin('xdist') + assert get_xdist_policy(pytestconfig) == 'no' +''') + assert E2E(pytester).run(passed=1) + + +@pytest.mark.serial() +def test_framework_with_xdist(pytester): + pytester.makepyfile(f''' +from sphinx.testing.internal.pytest_xdist import get_xdist_policy + +def test_check_setup(pytestconfig): + assert pytestconfig.pluginmanager.has_plugin({SPHINX_PLUGIN_NAME!r}) + assert pytestconfig.pluginmanager.has_plugin({MAGICO_PLUGIN_NAME!r}) + assert pytestconfig.pluginmanager.has_plugin('xdist') + assert get_xdist_policy(pytestconfig) == 'loadgroup' +''') + assert E2E(pytester).xdist_run(passed=1) + + +FOO: Final[str] = 'foo' +BAR: Final[str] = 'bar' +GROUP_POLICIES: Final[Sequence[GroupPolicy]] = ('native', 'sphinx', 123) + + +def _SRCDIR_VAR(testid): + return f'sid[{testid}]' + + +def _NODEID_VAR(testid): + return f'nid[{testid}]' + + +def _WORKID_VAR(testid): + return f'wid[{testid}]' + +# common header to write once + + +_FILEHEADER = r''' +import pytest + +@pytest.fixture(autouse=True) +def _add_test_id(request, app_info_extras): + app_info_extras.update(test_id=request.node.nodeid) + +@pytest.fixture() +def value(): # fake fixture that is to be replaced by a parametrization + return 0 +''' + + +def _casecontent(testid: str, *, group: GroupPolicy, parametrized: bool) -> str: + if group == 'native': + # do not use the auto strategy + xdist_group_mark = '@pytest.mark.sphinx_no_default_xdist()' + elif group == 'sphinx': + # use the auto-strategy by Sphinx + xdist_group_mark = None + else: + xdist_group_mark = f"@pytest.mark.xdist_group({str(group)!r})" + + if parametrized: + parametrize_mark = "@pytest.mark.parametrize('value', [1, 2])" + else: + parametrize_mark = None + + marks = '\n'.join(filter(None, (xdist_group_mark, parametrize_mark))) + return f''' +{marks} +@pytest.mark.sphinx('dummy') +def test_group_{testid}({MAGICO}, request, app, worker_id, value): + assert request.config.pluginmanager.has_plugin('xdist') + assert hasattr(request.config, 'workerinput') + + {MAGICO}({_SRCDIR_VAR(testid)!r}, str(app.srcdir)) + {MAGICO}({_NODEID_VAR(testid)!r}, request.node.nodeid) + {MAGICO}({_WORKID_VAR(testid)!r}, worker_id) +''' + + +class _ExtractInfo(NamedTuple): + source: SourceInfo + """The sources directory information.""" + + workid: str + """The xdist-worker ID.""" + nodeid: str + """The test node id.""" + + @property + def loader(self) -> str | None: + """The xdist-group (if any).""" + parts = self.nodeid.rsplit('@', maxsplit=1) + assert len(parts) == 2 or parts == [self.nodeid] + return parts[1] if len(parts) == 2 else None + + +def _extract_infos(output: MagicOutput, name: str, *, parametrized: bool) -> list[_ExtractInfo]: + srcs = output.findall(_SRCDIR_VAR(name), t=SourceInfo) + assert len(srcs) > 1 if parametrized else len(srcs) == 1 + assert all(srcs) + + wids = output.findall(_WORKID_VAR(name)) + assert len(wids) == len(srcs) + assert all(wids) + + nids = output.findall(_NODEID_VAR(name)) + assert len(nids) == len(srcs) + assert all(nids) + + return [ + _ExtractInfo(source, workid, nodeid) + for source, workid, nodeid in zip(srcs, wids, nids) + ] + + +def _check_parametrized_test_suite(suite: Sequence[_ExtractInfo]) -> None: + for tx, ty in itertools.combinations(suite, 2): + # sub-tests have different node IDs + assert tx.nodeid != ty.nodeid + # With xdist enabled, sub-tests are by default dispatched + # arbitrarily and may not have the same real path; however + # their namespace and configuration checksum must match. + assert tx.source.basenode == ty.source.basenode + assert tx.source.checksum == ty.source.checksum + assert tx.source.filename == ty.source.filename + + # the real paths of x and y only differ by their worker id + assert tx.workid in tx.source.realpath + x_to_y = tx.source.realpath.replace(tx.workid, ty.workid, 1) + assert ty.workid in ty.source.realpath + y_to_x = ty.source.realpath.replace(ty.workid, tx.workid, 1) + assert x_to_y == ty.source.realpath + assert y_to_x == tx.source.realpath + + +def _check_xdist_group(group: GroupPolicy, items: Sequence[_ExtractInfo]) -> None: + groups = {item.loader for item in items} + assert len(groups) == 1 + actual_group = groups.pop() + + if group == 'native': + # no group is specified + assert actual_group is None + elif group == 'sphinx': + # sphinx automatically generates a group using UUID-5 + assert isinstance(actual_group, str) + with pytest_not_raises(TypeError, ValueError): + uuid.UUID(actual_group, version=5) + else: + assert isinstance(group, int) + assert actual_group == str(group) + + +def _check_same_policy(group: GroupPolicy, suites: Sequence[Sequence[_ExtractInfo]]) -> None: + suite_loaders = [{item.loader for item in suite} for suite in suites] + assert all(len(loaders) == 1 for loaders in suite_loaders) + groups = [loaders.pop() for loaders in suite_loaders] + + if group == 'native': + for group_name, suite in zip(groups, suites): + assert group_name is None, suite + elif group == 'sphinx': + # the auto-generated groups are different + # because the tests are at different places + assert len(set(groups)) == len(groups) + else: + for group_name, suite in zip(groups, suites): + assert group_name == str(group), suite + + +@pytest.mark.serial() +class TestParallelTestingModule: + @staticmethod + def run(e2e: E2E, *, parametrized: bool, **groups: GroupPolicy) -> MagicOutput: + e2e.write(_FILEHEADER) + for testid, group in groups.items(): + e2e.write(_casecontent(testid, group=group, parametrized=parametrized)) + return e2e.xdist_run(jobs=len(groups)) # ensure to have distinct runners + + @pytest.mark.parametrize('policy', GROUP_POLICIES) + def test_source_for_non_parmetrized_tests(self, e2e: E2E, policy: GroupPolicy) -> None: + output = self.run(e2e, **dict.fromkeys((FOO, BAR), policy), parametrized=False) + foo = _extract_infos(output, FOO, parametrized=False)[0] + bar = _extract_infos(output, BAR, parametrized=False)[0] + + if policy in {'native', 'sphinx'}: + # by default, the worker ID will be different, hence + # the difference of paths + assert foo.source.realpath != bar.source.realpath + else: + # same group *and* same configuration implies (by default) + # the same sources directory (i.e., no side-effect expected) + assert foo.source.realpath == bar.source.realpath + + # same module, so same base node + assert foo.source.basenode == bar.source.basenode + # same configuration for this minimal test + assert foo.source.checksum == bar.source.checksum + # the sources directory name is the same since no isolation is expected + assert foo.source.filename == bar.source.filename + + # the node IDs are distinct + assert foo.nodeid != bar.nodeid + + if policy in {'native', 'sphinx'}: + # the worker IDs are distinct since no xdist group is set + assert foo.workid != bar.workid + # for non-parametrized tests, 'native' and 'sphinx' policies + # are equivalent (i.e., they do not set an xdist group) + assert foo.loader is None + assert bar.loader is None + else: + # the worker IDs are the same since they have the same group + group = str(policy) + assert foo.workid == bar.workid + assert foo.loader == group + assert bar.loader == group + + @pytest.mark.parametrize(('foo_group', 'bar_group'), [ + *zip(GROUP_POLICIES, GROUP_POLICIES), + *itertools.combinations(GROUP_POLICIES, 2), + ]) + def test_source_for_parametrized_tests( + self, e2e: E2E, foo_group: GroupPolicy, bar_group: GroupPolicy, + ) -> None: + output = self.run(e2e, **{FOO: foo_group, BAR: bar_group}, parametrized=True) + foo = _extract_infos(output, FOO, parametrized=True) + bar = _extract_infos(output, BAR, parametrized=True) + + _check_parametrized_test_suite(foo) + _check_parametrized_test_suite(bar) + + tx: _ExtractInfo + ty: _ExtractInfo + + for tx, ty in itertools.combinations((*foo, *bar), 2): + # inter-collectors also have the same source info + # except for the node location (fspath, lineno) + assert tx.source.basenode == ty.source.basenode + assert tx.source.checksum == ty.source.checksum + assert tx.source.filename == ty.source.filename + + _check_xdist_group(foo_group, foo) + _check_xdist_group(bar_group, bar) + + if (group := foo_group) == bar_group: + _check_same_policy(group, [foo, bar]) + + +@pytest.mark.serial() +class TestParallelTestingPackage: + """Same as :class:`TestParallelTestingModule` but with tests in different files.""" + + @staticmethod + def run(e2e: E2E, *, parametrized: bool, **groups: GroupPolicy) -> MagicOutput: + for testid, group in groups.items(): + source = _casecontent(testid, group=group, parametrized=parametrized) + e2e.write(testid, _FILEHEADER, source) + return e2e.xdist_run(jobs=len(groups)) # ensure to have distinct runners + + @pytest.mark.parametrize('policy', GROUP_POLICIES) + def test_source_for_non_parmetrized_tests(self, e2e: E2E, policy: GroupPolicy) -> None: + output = self.run(e2e, **dict.fromkeys((FOO, BAR), policy), parametrized=False) + foo = _extract_infos(output, FOO, parametrized=False)[0] + bar = _extract_infos(output, BAR, parametrized=False)[0] + + # Unlike for the module-scope tests, both the full path + # and the namespace ID are distinct since they are based + # on the module name (which is distinct for each suite since + # they are in different files). + assert foo.source.realpath != bar.source.realpath + assert foo.source.basenode != bar.source.basenode + + # logic blow is the same as for module-scoped tests + assert foo.source.checksum == bar.source.checksum + assert foo.source.filename == bar.source.filename + assert foo.nodeid != bar.nodeid + + if policy in {'native', 'sphinx'}: + assert foo.workid != bar.workid + assert foo.loader is None + assert bar.loader is None + else: + group = str(policy) + assert foo.workid == bar.workid + assert foo.loader == group + assert bar.loader == group + + @pytest.mark.parametrize(('foo_group', 'bar_group'), [ + *zip(GROUP_POLICIES, GROUP_POLICIES), + *itertools.combinations(GROUP_POLICIES, 2), + ]) + def test_source_for_parametrized_tests( + self, e2e: E2E, foo_group: GroupPolicy, bar_group: GroupPolicy, + ) -> None: + output = self.run(e2e, **{FOO: foo_group, BAR: bar_group}, parametrized=True) + foo = _extract_infos(output, FOO, parametrized=True) + bar = _extract_infos(output, BAR, parametrized=True) + + _check_parametrized_test_suite(foo) + _check_parametrized_test_suite(bar) + + tx: _ExtractInfo + ty: _ExtractInfo + for tx, ty in itertools.product(foo, bar): + # the base node is distinct since not in the same module (this + # was already checked previously, but here we check when we mix + # the policies whereas before we checked with identical policies) + assert tx.source.basenode != ty.source.basenode + assert tx.source.checksum == ty.source.checksum + assert tx.source.filename == ty.source.filename + + _check_xdist_group(foo_group, foo) + _check_xdist_group(bar_group, bar) + + if (group := foo_group) == bar_group: + _check_same_policy(group, [foo, bar]) diff --git a/tests/test_testing/test_testroot_finder.py b/tests/test_testing/test_testroot_finder.py new file mode 100644 index 00000000000..3fae5ca6c40 --- /dev/null +++ b/tests/test_testing/test_testroot_finder.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, overload + +import pytest +from _pytest.scope import Scope + +from sphinx.testing.internal.pytest_util import TestRootFinder + +from ._util import e2e_run + +if TYPE_CHECKING: + import os + from typing import Any, Literal + + +def test_testroot_finder_empty_args(): + with pytest.raises(ValueError, match="expecting a non-empty string or None for 'path'"): + TestRootFinder('') + + with pytest.raises(ValueError, match="expecting a non-empty string or None for 'default'"): + TestRootFinder(None, None, '') + + with pytest.raises(ValueError, match="expecting a non-empty string or None for 'default'"): + # prefix is allowed to be '' + TestRootFinder(None, '', '') + + +@pytest.mark.parametrize('prefix', [None, '', 'test-']) +@pytest.mark.parametrize('default', [None, 'default']) +@pytest.mark.parametrize('name', [None, 'foo']) +def test_testroot_finder_no_rootdir(prefix, default, name): + finder = TestRootFinder(prefix=prefix, default=default) + assert finder.find(name) is None + + +@pytest.mark.parametrize('path', ['/']) +@pytest.mark.parametrize('prefix', [None, '']) +@pytest.mark.parametrize(('name', 'expect'), [('foo', '/foo'), (None, None)]) +def test_testroot_finder_no_default_no_prefix(path, prefix, name, expect): + finder = TestRootFinder(path, prefix) + assert finder.find(name) == expect + + +@pytest.mark.parametrize('path', ['/']) +@pytest.mark.parametrize('prefix', [None, '']) +@pytest.mark.parametrize('default', ['default']) +@pytest.mark.parametrize(('name', 'expect'), [('foo', '/foo'), (None, '/default')]) +def test_testroot_finder_with_default(path, prefix, default, name, expect): + finder = TestRootFinder(path, prefix, default) + assert finder.find(name) == expect + + +@pytest.mark.parametrize('path', ['/']) +@pytest.mark.parametrize('prefix', ['test-']) +@pytest.mark.parametrize(('name', 'expect'), [('foo', '/test-foo'), (None, None)]) +def test_testroot_finder_with_prefix(path, prefix, name, expect): + finder = TestRootFinder(path, prefix) + assert finder.find(name) == expect + + +@pytest.mark.parametrize('path', ['/']) +@pytest.mark.parametrize('prefix', ['test-']) +@pytest.mark.parametrize('default', ['default']) +@pytest.mark.parametrize(('name', 'expect'), [('foo', '/test-foo'), (None, '/test-default')]) +def test_testroot_finder(pytester, path, prefix, default, name, expect): + finder = TestRootFinder(path, prefix, default) + assert finder.find(name) == expect + + +############################################################################### +# E2E tests +############################################################################### + + +# fmt: off +@overload +def e2e_with_fixture_def( # NoQA: E704 + fixt: Literal['rootdir'], attr: Literal['path'], + value: str | os.PathLike[str] | None, expect: str | None, + scope: Scope, +) -> str: ... +@overload # NoQA: E302 +def e2e_with_fixture_def( # NoQA: E704 + fixt: Literal['testroot_prefix'], attr: Literal['prefix'], + value: str | None, expect: str, + scope: Scope, +) -> str: ... +@overload # NoQA: E302 +def e2e_with_fixture_def( # NoQA: E704 + fixt: Literal['default_testroot'], attr: Literal['default'], + value: str | None, expect: str | None, + scope: Scope, +) -> str: ... +# fmt: on +def e2e_with_fixture_def( # NoQA: E302 + fixt: str, attr: str, value: Any, expect: Any, scope: Scope, +) -> str: + """A test with an attribute defined via a fixture. + + :param fixt: The fixture name. + :param attr: An attribute name. + :param value: The return value of the fixture. + :param expect: The expected attribute value. + :param scope: The fixture scope. + :return: The test file source. + """ + return f''' +import pytest + +@pytest.fixture(scope={scope.value!r}) +def {fixt}(): + return {value!r} + +def test(testroot_finder, {fixt}): + assert {fixt} == {value!r} + assert testroot_finder.{attr} == {expect!r} +''' + + +# fmt: off +@overload +def e2e_with_parametrize( # NoQA: E704 + fixt: Literal['rootdir'], attr: Literal['path'], + value: str | os.PathLike[str] | None, expect: str | None, + scope: Scope, +) -> str: ... +@overload # NoQA: E302 +def e2e_with_parametrize( # NoQA: E704 + fixt: Literal['testroot_prefix'], attr: Literal['prefix'], + value: str | None, expect: str, + scope: Scope, +) -> str: ... +@overload # NoQA: E302 +def e2e_with_parametrize( # NoQA: E704 + fixt: Literal['default_testroot'], attr: Literal['default'], + value: str | None, expect: str | None, + scope: Scope, +) -> str: ... +# fmt: on +def e2e_with_parametrize( # NoQA: E302 + fixt: str, attr: str, value: Any, expect: Any, scope: Scope, +) -> str: + """A test with an attribute defined via parametrization.""" + return f''' +import pytest + +@pytest.mark.parametrize({fixt!r}, [{value!r}], scope={scope.value!r}) +def test(testroot_finder, {fixt}): + assert {fixt} == {value!r} + assert testroot_finder.{attr} == {expect!r} +''' + + +@pytest.mark.parametrize('scope', Scope) +@pytest.mark.parametrize('value', [None, '/']) +def test_rootdir_e2e(pytester, scope, value): + script1 = e2e_with_fixture_def('rootdir', 'path', value, value, scope) + script2 = e2e_with_parametrize('rootdir', 'path', value, value, scope) + pytester.makepyfile(test_fixture_def=script1, test_parametrize=script2) + e2e_run(pytester, passed=2) + + +@pytest.mark.parametrize('scope', Scope) +@pytest.mark.parametrize('value', ['my-', '', None]) +def test_testroot_prefix_e2e(pytester, scope, value): + expect = value or '' # the constructor of TestRootFinder normalizes the prefix + script1 = e2e_with_fixture_def('testroot_prefix', 'prefix', value, expect, scope) + script2 = e2e_with_parametrize('testroot_prefix', 'prefix', value, expect, scope) + pytester.makepyfile(test_fixture_def=script1, test_parametrize=script2) + e2e_run(pytester, passed=2) + + +@pytest.mark.parametrize('scope', Scope) +@pytest.mark.parametrize('value', [None, 'default']) +def test_default_testroot_e2e(pytester, scope, value): + script1 = e2e_with_fixture_def('default_testroot', 'default', value, value, scope) + script2 = e2e_with_parametrize('default_testroot', 'default', value, value, scope) + pytester.makepyfile(test_fixture_def=script1, test_parametrize=script2) + e2e_run(pytester, passed=2) diff --git a/tests/test_toctree.py b/tests/test_toctree.py index e59085d4aca..39f7d25d37c 100644 --- a/tests/test_toctree.py +++ b/tests/test_toctree.py @@ -31,6 +31,7 @@ def test_singlehtml_toctree(app, status, warning): @pytest.mark.sphinx(testroot='toctree', srcdir="numbered-toctree") +@pytest.mark.isolate() # because we change the sources in-place def test_numbered_toctree(app, status, warning): # give argument to :numbered: option index = (app.srcdir / 'index.rst').read_text(encoding='utf8') From 550a30f6dc6d75c6cc3326626b22d824a2fd4adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Thu, 14 Mar 2024 17:06:19 +0100 Subject: [PATCH 07/47] fixup --- sphinx/testing/internal/pytest_util.py | 5 ++--- sphinx/testing/internal/util.py | 9 ++------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/sphinx/testing/internal/pytest_util.py b/sphinx/testing/internal/pytest_util.py index 047b83c9e94..497c0a08b4e 100644 --- a/sphinx/testing/internal/pytest_util.py +++ b/sphinx/testing/internal/pytest_util.py @@ -18,7 +18,7 @@ if TYPE_CHECKING: from collections.abc import Callable, Collection, Generator, Iterable - from typing import Any, ClassVar, Final, NoReturn + from typing import Any, ClassVar, Final T = TypeVar('T') DT = TypeVar('DT') @@ -43,8 +43,7 @@ class TestRootFinder: '/foo/bar/test-abc' """ - # This is still needed even if sphinx.testing.internal.__test__ is False - # because when this class is imported by pytest, it is considered a test. + # This is needed to avoid this class being considered as a test by pytest. __test__: ClassVar[Literal[False]] = False def __init__( diff --git a/sphinx/testing/internal/util.py b/sphinx/testing/internal/util.py index c165225f901..1f763bc8cac 100644 --- a/sphinx/testing/internal/util.py +++ b/sphinx/testing/internal/util.py @@ -33,17 +33,12 @@ def make_unique_id() -> str: ... # NoQA: E704 def make_unique_id(prefix: str | os.PathLike[str]) -> str: ... # NoQA: E704 # fmt: on def make_unique_id(prefix: str | os.PathLike[str] | None = None) -> str: # NoQA: E302 - r"""Generate a unique identifier prefixed by *prefix*. + r"""Generate a 128-bit unique identifier prefixed by *prefix*. :param prefix: An optional prefix to prepend to the unique identifier. :return: A unique identifier. - - .. note:: - - The probability for generating two identical IDs is negligible - and happens with the same probability as """ - suffix = uuid.uuid4().hex + suffix = os.urandom(16).hex() # 128-bits of entropy return '-'.join((os.fsdecode(prefix), suffix)) if prefix else suffix From 2235e7fa6b9ca37fd1a7fb5474e203531e7cec16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Thu, 14 Mar 2024 17:07:55 +0100 Subject: [PATCH 08/47] fixup --- tests/test_testing/test_magico.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_testing/test_magico.py b/tests/test_testing/test_magico.py index 76babe77e73..12c4791aec8 100644 --- a/tests/test_testing/test_magico.py +++ b/tests/test_testing/test_magico.py @@ -24,12 +24,12 @@ def test_inner_2(): print("YAY") @pytest.mark.serial() def test_magic_buffer_can_intercept_vars(request, e2e): e2e.makepyfile(textwrap.dedent(f''' - def test_inner_1({MAGICO}): + def test_inner_1({MAGICO}): {MAGICO}("a", 1) {MAGICO}("b", -1) {MAGICO}("b", -2) - def test_inner_2({MAGICO}): + def test_inner_2({MAGICO}): {MAGICO}("a", 2) {MAGICO}("b", -3) {MAGICO}("b", -4) From c8ffbd8b5e35b463200ddc1f60916f1259c30f99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Thu, 14 Mar 2024 17:36:22 +0100 Subject: [PATCH 09/47] try to fix windows --- sphinx/testing/fixtures.py | 4 +++- sphinx/testing/internal/util.py | 4 ++-- tests/test_extensions/test_ext_autodoc.py | 1 + 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index 00d0d3a5fb7..2d9553282d1 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -28,7 +28,7 @@ from sphinx.testing.internal.pytest_xdist import is_pytest_xdist_enabled from sphinx.testing.util import ( SphinxTestApp, - SphinxTestAppLazyBuild, + SphinxTestAppLazyBuild, strip_escseq, ) if TYPE_CHECKING: @@ -130,6 +130,8 @@ def pytest_runtest_teardown(item: pytest.Item) -> Generator[None, None, None]: ): # use carriage returns to avoid being printed inside the progression bar # and additionally show the node ID for visual purposes + if os.name == 'nt': + text = strip_escseq(text) print('\n\n', f'[{item.nodeid}]', '\n', text, sep='', end='') # NoQA: T201 item.add_report_section(f'teardown [{item.nodeid}]', 'fixture %r' % 'app', text) diff --git a/sphinx/testing/internal/util.py b/sphinx/testing/internal/util.py index 1f763bc8cac..571923d526f 100644 --- a/sphinx/testing/internal/util.py +++ b/sphinx/testing/internal/util.py @@ -51,10 +51,10 @@ def default_encoder(x: object) -> str: return hex(id(x))[2:] # use the most compact JSON format - env = json.dumps(args, ensure_ascii=False, sort_keys=True, indent=None, + env = json.dumps(args, ensure_ascii=True, sort_keys=True, indent=None, separators=(',', ':'), default=default_encoder) # avoid using unique_object_id() since we do not really need SHA-1 entropy - return binascii.crc32(env.encode('utf-8', errors='backslashreplace')) + return binascii.crc32(env.encode('utf-8')) # Use a LRU cache to speed-up the generation of the UUID-5 value diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py index 203e5b439ca..38602ce4a6e 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -2164,6 +2164,7 @@ def test_singledispatchmethod_classmethod_automethod(app): reason='Cython does not support Python 3.13 yet.') @pytest.mark.skipif(pyximport is None, reason='cython is not installed') @pytest.mark.sphinx('html', testroot='ext-autodoc') +@pytest.mark.isolate() def test_cython(app): options = {"members": None, "undoc-members": None} From 2b372ccb3b7d068c84c8bb437a74c190e6575984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Thu, 14 Mar 2024 17:43:28 +0100 Subject: [PATCH 10/47] fixup --- sphinx/testing/fixtures.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index 2d9553282d1..02bd2e54e86 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -28,7 +28,8 @@ from sphinx.testing.internal.pytest_xdist import is_pytest_xdist_enabled from sphinx.testing.util import ( SphinxTestApp, - SphinxTestAppLazyBuild, strip_escseq, + SphinxTestAppLazyBuild, + strip_escseq, ) if TYPE_CHECKING: From 866059ed5003066e05b615208af338f0216e66b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Thu, 14 Mar 2024 17:46:43 +0100 Subject: [PATCH 11/47] fixup? --- sphinx/testing/fixtures.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index 02bd2e54e86..3dbd9534dda 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -132,7 +132,11 @@ def pytest_runtest_teardown(item: pytest.Item) -> Generator[None, None, None]: # use carriage returns to avoid being printed inside the progression bar # and additionally show the node ID for visual purposes if os.name == 'nt': + # replace some weird stuff text = strip_escseq(text) + # replace un-encodable characters (don't know why pytest does not like that + # although it was fine when just using print outside of the report section) + text = text.encode('utf-8', errors='backslashrepplace').decode('utf-8') print('\n\n', f'[{item.nodeid}]', '\n', text, sep='', end='') # NoQA: T201 item.add_report_section(f'teardown [{item.nodeid}]', 'fixture %r' % 'app', text) From 9ee89b4065cd803fdff1ee9b23d94b4b58f953bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Thu, 14 Mar 2024 17:51:19 +0100 Subject: [PATCH 12/47] fixup --- sphinx/testing/fixtures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index 3dbd9534dda..01393d265ab 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -136,7 +136,7 @@ def pytest_runtest_teardown(item: pytest.Item) -> Generator[None, None, None]: text = strip_escseq(text) # replace un-encodable characters (don't know why pytest does not like that # although it was fine when just using print outside of the report section) - text = text.encode('utf-8', errors='backslashrepplace').decode('utf-8') + text = text.encode('ascii', errors='backslashreplace').decode('ascii') print('\n\n', f'[{item.nodeid}]', '\n', text, sep='', end='') # NoQA: T201 item.add_report_section(f'teardown [{item.nodeid}]', 'fixture %r' % 'app', text) From 100f861b547893a972dd7666d59ebd163ba66e32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 15 Mar 2024 09:49:14 +0100 Subject: [PATCH 13/47] fix a check + test --- sphinx/testing/internal/markers.py | 2 +- tests/test_intl/test_catalogs.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sphinx/testing/internal/markers.py b/sphinx/testing/internal/markers.py index f77ac22380b..a09f0a9b8a9 100644 --- a/sphinx/testing/internal/markers.py +++ b/sphinx/testing/internal/markers.py @@ -143,7 +143,7 @@ def _get_sphinx_environ(node: PytestNode, default_builder: str) -> SphinxMarkEnv err = 'missing builder name, got: %r' % buildername pytest.fail(format_mark_failure('sphinx', err)) - check_mark_keywords('sphinx', SphinxMarkEnviron.__annotations__, env, node=node) + check_mark_keywords('sphinx', SphinxMarkEnviron.__annotations__, env, node=node) return env diff --git a/tests/test_intl/test_catalogs.py b/tests/test_intl/test_catalogs.py index b7fd7be6f1c..1b74bca86e6 100644 --- a/tests/test_intl/test_catalogs.py +++ b/tests/test_intl/test_catalogs.py @@ -26,10 +26,10 @@ def _setup_test(app_params): @pytest.mark.usefixtures('_setup_test') -@pytest.mark.test_params(shared_result='test-catalogs') @pytest.mark.sphinx( 'html', testroot='intl', confoverrides={'language': 'en', 'locale_dirs': ['./locale']}) +@pytest.mark.isolate() # for Windows def test_compile_all_catalogs(app, status, warning): app.builder.compile_all_catalogs() @@ -42,10 +42,10 @@ def test_compile_all_catalogs(app, status, warning): @pytest.mark.usefixtures('_setup_test') -@pytest.mark.test_params(shared_result='test-catalogs') @pytest.mark.sphinx( 'html', testroot='intl', confoverrides={'language': 'en', 'locale_dirs': ['./locale']}) +@pytest.mark.isolate() # for Windows def test_compile_specific_catalogs(app, status, warning): locale_dir = app.srcdir / 'locale' catalog_dir = locale_dir / app.config.language / 'LC_MESSAGES' @@ -59,10 +59,10 @@ def test_compile_specific_catalogs(app, status, warning): @pytest.mark.usefixtures('_setup_test') -@pytest.mark.test_params(shared_result='test-catalogs') @pytest.mark.sphinx( 'html', testroot='intl', confoverrides={'language': 'en', 'locale_dirs': ['./locale']}) +@pytest.mark.isolate() # for Windows def test_compile_update_catalogs(app, status, warning): app.builder.compile_update_catalogs() From 875ef513a1abcf7ca1eecda2f601bd3270dcb2d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 15 Mar 2024 11:59:36 +0100 Subject: [PATCH 14/47] fixup --- sphinx/testing/fixtures.py | 4 +- sphinx/testing/internal/cache.py | 2 +- sphinx/testing/internal/markers.py | 95 ++++++++++++++------- sphinx/testing/internal/util.py | 58 ++++++++----- tests/test_extensions/test_ext_autodoc.py | 20 +++-- tests/test_testing/_util.py | 28 +++--- tests/test_testing/test_plugin_isolation.py | 8 +- tests/test_testing/test_plugin_xdist.py | 23 +++-- 8 files changed, 145 insertions(+), 93 deletions(-) diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index 01393d265ab..8f7f924939e 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -46,10 +46,10 @@ ( 'sphinx(' 'buildername="html", /, *, ' - 'testroot="root", confoverrides=None, ' + 'testroot="root", srcdir=None, confoverrides=None, ' 'freshenv=None, warningiserror=False, tags=None, ' 'verbosity=0, parallel=0, keep_going=False, ' - 'docutils_conf=None, isolate=False' + 'builddir=None, docutils_conf=None, isolate=False' '): arguments to initialize the sphinx test application.' ), 'test_params(*, shared_result=None): test configuration.', diff --git a/sphinx/testing/internal/cache.py b/sphinx/testing/internal/cache.py index d8526669f72..74ea7d5f634 100644 --- a/sphinx/testing/internal/cache.py +++ b/sphinx/testing/internal/cache.py @@ -10,7 +10,7 @@ class _CacheEntry(TypedDict): - """Cached entry in a :class:`SharedResult`.""" + """Cached entry in a :class:`ModuleCache`.""" status: str """The application's status output.""" diff --git a/sphinx/testing/internal/markers.py b/sphinx/testing/internal/markers.py index a09f0a9b8a9..fc0bcfc0e02 100644 --- a/sphinx/testing/internal/markers.py +++ b/sphinx/testing/internal/markers.py @@ -22,9 +22,9 @@ get_node_location, ) from sphinx.testing.internal.util import ( + get_container_id, get_environ_checksum, get_location_id, - get_namespace_id, make_unique_id, ) @@ -40,15 +40,27 @@ from sphinx.testing.internal.pytest_util import TestRootFinder +class _MISSING_TYPE: + pass + + +_MISSING = _MISSING_TYPE() + + class SphinxMarkEnviron(TypedDict, total=False): """Typed dictionary for the arguments of :func:`pytest.mark.sphinx`. - Note that this class differs from :class:`SphinxInitKwargs` since it - reflects the signature of the :func:`pytest.mark.sphinx` marker, but - not of the :class:`~sphinx.testing.util.SphinxTestApp` constructor. + For the :func:`!pytest.mark.sphinx` marker, we only allow keyword + arguments and not positional arguments except the builder name. + + Note that this differs from the :class:`~sphinx.testing.util.SphinxTestApp` + constructor which accepts both positional and keyword arguments; however + this is done as such so that it makes easier to check the marker itself. """ buildername: str + srcdir: str + confoverrides: dict[str, Any] # using freshenv=True will be treated as equivalent to use isolate=True # but in the future, we might want to deprecate this marker keyword in @@ -59,6 +71,8 @@ class SphinxMarkEnviron(TypedDict, total=False): verbosity: int parallel: int keep_going: bool + + builddir: str docutils_conf: str # added or updated fields @@ -93,8 +107,8 @@ class SphinxInitKwargs(TypedDict, total=False): parallel: int keep_going: bool # :class:`sphinx.testing.util.SphinxTestApp` optional arguments - docutils_conf: str | None builddir: Path | None + docutils_conf: str | None # :class:`sphinx.testing.util.SphinxTestApp` extras arguments isolate: Required[Isolation] """The deduced isolation policy.""" @@ -147,16 +161,27 @@ def _get_sphinx_environ(node: PytestNode, default_builder: str) -> SphinxMarkEnv return env -def _get_test_srcdir(testroot: str | None, shared_result: str | None) -> str: +def _get_test_srcdir( + srcdir: str | None, + testroot: str | None, + shared_result: str | None, +) -> str: """Deduce the sources directory from the given arguments. + :param srcdir: An optional explicit source directory name. :param testroot: An optional testroot ID to use. :param shared_result: An optional shared result ID. :return: The sources directory name *srcdir* (non-empty string). """ - check_mark_str_args('sphinx', testroot=testroot) + check_mark_str_args('sphinx', srcdir=srcdir, testroot=testroot) check_mark_str_args('test_params', shared_result=shared_result) + if srcdir is not None: + # the srcdir is explicitly given, so we use this name + # and we do not bother to make it unique (the user is + # responsible for that !) + return srcdir + if shared_result is not None: # include the testroot id for visual purposes (unless it is # not specified, which only occurs when there is no rootdir) @@ -165,6 +190,7 @@ def _get_test_srcdir(testroot: str | None, shared_result: str | None) -> str: if testroot is None: # neither an explicit nor the default testroot ID is given pytest.fail('missing %r or %r parameter' % ('testroot', 'srcdir')) + return testroot @@ -195,10 +221,6 @@ def process_sphinx( err = '%r and %r are mutually exclusive' % ('freshenv', 'isolate') pytest.fail(format_mark_failure('sphinx', err)) - # If 'freshenv=True', we switch to a full isolation; otherwise, - # we keep 'freshenv=False' and use the default isolation (note - # that 'isolate' is not specified, so we would have still used - # the default isolation). isolation = env['isolate'] = Isolation.always if freshenv else default_isolation else: freshenv = env['freshenv'] = False @@ -209,44 +231,53 @@ def process_sphinx( # 1.2. deduce the testroot ID testroot_id = env['testroot'] = env.get('testroot', testroot_finder.default) # 1.3. deduce the srcdir ID - srcdir = _get_test_srcdir(testroot_id, shared_result) + srcdir_name = env.get('srcdir', None) + srcdir = _get_test_srcdir(srcdir_name, testroot_id, shared_result) # 2. process the srcdir ID according to the isolation policy + is_unique_srcdir_id = srcdir_name is not None if isolation is Isolation.always: + # srcdir = XYZ-(32-bit random) srcdir = make_unique_id(srcdir) + is_unique_srcdir_id = True elif isolation is Isolation.grouped: if (location := get_node_location(node)) is None: srcdir = make_unique_id(srcdir) + is_unique_srcdir_id = True else: # For a 'grouped' isolation, we want the same prefix (the deduced # sources dierctory), but with a unique suffix based on the node # location. In particular, parmetrized tests will have the same # final ``srcdir`` value as they have the same location. suffix = get_location_id(location) + # srcdir = XYZ-(64-bit random) srcdir = f'{srcdir}-{suffix}' - # Do a somewhat hash on configuration values to give a minimal protection - # against side-effects (two tests with the same configuration should have - # the same output; if they mess up with their sources directory, then they - # should be isolated accordingly). If there is a bug in the test suite, we - # can reduce the number of tests that can have dependencies by adding some - # isolation safeguards. - testhash = get_namespace_id(node) - checksum = 0 if isolation is Isolation.always else get_environ_checksum( - env['buildername'], - # The default values must be kept in sync with the constructor - # default values of :class:`sphinx.testing.util.SphinxTestApp`. - env.get('confoverrides'), - env.get('freshenv', False), - env.get('warningiserror', False), - env.get('tags'), - env.get('verbosity', 0), - env.get('parallel', 0), - env.get('keep_going', False), - ) + if is_unique_srcdir_id: + namespace, checksum = '-', 0 + else: + namespace = get_container_id(node) + # Do a somewhat hash on configuration values to give a minimal protection + # against side-effects (two tests with the same configuration should have + # the same output; if they mess up with their sources directory, then they + # should be isolated accordingly). If there is a bug in the test suite, we + # can reduce the number of tests that can have dependencies by adding some + # isolation safeguards. + checksum = get_environ_checksum( + env['buildername'], + # The default values must be kept in sync with the constructor + # default values of :class:`sphinx.testing.util.SphinxTestApp`. + env.get('confoverrides'), + env.get('freshenv', False), + env.get('warningiserror', False), + env.get('tags'), + env.get('verbosity', 0), + env.get('parallel', 0), + env.get('keep_going', False), + ) kwargs = cast(SphinxInitKwargs, env) - kwargs['srcdir'] = Path(session_temp_dir, testhash, str(checksum), srcdir) + kwargs['srcdir'] = Path(session_temp_dir, namespace, str(checksum), srcdir) kwargs['testroot_path'] = testroot_finder.find(testroot_id) kwargs['shared_result'] = shared_result return [], kwargs diff --git a/sphinx/testing/internal/util.py b/sphinx/testing/internal/util.py index 571923d526f..93939665014 100644 --- a/sphinx/testing/internal/util.py +++ b/sphinx/testing/internal/util.py @@ -12,19 +12,26 @@ import json import os import pickle -import uuid from functools import lru_cache from typing import TYPE_CHECKING, overload import pytest if TYPE_CHECKING: - from typing import Any + from typing import Any, Final from _pytest.nodes import Node as PytestNode from sphinx.testing.internal.pytest_util import TestNodeLocation +UID_BITLEN: int = 32 +r"""The bit-length of unique identifiers generated by this module. + +Must be a power of two in :math:`[8, 128]`. +""" +UID_BUFLEN: Final[int] = UID_BITLEN // 8 +UID_HEXLEN: Final[int] = UID_BITLEN // 4 + # fmt: off @overload @@ -33,12 +40,12 @@ def make_unique_id() -> str: ... # NoQA: E704 def make_unique_id(prefix: str | os.PathLike[str]) -> str: ... # NoQA: E704 # fmt: on def make_unique_id(prefix: str | os.PathLike[str] | None = None) -> str: # NoQA: E302 - r"""Generate a 128-bit unique identifier prefixed by *prefix*. + r"""Generate a random unique identifier prefixed by *prefix*. :param prefix: An optional prefix to prepend to the unique identifier. - :return: A unique identifier. + :return: A random unique identifier. """ - suffix = os.urandom(16).hex() # 128-bits of entropy + suffix = os.urandom(UID_BUFLEN).hex() return '-'.join((os.fsdecode(prefix), suffix)) if prefix else suffix @@ -57,10 +64,6 @@ def default_encoder(x: object) -> str: return binascii.crc32(env.encode('utf-8')) -# Use a LRU cache to speed-up the generation of the UUID-5 value -# when generating the object ID for parametrized sub-tests (those -# sub-tests will be using the same "object id") since UUID-5 is -# based on SHA-1. @lru_cache(maxsize=65536) def unique_object_id(name: str) -> str: """Get a unique hexadecimal identifier for an object name. @@ -68,29 +71,38 @@ def unique_object_id(name: str) -> str: :param name: The name of the object to get a unique ID of. :return: A unique hexadecimal identifier for *name*. """ + from hashlib import sha1 + # ensure that non UTF-8 characters are supported and handled similarly - sanitized = name.encode('utf-8', errors='backslashreplace').decode('utf-8') - return uuid.uuid5(uuid.NAMESPACE_OID, sanitized).hex + h = sha1(name.encode('utf-8', errors='backslashreplace')) + byt = int.from_bytes(h.digest()[:UID_BUFLEN], byteorder='little') + return f'%00{UID_HEXLEN}x' % byt + +def get_container_id(node: PytestNode) -> str: + """Get a unique identifier for the node's container. -def get_namespace_id(node: PytestNode) -> str: - """Get a unique hexadecimal identifier for the node's namespace. + The node's container is defined by all but the last component of the + node's path (e.g., ``pkg.mod.test_func`` is contained in ``pkg.mod``). - The node's namespace is defined by all the modules and classes - the node is part of. + The entropy of the unique identifier is roughly 32-bits. """ - namespace = '@'.join(filter(None, ( - getattr(t.obj, '__name__', None) or None for t in node.listchain() - if isinstance(t, (pytest.Module, pytest.Class)) and t.obj - ))) or node.nodeid - return unique_object_id(namespace) + def get_obj_name(subject: PytestNode) -> str | None: + if isinstance(subject, pytest.Package): + return subject.name + if isinstance(subject, (pytest.Module, pytest.Class)): + return getattr(subject.obj, '__name__', None) + return None + + names = map(get_obj_name, node.listchain()) + container = '@'.join(filter(None, names)) or node.nodeid + return unique_object_id(container) def get_location_id(location: TestNodeLocation) -> str: - """Make a unique ID out of a test node location. + """Make a (roughly) 32-bit ID out of a test node location. - The ID is based on the physical node location (file and line number) - and is more precise than :func:`py_location_hash`. + The ID is based on the physical node location (file and line number). """ fspath, lineno = location return unique_object_id(f'{fspath}:L{lineno}') diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py index 38602ce4a6e..4f4e3b3d62d 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -7,6 +7,7 @@ import functools import operator import sys +import uuid from types import SimpleNamespace from unittest.mock import Mock from warnings import catch_warnings @@ -240,9 +241,9 @@ class G2(F2): pass assert formatsig('class', 'F2', F2, None, None) == \ - '(a1, a2, kw1=True, kw2=False)' + '(a1, a2, kw1=True, kw2=False)' assert formatsig('class', 'G2', G2, None, None) == \ - '(a1, a2, kw1=True, kw2=False)' + '(a1, a2, kw1=True, kw2=False)' # test for methods class H: @@ -254,6 +255,7 @@ def foo2(b, *c): def foo3(self, d='\n'): pass + assert formatsig('method', 'H.foo', H.foo1, None, None) == '(b, *c)' assert formatsig('method', 'H.foo', H.foo1, 'a', None) == '(a)' assert formatsig('method', 'H.foo', H.foo2, None, None) == '(*c)' @@ -275,16 +277,16 @@ def foo3(self, d='\n'): from functools import partial curried1 = partial(lambda a, b, c: None, 'A') assert formatsig('function', 'curried1', curried1, None, None) == \ - '(b, c)' + '(b, c)' curried2 = partial(lambda a, b, c=42: None, 'A') assert formatsig('function', 'curried2', curried2, None, None) == \ - '(b, c=42)' + '(b, c=42)' curried3 = partial(lambda a, b, *c: None, 'A') assert formatsig('function', 'curried3', curried3, None, None) == \ - '(b, *c)' + '(b, *c)' curried4 = partial(lambda a, b, c=42, *d, **e: None, 'A') assert formatsig('function', 'curried4', curried4, None, None) == \ - '(b, c=42, *d, **e)' + '(b, c=42, *d, **e)' @pytest.mark.sphinx('html', testroot='ext-autodoc') @@ -373,6 +375,7 @@ def f(): class J: def foo(self): """Method docstring""" + assert getdocl('method', J.foo) == ['Method docstring'] assert getdocl('function', J().foo) == ['Method docstring'] @@ -2163,8 +2166,9 @@ def test_singledispatchmethod_classmethod_automethod(app): @pytest.mark.skipif(sys.version_info[:2] >= (3, 13), reason='Cython does not support Python 3.13 yet.') @pytest.mark.skipif(pyximport is None, reason='cython is not installed') -@pytest.mark.sphinx('html', testroot='ext-autodoc') -@pytest.mark.isolate() +# use an explicit 'srcdir' to make the path smaller on Windows platforms +# so that cython can correctly compile the files +@pytest.mark.sphinx('html', srcdir=uuid.uuid4().hex, testroot='ext-autodoc') def test_cython(app): options = {"members": None, "undoc-members": None} diff --git a/tests/test_testing/_util.py b/tests/test_testing/_util.py index d52fe2a3720..3a471753eb1 100644 --- a/tests/test_testing/_util.py +++ b/tests/test_testing/_util.py @@ -4,7 +4,7 @@ import fnmatch import os import re -import uuid +import string from functools import lru_cache from io import StringIO from itertools import chain @@ -14,6 +14,8 @@ import pytest +from sphinx.testing.internal.util import UID_HEXLEN + from tests.test_testing._const import MAGICO_PLUGIN_NAME, SPHINX_PLUGIN_NAME if TYPE_CHECKING: @@ -26,22 +28,26 @@ def _parse_path(path: str) -> tuple[str, str, int, str]: fspath = Path(path) - checksum = fspath.parent.stem + checksum = fspath.parent.stem # can be '0' or a 32-bit numeric string if not checksum or not checksum.isnumeric(): pytest.fail(f'cannot extract configuration checksum from: {path!r}') - basenode = fspath.parent.parent.stem - - try: - uuid.UUID(basenode, version=5) - except ValueError: - pytest.fail(f'cannot extract namespace hash from: {path!r}') + contnode = fspath.parent.parent.stem # can be '-' or a hex string + if contnode != '-': + if not set(contnode).issubset(string.hexdigits): + pytest.fail(f'cannot extract container node ID from: {path!r} ' + 'expecting %r or a hexadecimal string, got %r' % ('-', contnode)) + if len(contnode) != UID_HEXLEN: + pytest.fail(f'cannot extract container node ID from: {path!r} ' + f'({contnode!r} must be of length {UID_HEXLEN}, got {len(contnode)})') - return str(fspath), basenode, int(checksum), fspath.stem + return str(fspath), contnode, int(checksum), fspath.stem @final class SourceInfo(tuple[str, str, int, str]): + """View on the sources directory path's components.""" + # We do not use a NamedTuple nor a dataclass since we we want an immutable # class in which its constructor checks the format of its unique argument. __slots__ = () @@ -55,8 +61,8 @@ def realpath(self) -> str: return self[0] @property - def basenode(self) -> str: - """The test node namespace identifier.""" + def contnode(self) -> str: + """The node container's identifier.""" return self[1] @property diff --git a/tests/test_testing/test_plugin_isolation.py b/tests/test_testing/test_plugin_isolation.py index 6984c7c20ef..811f60ad575 100644 --- a/tests/test_testing/test_plugin_isolation.py +++ b/tests/test_testing/test_plugin_isolation.py @@ -24,7 +24,7 @@ def test_group_{testid}({MAGICO}, app, value): ''' e2e.write(['import pytest', gen('a'), gen('b')]) - output = e2e.run() + output = e2e.run(silent=False) srcs_a = output.findall('a', t=SourceInfo) assert len(srcs_a) == 2 # two sub-tests @@ -35,7 +35,7 @@ def test_group_{testid}({MAGICO}, app, value): assert len(set(srcs_b)) == 1 srcinfo_a, srcinfo_b = srcs_a[0], srcs_b[0] - assert srcinfo_a.basenode == srcinfo_b.basenode # same namespace + assert srcinfo_a.contnode == srcinfo_b.contnode # same namespace assert srcinfo_a.checksum == srcinfo_b.checksum # same config assert srcinfo_a.filename != srcinfo_b.filename # diff shared id @@ -88,7 +88,7 @@ def test_group_{testid}({MAGICO}, app, value): assert len(set(srcs_b)) == 1 srcinfo_a, srcinfo_b = srcs_a[0], srcs_b[0] - assert srcinfo_a.basenode == srcinfo_b.basenode # same namespace + assert srcinfo_a.contnode == srcinfo_b.contnode # same namespace assert srcinfo_a.checksum != srcinfo_b.checksum # diff config assert srcinfo_a.filename == srcinfo_b.filename # same shared id @@ -116,6 +116,6 @@ def test_group_{testid}({MAGICO}, app, value): assert len(set(srcs_b)) == 1 srcinfo_a, srcinfo_b = srcs_a[0], srcs_b[0] - assert srcinfo_a.basenode != srcinfo_b.basenode # diff namespace + assert srcinfo_a.contnode != srcinfo_b.contnode # diff namespace assert srcinfo_a.checksum == srcinfo_b.checksum # same config assert srcinfo_a.filename == srcinfo_b.filename # same shared id diff --git a/tests/test_testing/test_plugin_xdist.py b/tests/test_testing/test_plugin_xdist.py index 8328baceadf..2f48ca0e30b 100644 --- a/tests/test_testing/test_plugin_xdist.py +++ b/tests/test_testing/test_plugin_xdist.py @@ -1,13 +1,12 @@ from __future__ import annotations import itertools -import uuid +import string from typing import TYPE_CHECKING, NamedTuple import pytest -from sphinx.testing.internal.pytest_util import pytest_not_raises - +from sphinx.testing.internal.util import UID_HEXLEN from ._const import MAGICO, MAGICO_PLUGIN_NAME, SPHINX_PLUGIN_NAME from ._util import E2E, SourceInfo @@ -146,13 +145,13 @@ def _extract_infos(output: MagicOutput, name: str, *, parametrized: bool) -> lis def _check_parametrized_test_suite(suite: Sequence[_ExtractInfo]) -> None: - for tx, ty in itertools.combinations(suite, 2): + for tx, ty in itertools.combinations(suite, 2): # type: (_ExtractInfo, _ExtractInfo) # sub-tests have different node IDs assert tx.nodeid != ty.nodeid # With xdist enabled, sub-tests are by default dispatched # arbitrarily and may not have the same real path; however # their namespace and configuration checksum must match. - assert tx.source.basenode == ty.source.basenode + assert tx.source.contnode == ty.source.contnode assert tx.source.checksum == ty.source.checksum assert tx.source.filename == ty.source.filename @@ -174,10 +173,10 @@ def _check_xdist_group(group: GroupPolicy, items: Sequence[_ExtractInfo]) -> Non # no group is specified assert actual_group is None elif group == 'sphinx': - # sphinx automatically generates a group using UUID-5 + # sphinx automatically generates a group using the node location assert isinstance(actual_group, str) - with pytest_not_raises(TypeError, ValueError): - uuid.UUID(actual_group, version=5) + assert set(actual_group).issubset(string.hexdigits) + assert len(actual_group) == UID_HEXLEN else: assert isinstance(group, int) assert actual_group == str(group) @@ -225,7 +224,7 @@ def test_source_for_non_parmetrized_tests(self, e2e: E2E, policy: GroupPolicy) - assert foo.source.realpath == bar.source.realpath # same module, so same base node - assert foo.source.basenode == bar.source.basenode + assert foo.source.contnode == bar.source.contnode # same configuration for this minimal test assert foo.source.checksum == bar.source.checksum # the sources directory name is the same since no isolation is expected @@ -268,7 +267,7 @@ def test_source_for_parametrized_tests( for tx, ty in itertools.combinations((*foo, *bar), 2): # inter-collectors also have the same source info # except for the node location (fspath, lineno) - assert tx.source.basenode == ty.source.basenode + assert tx.source.contnode == ty.source.contnode assert tx.source.checksum == ty.source.checksum assert tx.source.filename == ty.source.filename @@ -301,7 +300,7 @@ def test_source_for_non_parmetrized_tests(self, e2e: E2E, policy: GroupPolicy) - # on the module name (which is distinct for each suite since # they are in different files). assert foo.source.realpath != bar.source.realpath - assert foo.source.basenode != bar.source.basenode + assert foo.source.contnode != bar.source.contnode # logic blow is the same as for module-scoped tests assert foo.source.checksum == bar.source.checksum @@ -338,7 +337,7 @@ def test_source_for_parametrized_tests( # the base node is distinct since not in the same module (this # was already checked previously, but here we check when we mix # the policies whereas before we checked with identical policies) - assert tx.source.basenode != ty.source.basenode + assert tx.source.contnode != ty.source.contnode assert tx.source.checksum == ty.source.checksum assert tx.source.filename == ty.source.filename From bc002280891fe9d36a173a6a878337b33846f898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 15 Mar 2024 13:43:07 +0100 Subject: [PATCH 15/47] make the plugin backwards compatible --- sphinx/testing/fixtures.py | 276 +++++++++++++++----- sphinx/testing/internal/cache.py | 28 +- sphinx/testing/internal/markers.py | 24 +- sphinx/testing/internal/util.py | 4 +- tests/conftest.py | 19 +- tests/test_testing/_util.py | 2 +- tests/test_testing/conftest.py | 4 + tests/test_testing/test_plugin_isolation.py | 2 +- tests/test_testing/test_plugin_xdist.py | 1 + 9 files changed, 280 insertions(+), 80 deletions(-) diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index 8f7f924939e..53028f07204 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -9,38 +9,46 @@ import subprocess import sys import warnings -from io import StringIO -from typing import TYPE_CHECKING, Optional +from collections.abc import Callable +from typing import TYPE_CHECKING, Optional, cast import pytest from sphinx.deprecation import RemovedInSphinx90Warning -from sphinx.testing.internal.cache import ModuleCache +from sphinx.testing.internal.cache import LegacyModuleCache, ModuleCache from sphinx.testing.internal.isolation import Isolation from sphinx.testing.internal.markers import ( + AppLegacyParams, AppParams, get_location_id, process_isolate, process_sphinx, process_test_params, ) -from sphinx.testing.internal.pytest_util import TestRootFinder, find_context +from sphinx.testing.internal.pytest_util import ( + TestRootFinder, + find_context, + get_mark_parameters, +) from sphinx.testing.internal.pytest_xdist import is_pytest_xdist_enabled from sphinx.testing.util import ( SphinxTestApp, SphinxTestAppLazyBuild, + SphinxTestAppWrapperForSkipBuilding, strip_escseq, ) if TYPE_CHECKING: - from collections.abc import Callable, Generator + from collections.abc import Generator + from io import StringIO from pathlib import Path - from typing import Any, Final + from typing import Any, Final, Union from sphinx.testing.internal.isolation import IsolationPolicy - from sphinx.testing.internal.markers import ( - TestParams, - ) + from sphinx.testing.internal.markers import TestParams + + AnySphinxTestApp = Union[SphinxTestApp, SphinxTestAppWrapperForSkipBuilding] + AnyAppParams = Union[AppParams, AppLegacyParams] DEFAULT_ENABLED_MARKERS: Final[list[str]] = [ ( @@ -59,6 +67,10 @@ ############################################################################### # pytest hooks +# +# *** IMPORTANT *** +# +# The hooks must be compatible with the legacy plugin until Sphinx 9.x. ############################################################################### @@ -66,6 +78,8 @@ def pytest_addhooks(pluginmanager: pytest.PytestPluginManager) -> None: if pluginmanager.has_plugin('xdist'): from sphinx.testing import _xdist_hooks + # the legacy plugin does not really care about this plugin + # since it only depends on 'xdist' and not on sphinx itself pluginmanager.register(_xdist_hooks, name='sphinx-xdist-hooks') @@ -141,11 +155,24 @@ def pytest_runtest_teardown(item: pytest.Item) -> Generator[None, None, None]: item.add_report_section(f'teardown [{item.nodeid}]', 'fixture %r' % 'app', text) + ############################################################################### # sphinx fixtures ############################################################################### +@pytest.fixture() +def sphinx_use_legacy_plugin() -> bool: # xref RemovedInSphinx90Warning + """If true, use the legacy implementation of fixtures. + + Redefine this fixture in ``conftest.py`` or at the test level to use + the new plugin implementation (note that the test code might require + changes). By default, the new implementation is disabled so that no + breaking changes occur outside of Sphinx itself. + """ + return True + + @pytest.fixture(scope='session') def sphinx_test_tempdir(tmp_path_factory: pytest.TempPathFactory) -> Path: """Fixture for a temporary directory.""" @@ -201,6 +228,11 @@ def testroot_finder( return TestRootFinder(rootdir, testroot_prefix, default_testroot) +############################################################################### +# fixture: app_params() +############################################################################### + + def _init_sources(src: str | None, dst: Path, isolation: Isolation) -> None: if src is None or dst.exists(): return @@ -220,8 +252,7 @@ def _init_sources(src: str | None, dst: Path, isolation: Isolation) -> None: os.chmod(os.path.join(dirpath, filename), 0o444) -@pytest.fixture() -def app_params( +def __app_params_fixture( request: pytest.FixtureRequest, test_params: TestParams, module_cache: ModuleCache, @@ -230,10 +261,6 @@ def app_params( sphinx_isolation: IsolationPolicy, testroot_finder: TestRootFinder, ) -> AppParams: - """Parameters that are specified by ``pytest.mark.sphinx``. - - See :class:`sphinx.testing.util.SphinxTestApp` for the allowed parameters. - """ default_isolation = process_isolate(request.node, sphinx_isolation) shared_result_id = test_params['shared_result'] args, kwargs = process_sphinx( @@ -259,12 +286,55 @@ def app_params( return AppParams(args, kwargs) +@pytest.fixture() +def app_params( + request: pytest.FixtureRequest, + test_params: TestParams, + module_cache: ModuleCache, + shared_result: LegacyModuleCache, # xref RemovedInSphinx90Warning + sphinx_test_tempdir: Path, + sphinx_builder: str, + sphinx_isolation: IsolationPolicy, + testroot_finder: TestRootFinder, + sphinx_use_legacy_plugin: bool, # xref RemovedInSphinx90Warning +) -> AppParams | AppLegacyParams: + """Parameters that are specified by ``pytest.mark.sphinx``. + + See :class:`sphinx.testing.util.SphinxTestApp` for the allowed parameters. + """ + if sphinx_use_legacy_plugin: + msg = ('legacy implementation of sphinx.testing.fixtures is ' + 'deprecated; consider redefining sphinx_legacy_plugin() ' + 'in conftest.py to return False.') + warnings.warn(msg, RemovedInSphinx90Warning, stacklevel=2) + return __app_params_fixture_legacy( + request, test_params, shared_result, + sphinx_test_tempdir, testroot_finder.path, + ) + + return __app_params_fixture( + request, test_params, module_cache, + sphinx_test_tempdir, sphinx_builder, + sphinx_isolation, testroot_finder, + ) + + +############################################################################### +# fixture: test_params() +############################################################################### + + @pytest.fixture() def test_params(request: pytest.FixtureRequest) -> TestParams: """Test parameters that are specified by ``pytest.mark.test_params``.""" return process_test_params(request.node) +############################################################################### +# fixture: app() +############################################################################### + + @dataclasses.dataclass class _AppInfo: """Report to render at the end of a test using the :func:`app` fixture.""" @@ -318,9 +388,7 @@ def render(self) -> str: def _get_app_info( - request: pytest.FixtureRequest, - app: SphinxTestApp, - app_params: AppParams, + request: pytest.FixtureRequest, app: SphinxTestApp, app_params: AppParams, ) -> _AppInfo: # request.node.stash is not typed correctly in pytest stash: pytest.Stash = request.node.stash @@ -339,9 +407,10 @@ def _get_app_info( def app_info_extras( request: pytest.FixtureRequest, # ``app`` is not used but is marked as a dependency - app: SphinxTestApp, + app: AnySphinxTestApp, # xref RemovedInSphinx90Warning: update type # ``app_params`` is already a dependency of ``app`` - app_params: AppParams, + app_params: AnyAppParams, # xref RemovedInSphinx90Warning: update type + sphinx_use_legacy_plugin: bool, ) -> dict[str, Any]: """Fixture to update the information to render at the end of a test. @@ -352,23 +421,25 @@ def _add_app_info_extras(app, app_info_extras): app_info_extras.update(my_extra=1234) app_info_extras.update(app_extras=app.extras) """ + # xref RemovedInSphinx90Warning: remove the assert + assert not sphinx_use_legacy_plugin, 'legacy plugin does not support this fixture' + # xref RemovedInSphinx90Warning: remove the cast + app = cast(SphinxTestApp, app) + # xref RemovedInSphinx90Warning: remove the cast + app_params = cast(AppParams, app_params) app_info = _get_app_info(request, app, app_params) return app_info.extras -@pytest.fixture() -def app( +def __app_fixture( request: pytest.FixtureRequest, app_params: AppParams, make_app: Callable[..., SphinxTestApp], module_cache: ModuleCache, ) -> Generator[SphinxTestApp, None, None]: - """A :class:`sphinx.application.Sphinx` object suitable for testing.""" - # the 'app_params' fixture already depends on the 'test_result' fixture shared_result = app_params.kwargs['shared_result'] app = make_app(*app_params.args, **app_params.kwargs) yield app - info = _get_app_info(request, app, app_params) # update the messages accordingly info.messages = app.status.getvalue() @@ -379,28 +450,75 @@ def app( @pytest.fixture() -def status(app: SphinxTestApp) -> StringIO: +def app( + request: pytest.FixtureRequest, + app_params: AnyAppParams, # xref RemovedInSphinx90Warning: update type + test_params: TestParams, # xref RemovedInSphinx90Warning + make_app: Callable[..., AnySphinxTestApp], # xref RemovedInSphinx90Warning: update type + module_cache: ModuleCache, + shared_result: LegacyModuleCache, # xref RemovedInSphinx90Warning + sphinx_use_legacy_plugin: bool, # xref RemovedInSphinx90Warning +) -> Generator[AnySphinxTestApp, None, None]: # xref RemovedInSphinx90Warning: update type + """A :class:`sphinx.application.Sphinx` object suitable for testing.""" + # the 'app_params' fixture already depends on the 'test_result' fixture + if sphinx_use_legacy_plugin: # xref RemovedInSphinx90Warning + app_params = cast(AppLegacyParams, app_params) + gen = __app_fixture_legacy(request, app_params, test_params, make_app, shared_result) + else: + # xref RemovedInSphinx90Warning: remove the cast + app_params = cast(AppParams, app_params) + make_app = cast(Callable[..., SphinxTestApp], make_app) + gen = __app_fixture(request, app_params, make_app, module_cache) + + yield from gen + return + + +############################################################################### +# other fixtures +############################################################################### + +@pytest.fixture() +def status( + # xref RemovedInSphinx90Warning: narrow type + app: SphinxTestApp | SphinxTestAppWrapperForSkipBuilding, +) -> StringIO: """Fixture for the :func:`~sphinx.testing.plugin.app` status stream.""" return app.status @pytest.fixture() -def warning(app: SphinxTestApp) -> StringIO: +def warning( + # xref RemovedInSphinx90Warning: narrow type + app: SphinxTestApp | SphinxTestAppWrapperForSkipBuilding, +) -> StringIO: """Fixture for the :func:`~sphinx.testing.plugin.app` warning stream.""" return app.warning @pytest.fixture() -def make_app(test_params: TestParams) -> Generator[Callable[..., SphinxTestApp], None, None]: +def make_app( + test_params: TestParams, + sphinx_use_legacy_plugin: bool, # xref RemovedInSphinx90Warning + # xref RemovedInSphinx90Warning: narrow callable return type +) -> Generator[Callable[..., SphinxTestApp | SphinxTestAppWrapperForSkipBuilding], None, None]: """Fixture to create :class:`~sphinx.testing.util.SphinxTestApp` objects.""" stack: list[SphinxTestApp] = [] allow_rebuild = test_params['shared_result'] is None - def make(*args: Any, **kwargs: Any) -> SphinxTestApp: + # xref RemovedInSphinx90Warning: narrow return type + def make(*args: Any, **kwargs: Any) -> SphinxTestApp | SphinxTestAppWrapperForSkipBuilding: if allow_rebuild: app = SphinxTestApp(*args, **kwargs) else: - app = SphinxTestAppLazyBuild(*args, **kwargs) + if sphinx_use_legacy_plugin: # xref RemovedInSphinx90Warning + subject = SphinxTestApp(*args, **kwargs) + + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', category=RemovedInSphinx90Warning) + app = SphinxTestAppWrapperForSkipBuilding(subject) # type: ignore[assignment] # NoQA: E501 + else: + app = SphinxTestAppLazyBuild(*args, **kwargs) stack.append(app) return app @@ -435,7 +553,8 @@ def _module_cache_clear(request: pytest.FixtureRequest) -> None: @pytest.fixture() -def if_graphviz_found(app: SphinxTestApp) -> None: # NoQA: PT004 +# xref RemovedInSphinx90Warning: update type +def if_graphviz_found(app: AnySphinxTestApp) -> None: # NoQA: PT004 """ The test will be skipped when using 'if_graphviz_found' fixture and graphviz dot command is not found. @@ -518,44 +637,81 @@ def rollback_sysmodules() -> Generator[None, None, None]: # NoQA: PT004 ############################################################################### # sphinx deprecated fixtures +# +# Once we are in version 9.x, we can remove the private implementations +# and clean-up the fixtures so that they use a single implementation. ############################################################################### -# XXX: RemovedInSphinx90Warning -class SharedResult: - cache: dict[str, dict[str, str]] = {} +def __app_params_fixture_legacy( # xref RemovedInSphinx90Warning + request: pytest.FixtureRequest, + test_params: TestParams, + shared_result: LegacyModuleCache, + sphinx_test_tempdir: Path, + rootdir: str | os.PathLike[str] | None, +) -> AppLegacyParams: + """ + Parameters that are specified by 'pytest.mark.sphinx' for + sphinx.application.Sphinx initialization + """ + # ##### process pytest.mark.sphinx + args, kwargs = get_mark_parameters(request.node, 'sphinx') - def __init__(self) -> None: - warnings.warn("this object is deprecated and will be removed in the future", - RemovedInSphinx90Warning, stacklevel=2) + # ##### 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) - 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']), - } + 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 = os.path.join(rootdir, 'test-' + testroot) + shutil.copytree(testroot_path, srcdir) + + return AppLegacyParams(args, kwargs) + + +def __app_fixture_legacy( # xref RemovedInSphinx90Warning + request: pytest.FixtureRequest, + app_params: AppLegacyParams, + test_params: TestParams, + make_app: Callable[..., AnySphinxTestApp], + shared_result: LegacyModuleCache, +) -> Generator[AnySphinxTestApp, None, None]: + app = make_app(*app_params.args, **app_params.kwargs) + yield app + + print('# testroot:', app_params.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 shared_result() -> SharedResult: - warnings.warn("this fixture is deprecated; use 'module_cache' instead", - RemovedInSphinx90Warning, stacklevel=2) - return SharedResult() +def shared_result( + request: pytest.FixtureRequest, + sphinx_use_legacy_plugin: bool, +) -> LegacyModuleCache: + if 'app' not in request.fixturenames and not sphinx_use_legacy_plugin: + # warn a direct usage of this fixture + warnings.warn("this fixture is deprecated", RemovedInSphinx90Warning, stacklevel=2) + return LegacyModuleCache() @pytest.fixture(scope='module', autouse=True) def _shared_result_cache() -> None: - # XXX: RemovedInSphinx90Warning - SharedResult.cache.clear() + LegacyModuleCache.cache.clear() # xref RemovedInSphinx90Warning + + +SharedResult = LegacyModuleCache # xref RemovedInSphinx90Warning diff --git a/sphinx/testing/internal/cache.py b/sphinx/testing/internal/cache.py index 74ea7d5f634..2a444770ffd 100644 --- a/sphinx/testing/internal/cache.py +++ b/sphinx/testing/internal/cache.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, TypedDict if TYPE_CHECKING: - from sphinx.testing.util import SphinxTestApp + from sphinx.testing.util import SphinxTestApp, SphinxTestAppWrapperForSkipBuilding class _CacheEntry(TypedDict): @@ -59,3 +59,29 @@ def restore(self, key: str) -> _CacheFrame | None: data = self._cache[key] return {'status': StringIO(data['status']), 'warning': StringIO(data['warning'])} + + +# XXX: RemovedInSphinx90Warning +class LegacyModuleCache: # kept for legacy purposes + cache: dict[str, dict[str, str]] = {} + + def store( + self, key: str, app_: SphinxTestApp | SphinxTestAppWrapperForSkipBuilding, + ) -> None: + 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']), + } diff --git a/sphinx/testing/internal/markers.py b/sphinx/testing/internal/markers.py index fc0bcfc0e02..faec9540d53 100644 --- a/sphinx/testing/internal/markers.py +++ b/sphinx/testing/internal/markers.py @@ -40,13 +40,6 @@ from sphinx.testing.internal.pytest_util import TestRootFinder -class _MISSING_TYPE: - pass - - -_MISSING = _MISSING_TYPE() - - class SphinxMarkEnviron(TypedDict, total=False): """Typed dictionary for the arguments of :func:`pytest.mark.sphinx`. @@ -133,6 +126,11 @@ class AppParams(NamedTuple): """The constructor keyword arguments, including ``buildername``.""" +class AppLegacyParams(NamedTuple): + args: list[Any] + kwargs: dict[str, Any] + + class TestParams(TypedDict): """A view on the arguments of :func:`pytest.mark.test_params`.""" @@ -230,14 +228,14 @@ def process_sphinx( isolation = env['isolate'] = normalize_isolation_policy(isolation) # 1.2. deduce the testroot ID testroot_id = env['testroot'] = env.get('testroot', testroot_finder.default) - # 1.3. deduce the srcdir ID + # 1.3. deduce the srcdir name (possibly explicitly given) srcdir_name = env.get('srcdir', None) srcdir = _get_test_srcdir(srcdir_name, testroot_id, shared_result) # 2. process the srcdir ID according to the isolation policy is_unique_srcdir_id = srcdir_name is not None if isolation is Isolation.always: - # srcdir = XYZ-(32-bit random) + # srcdir = XYZ-(RANDOM-UID) srcdir = make_unique_id(srcdir) is_unique_srcdir_id = True elif isolation is Isolation.grouped: @@ -250,10 +248,12 @@ def process_sphinx( # location. In particular, parmetrized tests will have the same # final ``srcdir`` value as they have the same location. suffix = get_location_id(location) - # srcdir = XYZ-(64-bit random) + # srcdir = XYZ-(RANDOM-UID) srcdir = f'{srcdir}-{suffix}' if is_unique_srcdir_id: + # when the sources directory is known to be unique across + # all other tests, we do not include a namespace or checksum namespace, checksum = '-', 0 else: namespace = get_container_id(node) @@ -267,10 +267,10 @@ def process_sphinx( env['buildername'], # The default values must be kept in sync with the constructor # default values of :class:`sphinx.testing.util.SphinxTestApp`. - env.get('confoverrides'), + env.get('confoverrides', None), env.get('freshenv', False), env.get('warningiserror', False), - env.get('tags'), + env.get('tags', None), env.get('verbosity', 0), env.get('parallel', 0), env.get('keep_going', False), diff --git a/sphinx/testing/internal/util.py b/sphinx/testing/internal/util.py index 93939665014..3ae5292972a 100644 --- a/sphinx/testing/internal/util.py +++ b/sphinx/testing/internal/util.py @@ -84,8 +84,6 @@ def get_container_id(node: PytestNode) -> str: The node's container is defined by all but the last component of the node's path (e.g., ``pkg.mod.test_func`` is contained in ``pkg.mod``). - - The entropy of the unique identifier is roughly 32-bits. """ def get_obj_name(subject: PytestNode) -> str | None: if isinstance(subject, pytest.Package): @@ -100,7 +98,7 @@ def get_obj_name(subject: PytestNode) -> str | None: def get_location_id(location: TestNodeLocation) -> str: - """Make a (roughly) 32-bit ID out of a test node location. + """Get a unique hexadecimal identifier out of a test location. The ID is based on the physical node location (file and line number). """ diff --git a/tests/conftest.py b/tests/conftest.py index ad67eb4caa6..b0ab8ecc04c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -49,6 +49,11 @@ def _init_console(locale_dir=sphinx.locale._LOCALE_DIR, catalog='sphinx'): os.environ['SPHINX_AUTODOC_RELOAD_MODULES'] = '1' +############################################################################### +# pytest hooks +############################################################################### + + def pytest_configure(config: Config) -> None: config.addinivalue_line('markers', 'serial(): mark a test as non-xdist friendly') config.addinivalue_line('markers', 'unload(*pattern): unload matching modules') @@ -62,11 +67,11 @@ def pytest_configure(config: Config) -> None: def pytest_report_header(config: Config) -> str: - headers = { + headers: dict[str, str] = { 'libraries': f'Sphinx-{sphinx.__display_version__}, docutils-{docutils.__version__}', } if (factory := get_tmp_path_factory(config, None)) is not None: - headers['base tmp_path'] = factory.getbasetemp() + headers['base tmp_path'] = os.fsdecode(factory.getbasetemp()) return '\n'.join(f'{key}: {value}' for key, value in headers.items()) @@ -130,6 +135,16 @@ def pytest_collection_modifyitems(session: Session, config: Config, items: list[ items[:] = [item for item in items if item.get_closest_marker('serial') is None] +############################################################################### +# fixtures +############################################################################### + + +@pytest.fixture() +def sphinx_use_legacy_plugin() -> bool: # xref RemovedInSphinx90Warning + return False # use the new implementation + + @pytest.fixture(scope='session') def rootdir() -> Path: return Path(__file__).parent.resolve() / 'roots' diff --git a/tests/test_testing/_util.py b/tests/test_testing/_util.py index 3a471753eb1..3c7812e2c53 100644 --- a/tests/test_testing/_util.py +++ b/tests/test_testing/_util.py @@ -133,7 +133,7 @@ def runpytest(self, *args: str, plugins: Sequence[str] = (), silent: bool = True # runpytest() does not accept 'plugins' if the method is 'subprocess' plugins = (SPHINX_PLUGIN_NAME, MAGICO_PLUGIN_NAME, *plugins) if silent: - with open(os.devnull, 'w') as NUL, contextlib.redirect_stdout(NUL): + with open(os.devnull, 'w', encoding='utf-8') as NUL, contextlib.redirect_stdout(NUL): return self.__pytester.runpytest_inprocess(*args, plugins=plugins) else: return self.__pytester.runpytest_inprocess(*args, plugins=plugins) diff --git a/tests/test_testing/conftest.py b/tests/test_testing/conftest.py index dda84f9d379..f8760756040 100644 --- a/tests/test_testing/conftest.py +++ b/tests/test_testing/conftest.py @@ -63,6 +63,10 @@ def _pytester_conftest(pytestconfig: Config, pytester: Pytester) -> None: pytest_plugins = [{SPHINX_PLUGIN_NAME!r}, {MAGICO_PLUGIN_NAME!r}] collect_ignore = ['certs', 'roots'] +@pytest.fixture() +def sphinx_use_legacy_plugin() -> bool: # xref RemovedInSphinx90Warning + return False # use the new implementation + @pytest.fixture(scope='session') def rootdir(): return {testroot_dir!r} diff --git a/tests/test_testing/test_plugin_isolation.py b/tests/test_testing/test_plugin_isolation.py index 811f60ad575..5e550397b18 100644 --- a/tests/test_testing/test_plugin_isolation.py +++ b/tests/test_testing/test_plugin_isolation.py @@ -24,7 +24,7 @@ def test_group_{testid}({MAGICO}, app, value): ''' e2e.write(['import pytest', gen('a'), gen('b')]) - output = e2e.run(silent=False) + output = e2e.run() srcs_a = output.findall('a', t=SourceInfo) assert len(srcs_a) == 2 # two sub-tests diff --git a/tests/test_testing/test_plugin_xdist.py b/tests/test_testing/test_plugin_xdist.py index 2f48ca0e30b..c0edc7f4e54 100644 --- a/tests/test_testing/test_plugin_xdist.py +++ b/tests/test_testing/test_plugin_xdist.py @@ -7,6 +7,7 @@ import pytest from sphinx.testing.internal.util import UID_HEXLEN + from ._const import MAGICO, MAGICO_PLUGIN_NAME, SPHINX_PLUGIN_NAME from ._util import E2E, SourceInfo From 6f214425d7c7769b150dcb92b4f3e91cf92b02e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 15 Mar 2024 14:00:06 +0100 Subject: [PATCH 16/47] ensure type safety --- sphinx/testing/internal/markers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sphinx/testing/internal/markers.py b/sphinx/testing/internal/markers.py index faec9540d53..7b4f53fa89d 100644 --- a/sphinx/testing/internal/markers.py +++ b/sphinx/testing/internal/markers.py @@ -278,6 +278,8 @@ def process_sphinx( kwargs = cast(SphinxInitKwargs, env) kwargs['srcdir'] = Path(session_temp_dir, namespace, str(checksum), srcdir) + # ensure that the type of a possible 'builddir' argument is indeed a Path + kwargs['builddir'] = Path(builddir) if (builddir := env.get('builddir')) else None kwargs['testroot_path'] = testroot_finder.find(testroot_id) kwargs['shared_result'] = shared_result return [], kwargs From 6789bc3380e0dff7facab6a65c796ecb62cb9c70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 15 Mar 2024 14:14:21 +0100 Subject: [PATCH 17/47] allow buildername as a keyword argument in the marker --- sphinx/testing/internal/markers.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/sphinx/testing/internal/markers.py b/sphinx/testing/internal/markers.py index 7b4f53fa89d..7d50f0cd55b 100644 --- a/sphinx/testing/internal/markers.py +++ b/sphinx/testing/internal/markers.py @@ -145,11 +145,15 @@ def _get_sphinx_environ(node: PytestNode, default_builder: str) -> SphinxMarkEnv pytest.fail(format_mark_failure('sphinx', err)) env = cast(SphinxMarkEnviron, kwargs) - if env.pop('buildername', None) is not None: - err = '%r is a positional-only argument' % 'buildername' - pytest.fail(format_mark_failure('sphinx', err)) - env['buildername'] = buildername = args[0] if args else default_builder + if args: + buildername = args[0] + if buildername != env.pop('buildername', buildername): + err = '%r has duplicated values' % 'buildername' + pytest.fail(format_mark_failure('sphinx', err)) + env['buildername'] = buildername + else: + buildername = env.setdefault('buildername', default_builder) if not buildername: err = 'missing builder name, got: %r' % buildername From 333aa8f7969c879daff89764f963e3e3349d6493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 15 Mar 2024 14:14:34 +0100 Subject: [PATCH 18/47] allow buildername as a keyword argument in the marker --- sphinx/testing/fixtures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index 53028f07204..a4d69f11ba3 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -53,7 +53,7 @@ DEFAULT_ENABLED_MARKERS: Final[list[str]] = [ ( 'sphinx(' - 'buildername="html", /, *, ' + 'buildername="html", *, ' 'testroot="root", srcdir=None, confoverrides=None, ' 'freshenv=None, warningiserror=False, tags=None, ' 'verbosity=0, parallel=0, keep_going=False, ' From 91b035f1a42d8732ca4de02145b1ee4577e0b7e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 15 Mar 2024 14:16:13 +0100 Subject: [PATCH 19/47] allow buildername as a keyword argument in the marker --- sphinx/testing/internal/markers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx/testing/internal/markers.py b/sphinx/testing/internal/markers.py index 7d50f0cd55b..af6d0b231e3 100644 --- a/sphinx/testing/internal/markers.py +++ b/sphinx/testing/internal/markers.py @@ -147,7 +147,7 @@ def _get_sphinx_environ(node: PytestNode, default_builder: str) -> SphinxMarkEnv env = cast(SphinxMarkEnviron, kwargs) if args: - buildername = args[0] + buildername = args.pop() if buildername != env.pop('buildername', buildername): err = '%r has duplicated values' % 'buildername' pytest.fail(format_mark_failure('sphinx', err)) From 1aaa0378b22e5726b7b6b0fc6ae3923e0c7408ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 15 Mar 2024 14:18:21 +0100 Subject: [PATCH 20/47] fix deprecation warning --- sphinx/testing/fixtures.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index a4d69f11ba3..54d7e172316 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -703,7 +703,10 @@ def shared_result( request: pytest.FixtureRequest, sphinx_use_legacy_plugin: bool, ) -> LegacyModuleCache: - if 'app' not in request.fixturenames and not sphinx_use_legacy_plugin: + if ( + not {'app', 'app_params'}.intersection(request.fixturenames) + and not sphinx_use_legacy_plugin + ): # warn a direct usage of this fixture warnings.warn("this fixture is deprecated", RemovedInSphinx90Warning, stacklevel=2) return LegacyModuleCache() From 42e27f0d601a9a7ddfa25908de3ff0b652a12707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 15 Mar 2024 14:23:40 +0100 Subject: [PATCH 21/47] revert some changes --- tests/test_builders/test_build.py | 6 +++--- tests/test_builders/test_build_dirhtml.py | 2 +- tests/test_builders/test_build_html.py | 2 +- tests/test_extensions/test_ext_autodoc.py | 14 ++++++------- .../test_ext_inheritance_diagram.py | 2 +- tests/test_markup/test_smartquotes.py | 20 +++++++++---------- 6 files changed, 22 insertions(+), 24 deletions(-) diff --git a/tests/test_builders/test_build.py b/tests/test_builders/test_build.py index 309afdb2dbf..3f6d12c7c99 100644 --- a/tests/test_builders/test_build.py +++ b/tests/test_builders/test_build.py @@ -67,7 +67,7 @@ def test_root_doc_not_found(tmp_path, make_app): app.build(force_all=True) # no index.rst -@pytest.mark.sphinx('text', testroot='circular') +@pytest.mark.sphinx(buildername='text', testroot='circular') def test_circular_toctree(app, status, warning): app.build(force_all=True) warnings = warning.getvalue() @@ -79,7 +79,7 @@ def test_circular_toctree(app, status, warning): 'index <- sub <- index') in warnings -@pytest.mark.sphinx('text', testroot='numbered-circular') +@pytest.mark.sphinx(buildername='text', testroot='numbered-circular') def test_numbered_circular_toctree(app, status, warning): app.build(force_all=True) warnings = warning.getvalue() @@ -91,7 +91,7 @@ def test_numbered_circular_toctree(app, status, warning): 'index <- sub <- index') in warnings -@pytest.mark.sphinx('dummy', testroot='images') +@pytest.mark.sphinx(buildername='dummy', testroot='images') def test_image_glob(app, status, warning): app.build(force_all=True) diff --git a/tests/test_builders/test_build_dirhtml.py b/tests/test_builders/test_build_dirhtml.py index 3e7d14a1a7b..dc5ab86031d 100644 --- a/tests/test_builders/test_build_dirhtml.py +++ b/tests/test_builders/test_build_dirhtml.py @@ -7,7 +7,7 @@ from sphinx.util.inventory import InventoryFile -@pytest.mark.sphinx('dirhtml', testroot='builder-dirhtml') +@pytest.mark.sphinx(buildername='dirhtml', testroot='builder-dirhtml') def test_dirhtml(app, status, warning): app.build() diff --git a/tests/test_builders/test_build_html.py b/tests/test_builders/test_build_html.py index 581fb4e790c..0d88645d972 100644 --- a/tests/test_builders/test_build_html.py +++ b/tests/test_builders/test_build_html.py @@ -56,7 +56,7 @@ def test_html4_error(make_app, tmp_path): match='HTML 4 is no longer supported by Sphinx', ): make_app( - 'html', + buildername='html', srcdir=tmp_path, confoverrides={'html4_writer': True}, ) diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py index 4f4e3b3d62d..962881b4881 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -241,9 +241,9 @@ class G2(F2): pass assert formatsig('class', 'F2', F2, None, None) == \ - '(a1, a2, kw1=True, kw2=False)' + '(a1, a2, kw1=True, kw2=False)' assert formatsig('class', 'G2', G2, None, None) == \ - '(a1, a2, kw1=True, kw2=False)' + '(a1, a2, kw1=True, kw2=False)' # test for methods class H: @@ -255,7 +255,6 @@ def foo2(b, *c): def foo3(self, d='\n'): pass - assert formatsig('method', 'H.foo', H.foo1, None, None) == '(b, *c)' assert formatsig('method', 'H.foo', H.foo1, 'a', None) == '(a)' assert formatsig('method', 'H.foo', H.foo2, None, None) == '(*c)' @@ -277,16 +276,16 @@ def foo3(self, d='\n'): from functools import partial curried1 = partial(lambda a, b, c: None, 'A') assert formatsig('function', 'curried1', curried1, None, None) == \ - '(b, c)' + '(b, c)' curried2 = partial(lambda a, b, c=42: None, 'A') assert formatsig('function', 'curried2', curried2, None, None) == \ - '(b, c=42)' + '(b, c=42)' curried3 = partial(lambda a, b, *c: None, 'A') assert formatsig('function', 'curried3', curried3, None, None) == \ - '(b, *c)' + '(b, *c)' curried4 = partial(lambda a, b, c=42, *d, **e: None, 'A') assert formatsig('function', 'curried4', curried4, None, None) == \ - '(b, c=42, *d, **e)' + '(b, c=42, *d, **e)' @pytest.mark.sphinx('html', testroot='ext-autodoc') @@ -375,7 +374,6 @@ def f(): class J: def foo(self): """Method docstring""" - assert getdocl('method', J.foo) == ['Method docstring'] assert getdocl('function', J().foo) == ['Method docstring'] diff --git a/tests/test_extensions/test_ext_inheritance_diagram.py b/tests/test_extensions/test_ext_inheritance_diagram.py index 6392f3d268e..c13ccea9247 100644 --- a/tests/test_extensions/test_ext_inheritance_diagram.py +++ b/tests/test_extensions/test_ext_inheritance_diagram.py @@ -15,7 +15,7 @@ from sphinx.ext.intersphinx import load_mappings, normalize_intersphinx_mapping -@pytest.mark.sphinx("html", testroot="inheritance") +@pytest.mark.sphinx(buildername="html", testroot="inheritance") @pytest.mark.usefixtures('if_graphviz_found') def test_inheritance_diagram(app, status, warning): # monkey-patch InheritaceDiagram.run() so we can get access to its diff --git a/tests/test_markup/test_smartquotes.py b/tests/test_markup/test_smartquotes.py index 0900bf2f3f7..1d4e8e1271a 100644 --- a/tests/test_markup/test_smartquotes.py +++ b/tests/test_markup/test_smartquotes.py @@ -4,7 +4,7 @@ from html5lib import HTMLParser -@pytest.mark.sphinx('html', testroot='smartquotes', freshenv=True) +@pytest.mark.sphinx(buildername='html', testroot='smartquotes', freshenv=True) def test_basic(app, status, warning): app.build() @@ -12,7 +12,7 @@ def test_basic(app, status, warning): assert '

– “Sphinx” is a tool that makes it easy …

' in content -@pytest.mark.sphinx('html', testroot='smartquotes', freshenv=True) +@pytest.mark.sphinx(buildername='html', testroot='smartquotes', freshenv=True) def test_literals(app, status, warning): app.build() @@ -30,7 +30,7 @@ def test_literals(app, status, warning): assert code_text == "literal with 'quotes'" -@pytest.mark.sphinx('text', testroot='smartquotes', freshenv=True) +@pytest.mark.sphinx(buildername='text', testroot='smartquotes', freshenv=True) def test_text_builder(app, status, warning): app.build() @@ -38,7 +38,7 @@ def test_text_builder(app, status, warning): assert '-- "Sphinx" is a tool that makes it easy ...' in content -@pytest.mark.sphinx('man', testroot='smartquotes', freshenv=True) +@pytest.mark.sphinx(buildername='man', testroot='smartquotes', freshenv=True) def test_man_builder(app, status, warning): app.build() @@ -46,7 +46,7 @@ def test_man_builder(app, status, warning): assert r'\-\- \(dqSphinx\(dq is a tool that makes it easy ...' in content -@pytest.mark.sphinx('latex', testroot='smartquotes', freshenv=True) +@pytest.mark.sphinx(buildername='latex', testroot='smartquotes', freshenv=True) def test_latex_builder(app, status, warning): app.build() @@ -54,7 +54,7 @@ def test_latex_builder(app, status, warning): assert '\\textendash{} “Sphinx” is a tool that makes it easy …' in content -@pytest.mark.sphinx('html', testroot='smartquotes', freshenv=True, +@pytest.mark.sphinx(buildername='html', testroot='smartquotes', freshenv=True, confoverrides={'language': 'ja'}) def test_ja_html_builder(app, status, warning): app.build() @@ -63,7 +63,7 @@ def test_ja_html_builder(app, status, warning): assert '

-- "Sphinx" is a tool that makes it easy ...

' in content -@pytest.mark.sphinx('html', testroot='smartquotes', freshenv=True, +@pytest.mark.sphinx(buildername='html', testroot='smartquotes', freshenv=True, confoverrides={'smartquotes': False}) def test_smartquotes_disabled(app, status, warning): app.build() @@ -72,7 +72,7 @@ def test_smartquotes_disabled(app, status, warning): assert '

-- "Sphinx" is a tool that makes it easy ...

' in content -@pytest.mark.sphinx('html', testroot='smartquotes', freshenv=True, +@pytest.mark.sphinx(buildername='html', testroot='smartquotes', freshenv=True, confoverrides={'smartquotes_action': 'q'}) def test_smartquotes_action(app, status, warning): app.build() @@ -81,7 +81,7 @@ def test_smartquotes_action(app, status, warning): assert '

-- “Sphinx” is a tool that makes it easy ...

' in content -@pytest.mark.sphinx('html', testroot='smartquotes', freshenv=True, +@pytest.mark.sphinx(buildername='html', testroot='smartquotes', freshenv=True, confoverrides={'language': 'ja', 'smartquotes_excludes': {}}) def test_smartquotes_excludes_language(app, status, warning): app.build() @@ -90,7 +90,7 @@ def test_smartquotes_excludes_language(app, status, warning): assert '

– 「Sphinx」 is a tool that makes it easy …

' in content -@pytest.mark.sphinx('man', testroot='smartquotes', freshenv=True, +@pytest.mark.sphinx(buildername='man', testroot='smartquotes', freshenv=True, confoverrides={'smartquotes_excludes': {}}) def test_smartquotes_excludes_builders(app, status, warning): app.build() From beb9c1ef95c6451ce0990d044e846c2e26b06a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 15 Mar 2024 14:25:24 +0100 Subject: [PATCH 22/47] revert some changes --- tests/test_builders/test_build_text.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_builders/test_build_text.py b/tests/test_builders/test_build_text.py index 59087338972..6dc0d037533 100644 --- a/tests/test_builders/test_build_text.py +++ b/tests/test_builders/test_build_text.py @@ -5,7 +5,14 @@ from sphinx.writers.text import MAXWIDTH, Cell, Table -with_text_app = pytest.mark.sphinx('text', testroot='build-text').with_args + +def with_text_app(*args, **kw): + default_kw = { + 'buildername': 'text', + 'testroot': 'build-text', + } + default_kw.update(kw) + return pytest.mark.sphinx(*args, **default_kw) @with_text_app() From 4d7172a2f4c4c98cd0a13425e6f819b71ae4c5e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 15 Mar 2024 17:06:37 +0100 Subject: [PATCH 23/47] cleanup and simplify --- sphinx/testing/fixtures.py | 114 ++--- sphinx/testing/internal/cache.py | 68 +++ sphinx/testing/internal/markers.py | 9 +- sphinx/testing/internal/pytest_util.py | 3 +- tests/test_testing/_const.py | 8 - tests/test_testing/_util.py | 486 -------------------- tests/test_testing/conftest.py | 12 +- tests/test_testing/magico.py | 78 ---- tests/test_testing/test_magico.py | 87 ---- tests/test_testing/test_plugin_isolation.py | 121 ----- tests/test_testing/test_plugin_xdist.py | 349 -------------- tests/test_testing/test_testroot_finder.py | 18 +- 12 files changed, 115 insertions(+), 1238 deletions(-) delete mode 100644 tests/test_testing/_util.py delete mode 100644 tests/test_testing/magico.py delete mode 100644 tests/test_testing/test_magico.py delete mode 100644 tests/test_testing/test_plugin_isolation.py delete mode 100644 tests/test_testing/test_plugin_xdist.py diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index 54d7e172316..ceca4a03616 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -2,8 +2,6 @@ from __future__ import annotations -import dataclasses -import itertools import os import shutil import subprocess @@ -15,7 +13,7 @@ import pytest from sphinx.deprecation import RemovedInSphinx90Warning -from sphinx.testing.internal.cache import LegacyModuleCache, ModuleCache +from sphinx.testing.internal.cache import AppInfo, LegacyModuleCache, ModuleCache from sphinx.testing.internal.isolation import Isolation from sphinx.testing.internal.markers import ( AppLegacyParams, @@ -44,6 +42,8 @@ from pathlib import Path from typing import Any, Final, Union + from _pytest.nodes import Node as PytestNode + from sphinx.testing.internal.isolation import IsolationPolicy from sphinx.testing.internal.markers import TestParams @@ -65,6 +65,7 @@ 'sphinx_no_default_xdist(): disable the default xdist-group on tests', ] + ############################################################################### # pytest hooks # @@ -131,10 +132,10 @@ def pytest_runtest_teardown(item: pytest.Item) -> Generator[None, None, None]: # not print-friendly, we must use the report sections if _APP_INFO_KEY in item.stash: - info: _AppInfo = item.stash[_APP_INFO_KEY] + info = item.stash[_APP_INFO_KEY] del item.stash[_APP_INFO_KEY] - text = info.render() + text = info.render(nodeid=item.nodeid) if ( # do not duplicate the report info when using -rA @@ -151,7 +152,7 @@ def pytest_runtest_teardown(item: pytest.Item) -> Generator[None, None, None]: # replace un-encodable characters (don't know why pytest does not like that # although it was fine when just using print outside of the report section) text = text.encode('ascii', errors='backslashreplace').decode('ascii') - print('\n\n', f'[{item.nodeid}]', '\n', text, sep='', end='') # NoQA: T201 + print('\n\n', text, sep='', end='') # NoQA: T201 item.add_report_section(f'teardown [{item.nodeid}]', 'fixture %r' % 'app', text) @@ -335,72 +336,20 @@ def test_params(request: pytest.FixtureRequest) -> TestParams: ############################################################################### -@dataclasses.dataclass -class _AppInfo: - """Report to render at the end of a test using the :func:`app` fixture.""" - - builder: str - """The builder name.""" - - testroot_path: str | None - """The absolute path to the sources directory (if any).""" - shared_result: str | None - """The user-defined shared result (if any).""" - - srcdir: str - """The absolute path to the application's sources directory.""" - outdir: str - """The absolute path to the application's output directory.""" - - # fields below are updated when tearing down :func:`app` - # or requesting :func:`app_test_info` (only *extras* is - # publicly exposed by the latter) - - messages: str = dataclasses.field(default='', init=False) - """The application's status messages.""" - warnings: str = dataclasses.field(default='', init=False) - """The application's warnings messages.""" - extras: dict[str, Any] = dataclasses.field(default_factory=dict, init=False) - """Attributes added by :func:`sphinx.testing.plugin.app_test_info`.""" - - def render(self) -> str: - """Format the report as a string to print or render.""" - config = [('builder', self.builder)] - if self.testroot_path: - config.append(('testroot path', self.testroot_path)) - config.extend([('srcdir', self.srcdir), ('outdir', self.outdir)]) - config.extend((name, repr(value)) for name, value in self.extras.items()) - - tw, _ = shutil.get_terminal_size() - kw = 8 + max(len(name) for name, _ in config) - - lines = itertools.chain( - [f'{" configuration ":-^{tw}}'], - (f'{name:{kw}s} {strvalue}' for name, strvalue in config), - [f'{" messages ":-^{tw}}', text] if (text := self.messages) else (), - [f'{" warnings ":-^{tw}}', text] if (text := self.warnings) else (), - ['=' * tw], - ) - return '\n'.join(lines) - - -_APP_INFO_KEY: pytest.StashKey[_AppInfo] = pytest.StashKey() +_APP_INFO_KEY: pytest.StashKey[AppInfo] = pytest.StashKey() -def _get_app_info( - request: pytest.FixtureRequest, app: SphinxTestApp, app_params: AppParams, -) -> _AppInfo: - # request.node.stash is not typed correctly in pytest - stash: pytest.Stash = request.node.stash - if _APP_INFO_KEY not in stash: - stash[_APP_INFO_KEY] = _AppInfo( +def _get_app_info(node: PytestNode, app: SphinxTestApp, app_params: AppParams) -> AppInfo: + """Create or get the current :class:`_AppInfo` object of the node.""" + if _APP_INFO_KEY not in node.stash: + node.stash[_APP_INFO_KEY] = AppInfo( builder=app.builder.name, testroot_path=app_params.kwargs['testroot_path'], shared_result=app_params.kwargs['shared_result'], srcdir=os.fsdecode(app.srcdir), outdir=os.fsdecode(app.outdir), ) - return stash[_APP_INFO_KEY] + return node.stash[_APP_INFO_KEY] @pytest.fixture() @@ -420,6 +369,9 @@ def app_info_extras( def _add_app_info_extras(app, app_info_extras): app_info_extras.update(my_extra=1234) app_info_extras.update(app_extras=app.extras) + + Note that this fixture is only available if sphinx_use_legacy_plugin() + is configured to return False (i.e., if the legacy plugin is disabled). """ # xref RemovedInSphinx90Warning: remove the assert assert not sphinx_use_legacy_plugin, 'legacy plugin does not support this fixture' @@ -427,7 +379,7 @@ def _add_app_info_extras(app, app_info_extras): app = cast(SphinxTestApp, app) # xref RemovedInSphinx90Warning: remove the cast app_params = cast(AppParams, app_params) - app_info = _get_app_info(request, app, app_params) + app_info = _get_app_info(request.node, app, app_params) return app_info.extras @@ -438,12 +390,10 @@ def __app_fixture( module_cache: ModuleCache, ) -> Generator[SphinxTestApp, None, None]: shared_result = app_params.kwargs['shared_result'] + app = make_app(*app_params.args, **app_params.kwargs) yield app - info = _get_app_info(request, app, app_params) - # update the messages accordingly - info.messages = app.status.getvalue() - info.warnings = app.warning.getvalue() + _get_app_info(request.node, app, app_params).update(app) if shared_result is not None: module_cache.store(shared_result, app) @@ -460,38 +410,32 @@ def app( sphinx_use_legacy_plugin: bool, # xref RemovedInSphinx90Warning ) -> Generator[AnySphinxTestApp, None, None]: # xref RemovedInSphinx90Warning: update type """A :class:`sphinx.application.Sphinx` object suitable for testing.""" - # the 'app_params' fixture already depends on the 'test_result' fixture if sphinx_use_legacy_plugin: # xref RemovedInSphinx90Warning + # a warning will be emitted by the app_params fixture app_params = cast(AppLegacyParams, app_params) - gen = __app_fixture_legacy(request, app_params, test_params, make_app, shared_result) + fixt = __app_fixture_legacy(request, app_params, test_params, make_app, shared_result) else: # xref RemovedInSphinx90Warning: remove the cast app_params = cast(AppParams, app_params) make_app = cast(Callable[..., SphinxTestApp], make_app) - gen = __app_fixture(request, app_params, make_app, module_cache) + fixt = __app_fixture(request, app_params, make_app, module_cache) - yield from gen + yield from fixt return - ############################################################################### # other fixtures ############################################################################### + @pytest.fixture() -def status( - # xref RemovedInSphinx90Warning: narrow type - app: SphinxTestApp | SphinxTestAppWrapperForSkipBuilding, -) -> StringIO: +def status(app: AnySphinxTestApp) -> StringIO: # xref RemovedInSphinx90Warning: narrow type """Fixture for the :func:`~sphinx.testing.plugin.app` status stream.""" return app.status @pytest.fixture() -def warning( - # xref RemovedInSphinx90Warning: narrow type - app: SphinxTestApp | SphinxTestAppWrapperForSkipBuilding, -) -> StringIO: +def warning(app: AnySphinxTestApp) -> StringIO: # xref RemovedInSphinx90Warning: narrow type """Fixture for the :func:`~sphinx.testing.plugin.app` warning stream.""" return app.warning @@ -699,7 +643,7 @@ def __app_fixture_legacy( # xref RemovedInSphinx90Warning @pytest.fixture() -def shared_result( +def shared_result( # xref RemovedInSphinx90Warning request: pytest.FixtureRequest, sphinx_use_legacy_plugin: bool, ) -> LegacyModuleCache: @@ -713,8 +657,8 @@ def shared_result( @pytest.fixture(scope='module', autouse=True) -def _shared_result_cache() -> None: - LegacyModuleCache.cache.clear() # xref RemovedInSphinx90Warning +def _shared_result_cache() -> None: # xref RemovedInSphinx90Warning + LegacyModuleCache.cache.clear() SharedResult = LegacyModuleCache # xref RemovedInSphinx90Warning diff --git a/sphinx/testing/internal/cache.py b/sphinx/testing/internal/cache.py index 2a444770ffd..0913285d7e8 100644 --- a/sphinx/testing/internal/cache.py +++ b/sphinx/testing/internal/cache.py @@ -2,10 +2,15 @@ __all__ = () +import dataclasses +import itertools +import shutil from io import StringIO from typing import TYPE_CHECKING, TypedDict if TYPE_CHECKING: + from typing import Any + from sphinx.testing.util import SphinxTestApp, SphinxTestAppWrapperForSkipBuilding @@ -61,6 +66,69 @@ def restore(self, key: str) -> _CacheFrame | None: return {'status': StringIO(data['status']), 'warning': StringIO(data['warning'])} +@dataclasses.dataclass +class AppInfo: + """Information to report during the teardown phase of the ``app()`` fixture. + + The information is either rendered as a report section (for ``xdist`` + integration) or directly printed using a ``print`` statement. + """ + + builder: str + """The builder name.""" + + testroot_path: str | None + """The absolute path to the sources directory (if any).""" + shared_result: str | None + """The user-defined shared result (if any).""" + + srcdir: str + """The absolute path to the application's sources directory.""" + outdir: str + """The absolute path to the application's output directory.""" + + extras: dict[str, Any] = dataclasses.field(default_factory=dict, init=False) + """Attributes added by :func:`sphinx.testing.fixtures.app_test_info`.""" + + # fields below are updated when tearing down :func:`sphinx.testing.fixtures.app` + _messages: str = dataclasses.field(default='', init=False) + """The application's status messages (updated by the fixture).""" + _warnings: str = dataclasses.field(default='', init=False) + """The application's warnings messages (updated by the fixture).""" + + def update(self, app: SphinxTestApp | SphinxTestAppWrapperForSkipBuilding) -> None: + """Update the application's status and warning messages.""" + self._messages = app.status.getvalue() + self._warnings = app.warning.getvalue() + + def render(self, nodeid: str | None = None) -> str: + """Format the report as a string to print or render. + + :param nodeid: Optional node id to include in the report. + :return: The formatted information. + """ + config = [('builder', self.builder)] + if nodeid: + config.insert(0, ('test case', nodeid)) + + if self.testroot_path: + config.append(('testroot path', self.testroot_path)) + config.extend([('srcdir', self.srcdir), ('outdir', self.outdir)]) + config.extend((name, repr(value)) for name, value in self.extras.items()) + + tw, _ = shutil.get_terminal_size() + kw = 8 + max(len(name) for name, _ in config) + + lines = itertools.chain( + [f'{" environment ":-^{tw}}'], + (f'{name:{kw}s} {strvalue}' for name, strvalue in config), + [f'{" messages ":-^{tw}}', text] if (text := self._messages) else (), + [f'{" warnings ":-^{tw}}', text] if (text := self._warnings) else (), + ['=' * tw], + ) + return '\n'.join(lines) + + # XXX: RemovedInSphinx90Warning class LegacyModuleCache: # kept for legacy purposes cache: dict[str, dict[str, str]] = {} diff --git a/sphinx/testing/internal/markers.py b/sphinx/testing/internal/markers.py index af6d0b231e3..bb863622a03 100644 --- a/sphinx/testing/internal/markers.py +++ b/sphinx/testing/internal/markers.py @@ -156,8 +156,7 @@ def _get_sphinx_environ(node: PytestNode, default_builder: str) -> SphinxMarkEnv buildername = env.setdefault('buildername', default_builder) if not buildername: - err = 'missing builder name, got: %r' % buildername - pytest.fail(format_mark_failure('sphinx', err)) + pytest.fail(format_mark_failure('sphinx', 'missing builder name')) check_mark_keywords('sphinx', SphinxMarkEnviron.__annotations__, env, node=node) return env @@ -302,10 +301,8 @@ def process_test_params(node: PytestNode) -> TestParams: if m.args: pytest.fail(format_mark_failure('test_params', 'unexpected positional argument')) - check_mark_keywords( - 'test_params', TestParams.__annotations__, - kwargs := m.kwargs, node=node, strict=True, - ) + check_mark_keywords('test_params', TestParams.__annotations__, + kwargs := m.kwargs, node=node, strict=True) if (shared_result_id := kwargs.get('shared_result', None)) is None: # generate a random shared_result for @pytest.mark.test_params() diff --git a/sphinx/testing/internal/pytest_util.py b/sphinx/testing/internal/pytest_util.py index 497c0a08b4e..66b37f6ca21 100644 --- a/sphinx/testing/internal/pytest_util.py +++ b/sphinx/testing/internal/pytest_util.py @@ -1,5 +1,4 @@ -"""Internal utility functions for interacting with pytest. -""" +"""Internal utility functions for interacting with pytest.""" from __future__ import annotations diff --git a/tests/test_testing/_const.py b/tests/test_testing/_const.py index 6096db45818..f209b8bbdd5 100644 --- a/tests/test_testing/_const.py +++ b/tests/test_testing/_const.py @@ -14,11 +14,3 @@ """Directory containing the current (local) sphinx's implementation.""" SPHINX_PLUGIN_NAME: Final[str] = 'sphinx.testing.fixtures' -MAGICO_PLUGIN_NAME: Final[str] = 'tests.test_testing.magico' -CORE_PLUGINS: Final[tuple[str, ...]] = (SPHINX_PLUGIN_NAME, MAGICO_PLUGIN_NAME) - -MAGICO: Final[str] = 'sphinx_magico' -"""Magical fixture name to use for writing a "debug" test message. - -See :mod:`test_magico` for usage. -""" diff --git a/tests/test_testing/_util.py b/tests/test_testing/_util.py deleted file mode 100644 index 3c7812e2c53..00000000000 --- a/tests/test_testing/_util.py +++ /dev/null @@ -1,486 +0,0 @@ -from __future__ import annotations - -import contextlib -import fnmatch -import os -import re -import string -from functools import lru_cache -from io import StringIO -from itertools import chain -from pathlib import Path -from threading import RLock -from typing import TYPE_CHECKING, TypedDict, TypeVar, final, overload - -import pytest - -from sphinx.testing.internal.util import UID_HEXLEN - -from tests.test_testing._const import MAGICO_PLUGIN_NAME, SPHINX_PLUGIN_NAME - -if TYPE_CHECKING: - from collections.abc import Callable, Iterator, Mapping, Sequence - from typing import Any, Final - - from _pytest.pytester import Pytester, RunResult - from typing_extensions import Unpack - - -def _parse_path(path: str) -> tuple[str, str, int, str]: - fspath = Path(path) - checksum = fspath.parent.stem # can be '0' or a 32-bit numeric string - if not checksum or not checksum.isnumeric(): - pytest.fail(f'cannot extract configuration checksum from: {path!r}') - - contnode = fspath.parent.parent.stem # can be '-' or a hex string - if contnode != '-': - if not set(contnode).issubset(string.hexdigits): - pytest.fail(f'cannot extract container node ID from: {path!r} ' - 'expecting %r or a hexadecimal string, got %r' % ('-', contnode)) - if len(contnode) != UID_HEXLEN: - pytest.fail(f'cannot extract container node ID from: {path!r} ' - f'({contnode!r} must be of length {UID_HEXLEN}, got {len(contnode)})') - - return str(fspath), contnode, int(checksum), fspath.stem - - -@final -class SourceInfo(tuple[str, str, int, str]): - """View on the sources directory path's components.""" - - # We do not use a NamedTuple nor a dataclass since we we want an immutable - # class in which its constructor checks the format of its unique argument. - __slots__ = () - - def __new__(cls, path: str) -> SourceInfo: - return tuple.__new__(cls, _parse_path(path)) - - @property - def realpath(self) -> str: - """The absolute path to the sources directory.""" - return self[0] - - @property - def contnode(self) -> str: - """The node container's identifier.""" - return self[1] - - @property - def checksum(self) -> int: - """The Sphinx configuration checksum.""" - return self[2] - - @property - def filename(self) -> str: - """The sources directory name.""" - return self[3] - - -@final -class Outcome(TypedDict, total=False): - passed: int - skipped: int - failed: int - errors: int - xpassed: int - xfailed: int - warnings: int - deselected: int - - -def _assert_outcomes(actual: Mapping[str, int], expect: Outcome) -> None: - for status in ('passed', 'xpassed'): - # for successful tests, we do not care if the count is not given - obtained = actual.get(status, 0) - expected = expect.get(status, obtained) - assert obtained == expected, (status, actual, expect) - - for status in ('skipped', 'failed', 'errors', 'xfailed', 'warnings', 'deselected'): - obtained = actual.get(status, 0) - expected = expect.get(status, 0) - assert obtained == expected, (status, actual, expect) - - -def _make_testable_name(name: str) -> str: - return name if name.startswith('test_') else f'test_{name}' - - -def _make_testable_path(path: str | os.PathLike[str]) -> str: - return os.path.join(*map(_make_testable_name, Path(path).parts)) - - -@final -class E2E: - """End-to-end integration test interface.""" - - def __init__(self, pytester: Pytester) -> None: - self.__pytester = pytester - - def makepyfile(self, *args: Any, **kwargs: Any) -> Path: - """Delegate to :meth:`_pytest.pytester.Pytester.makepyfile`.""" - return self.__pytester.makepyfile(*args, **kwargs) - - def makepytest(self, *args: Any, **kwargs: Any) -> Path: - """Same as :meth:`makepyfile` but add ``test_`` prefixes to files if needed.""" - kwargs = {_make_testable_path(dest): source for dest, source in kwargs.items()} - return self.makepyfile(*args, **kwargs) - - def runpytest(self, *args: str, plugins: Sequence[str] = (), silent: bool = True) -> RunResult: - """Run the pytester in the same process. - - When *silent* is true, the pytester internal output is suprressed. - """ - # runpytest() does not accept 'plugins' if the method is 'subprocess' - plugins = (SPHINX_PLUGIN_NAME, MAGICO_PLUGIN_NAME, *plugins) - if silent: - with open(os.devnull, 'w', encoding='utf-8') as NUL, contextlib.redirect_stdout(NUL): - return self.__pytester.runpytest_inprocess(*args, plugins=plugins) - else: - return self.__pytester.runpytest_inprocess(*args, plugins=plugins) - - # fmt: off - @overload - def write(self, main_case: str | Sequence[str], /) -> Path: ... # NoQA: E704 - @overload - def write(self, dest: str, /, *cases: str | Sequence[str]) -> Path: ... # NoQA: E704 - # fmt: on - def write(self, dest: Sequence[str], /, *cases: str | Sequence[str]) -> Path: # NoQA: E301 - """Write a Python test file. - - When *dest* is specified, it should indicate where the test file is to - be written, possibly omitting ``test_`` prefixes, e.g.:: - - e2e.write('pkg/foo', '...') # writes to 'test_pkg/test_foo.py' - - When *dest* is not specified, its default value is 'main'. - - :param dest: The destination identifier. - :param cases: The content parts to write. - :return: The path where the cases where written to. - """ - if not cases: - dest, cases = 'main', (dest,) - - assert isinstance(dest, str) - path = _make_testable_path(dest) - - sources = [[case] if isinstance(case, str) else case for case in cases] - lines = (self._getpysource(path), *chain.from_iterable(sources)) - suite = '\n'.join(filter(None, lines)).strip() - return self.makepyfile(**{path: suite}) - - def run(self, /, *, silent: bool = True, **outcomes: Unpack[Outcome]) -> MagicOutput: - """Run the internal pytester object without ``xdist``.""" - res = self.runpytest('-rA', plugins=['no:xdist'], silent=silent) - _assert_outcomes(res.parseoutcomes(), outcomes) - return MagicOutput(res) - - def xdist_run( - self, /, *, jobs: int = 2, silent: bool = True, **outcomes: Unpack[Outcome], - ) -> MagicOutput: - """Run the internal pytester object with ``xdist``.""" - # The :option:`!-r` pytest option is set to ``A`` since we need - # to intercept the report sections and the distribution policy - # is ``loadgroup`` to ensure that ``xdist_group`` is supported. - args = ('-rA', '--numprocesses', str(jobs), '--dist', 'loadgroup') - res = self.runpytest(*args, plugins=['xdist'], silent=silent) - _assert_outcomes(res.parseoutcomes(), outcomes) - return MagicOutput(res) - - def _getpysource(self, path: str) -> str: - curr = self.__pytester.path.joinpath(path).with_suffix('.py') - if curr.exists(): - return curr.read_text(encoding='utf-8').strip() - return '' - - -def e2e_run(t: Pytester, /, **outcomes: Unpack[Outcome]) -> MagicOutput: - """Shorthand for ``E2E(t).run(**outcomes)``.""" - return E2E(t).run(**outcomes) - - -def e2e_xdist_run(t: Pytester, /, *, jobs: int = 2, **outcomes: Unpack[Outcome]) -> MagicOutput: - """Shorthand for ``E2E(t).xdist_run(jobs=jobs, **outcomes)``.""" - return E2E(t).xdist_run(jobs=jobs, **outcomes) - - -############################################################################### -# magic I/O for xdist support -############################################################################### - -_CHANNEL_FOR_VALUE: Final[str] = '' -_CHANNEL_FOR_PRINT: Final[str] = '' - -_TXT_SECTION: Final[str] = 'txt' -_END_SECTION: Final[str] = 'end' -_END_CONTENT: Final[str] = '@EOM' - -_CAPTURE_STATE: Final[str] = 'teardown' - - -def _format_message(prefix: str, *args: Any, sep: str, end: str) -> str: - return f'{prefix} {sep.join(map(str, args))}{end}' - - -def _format_message_for_value_channel(varname: str, value: Any) -> str: - return _format_message(_CHANNEL_FOR_VALUE, varname, value, sep='=', end='\n') - - -def _format_message_for_print_channel(*args: Any, sep: str, end: str) -> str: - return _format_message(_CHANNEL_FOR_PRINT, *args, sep=sep, end=end) - - -@lru_cache(maxsize=128) -def _compile_pattern_for_value_channel(varname: str, pattern: str) -> re.Pattern[str]: - channel, varname = re.escape(_CHANNEL_FOR_VALUE), re.escape(varname) - return re.compile(rf'^{channel} {varname}=({pattern})$') - - -@lru_cache(maxsize=128) -def _compile_pattern_for_print_channel(pattern: str) -> re.Pattern[str]: - channel = re.escape(_CHANNEL_FOR_PRINT) - return re.compile(rf'^{channel} ({pattern})$') - - -def _magic_section(nodeid: str, channel: str, marker: str) -> str: - return f'{channel}@{marker} -- {nodeid}' - - -@lru_cache(maxsize=256) -def _compile_nodeid_pattern(nodeid: str) -> str: - return fnmatch.translate(nodeid).rstrip(r'\Z') # remove the \Z marker - - -@lru_cache(maxsize=256) -def _get_magic_patterns(nodeid: str, channel: str) -> tuple[re.Pattern[str], re.Pattern[str]]: - channel = re.escape(channel) - - def get_pattern(section_type: str) -> re.Pattern[str]: - title = _magic_section(nodeid, channel, re.escape(section_type)) - return re.compile(f'{title} {_CAPTURE_STATE}') - - return get_pattern(_TXT_SECTION), get_pattern(_END_SECTION) - - -def _create_magic_teardownsection(item: pytest.Item, channel: str, content: str) -> None: - if content: - txt_section = _magic_section(item.nodeid, channel, _TXT_SECTION) - item.add_report_section(_CAPTURE_STATE, txt_section, content) - # a fake section is added in order to know where to stop - end_section = _magic_section(item.nodeid, channel, _END_SECTION) - item.add_report_section(_CAPTURE_STATE, end_section, _END_CONTENT) - - -@final -class MagicWriter: - """I/O stream responsible for messages to include in a report section.""" - - _lock = RLock() - - def __init__(self) -> None: - self._vals = StringIO() - self._info = StringIO() - - def __call__(self, varname: str, value: Any, /) -> None: - """Store the value of a variable at the call site. - - .. seealso:: - - :meth:`MagicOutput.find` - :meth:`MagicOutput.findall` - """ - payload = _format_message_for_value_channel(varname, value) - self._write(self._vals, payload) - - def info(self, *args: Any, sep: str = ' ', end: str = '\n') -> None: - """Emulate a ``print()`` in a pytester test. - - .. seealso:: - - :meth:`MagicOutput.message` - :meth:`MagicOutput.messages` - """ - payload = _format_message_for_print_channel(*args, sep=sep, end=end) - self._write(self._info, payload) - - @classmethod - def _write(cls, dest: StringIO, line: str) -> None: - with cls._lock: - dest.write(line) - - def pytest_runtest_teardown(self, item: pytest.Item) -> None: - """Called when tearing down a pytest item. - - This is *not* registered as a pytest but the implementation is kept - here since :class:`MagicOutput` intimely depends on this class. - """ - _create_magic_teardownsection(item, _CHANNEL_FOR_VALUE, self._vals.getvalue()) - _create_magic_teardownsection(item, _CHANNEL_FOR_PRINT, self._info.getvalue()) - - -_T = TypeVar('_T') - - -class MagicOutput: - """The output of a :class:`_pytest.pytster.Pytester` execution.""" - - def __init__(self, res: RunResult) -> None: - self.res = res - self.lines = tuple(res.outlines) - - # fmt: off - @overload - def find(self, name: str, expr: str = ..., *, nodeid: str | None = ..., t: None = ...) -> str: ... # NoQA: E704 - @overload - def find(self, name: str, expr: str = ..., *, nodeid: str | None = ..., t: Callable[[str], _T]) -> _T: ... # NoQA: E704 - # fmt: on - def find(self, name: str, expr: str = r'.*', *, nodeid: str | None = None, t: Callable[[str], Any] | None = None) -> Any: # NoQA: E301 - """Find the first occurrence of a variable value. - - :param name: A variable name. - :param expr: A variable value pattern. - :param nodeid: Optional node ID to filter messages. - :param t: Optional adapter function. - :return: The variable value (possibly converted via *t*). - """ - values = self._findall(name, expr, nodeid=nodeid) - value = next(values, None) - assert value is not None, (name, expr, nodeid) - return value if t is None else t(value) - - # fmt: off - @overload - def findall(self, name: str, expr: str = ..., *, nodeid: str | None = ..., t: None = ...) -> list[str]: ... # NoQA: E704 - @overload - def findall(self, name: str, expr: str = ..., *, nodeid: str | None = ..., t: Callable[[str], _T]) -> list[_T]: ... # NoQA: E704 - # fmt: on - def findall(self, name: str, expr: str = r'.*', *, nodeid: str | None = None, t: Callable[[str], Any] | None = None) -> list[Any]: # NoQA: E301 - """Find the all occurrences of a variable value. - - :param name: A variable name. - :param expr: A variable value pattern. - :param nodeid: Optional node ID to filter messages. - :param t: Optional adapter function. - :return: The variable values (possibly converted via *t*). - """ - values = self._findall(name, expr, nodeid=nodeid) - return list(values) if t is None else list(map(t, values)) - - def _findall(self, name: str, expr: str, *, nodeid: str | None) -> Iterator[str]: - pattern = _compile_pattern_for_value_channel(name, expr) - yield from self._parselines(pattern, nodeid, _CHANNEL_FOR_VALUE) - - def message(self, expr: str = r'.*', *, nodeid: str | None = None) -> str | None: - """Find the first occurrence of a print-like message. - - Messages for printing variables are not included. - - :param expr: A message pattern. - :param nodeid: Optional node ID to filter messages. - :return: A message or ``None``. - """ - return next(self._messages(expr, nodeid=nodeid), None) - - def messages(self, expr: str = r'.*', *, nodeid: str | None = None) -> list[str]: - """Find all occurrences of print-like messages. - - Messages for printing variables are not included. - - :param expr: A message pattern. - :param nodeid: Optional node ID to filter messages. - :return: A list of messages. - """ - return list(self._messages(expr, nodeid=nodeid)) - - def _messages(self, expr: str, *, nodeid: str | None) -> Iterator[str]: - pattern = _compile_pattern_for_print_channel(expr) - yield from self._parselines(pattern, nodeid, _CHANNEL_FOR_PRINT) - - def _parselines(self, pattern: re.Pattern[str], nodeid: str | None, channel: str) -> Iterator[str]: - assert pattern.groups == 1 - - if nodeid is None: - lines_dict = self._find_magic_teardownsections(channel) - lines: Sequence[str] = list(chain.from_iterable(lines_dict.values())) - else: - lines = self._find_magic_teardownsection(nodeid, channel) - - for match in filter(None, map(pattern.match, lines)): - value = match.group(1) - assert isinstance(value, str), (pattern, nodeid, channel) - yield value - - def _find_magic_teardownsection(self, nodeid: str, channel: str) -> Sequence[str]: - nodeid = _compile_nodeid_pattern(nodeid) - main_pattern, stop_pattern = _get_magic_patterns(nodeid, channel) - - state = 0 - start, stop = None, None # type: (int | None, int | None) - for index, line in enumerate(self.res.outlines): - if state == 0 and main_pattern.search(line): - start = index + 1 # skip the header itself - state = 1 - - elif state == 1 and stop_pattern.search(line): - stop = index - state = 2 - - elif state == 2: - if stop == index - 1 and line == _END_CONTENT: - return self.lines[start:stop] - - state = 0 # try again - start, stop = None, None - - return [] - - def _find_magic_teardownsections(self, channel: str) -> dict[str, Sequence[str]]: - main_pattern, stop_pattern = _get_magic_patterns(r'(?P.+::.+)', channel) - - state, curid = 0, None - positions: dict[str, tuple[int | None, int | None]] = {} - index = 0 - while index < len(self.lines): - line = self.lines[index] - if state == 0 and (m := main_pattern.search(line)) is not None: - assert curid is None - curid = m.group(1) - assert curid is not None - assert curid not in positions - # we ignore the header in the output - positions[curid] = (index + 1, None) - state = 1 - elif state == 1 and (m := stop_pattern.search(line)) is not None: - assert curid is not None - nodeid = m.group(1) - if curid == nodeid: # found a corresponding section - positions[nodeid] = (positions[nodeid][0], index) - state = 2 # check that the content of the end section is correct - else: - # something went wrong :( - prev_top_index, _ = positions.pop(curid) - # reset the state and the ID we were looking for - state, curid = 0, None - # next loop iteration will retry the whole block - assert prev_top_index is not None - index = prev_top_index - elif state == 2: - assert curid is not None - assert curid in positions - _, prev_bot_index = positions[curid] - assert prev_bot_index == index - 1 - # check that the previous line was the header - if line != _END_CONTENT: - # we did not have the expected end content (note that - # this implementation does not support having end-markers - # inside another section) - del positions[curid] - # next loop iteration will retry the same line but in state 0 - index = prev_bot_index - - # reset the state and the ID we were looking for - state, curid = 0, None - - index += 1 - - return {n: self.lines[i:j] for n, (i, j) in positions.items() if j is not None} diff --git a/tests/test_testing/conftest.py b/tests/test_testing/conftest.py index f8760756040..62819a55eed 100644 --- a/tests/test_testing/conftest.py +++ b/tests/test_testing/conftest.py @@ -5,15 +5,14 @@ import pytest -from ._const import MAGICO_PLUGIN_NAME, PROJECT_PATH, SPHINX_PLUGIN_NAME -from ._util import E2E +from ._const import PROJECT_PATH, SPHINX_PLUGIN_NAME if TYPE_CHECKING: from _pytest.config import Config from _pytest.pytester import Pytester pytest_plugins = ['pytester'] -collect_ignore = [MAGICO_PLUGIN_NAME] +collect_ignore = [] # change this fixture when the rest of the test suite is changed @@ -22,11 +21,6 @@ def default_testroot(): return 'minimal' -@pytest.fixture() -def e2e(pytester: Pytester) -> E2E: - return E2E(pytester) - - @pytest.fixture(autouse=True) def _pytester_pyprojecttoml(pytester: Pytester) -> None: # TL;DR: this is a patch to force pytester & xdist using the local plugin @@ -60,7 +54,7 @@ def _pytester_conftest(pytestconfig: Config, pytester: Pytester) -> None: pytester.makeconftest(f''' import pytest -pytest_plugins = [{SPHINX_PLUGIN_NAME!r}, {MAGICO_PLUGIN_NAME!r}] +pytest_plugins = [{SPHINX_PLUGIN_NAME!r}] collect_ignore = ['certs', 'roots'] @pytest.fixture() diff --git a/tests/test_testing/magico.py b/tests/test_testing/magico.py deleted file mode 100644 index d19e33451c3..00000000000 --- a/tests/test_testing/magico.py +++ /dev/null @@ -1,78 +0,0 @@ -r"""Interception plugin for checking our plugin. - -Testing plugins is achieved by :class:`_pytest.pytester.Pytester`. However, -when ``xdist`` is active, capturing support is limited and it is not possible -to print messages inside the tests being tested and check them outside, e.g.:: - - import textwrap - - def test_my_plugin(pytester): - pytester.makepyfile(textwrap.dedent(''' - def test_inner_1(): print("YAY") - def test_inner_2(): print("YAY") - '''.strip('\n'))) - - # this should capture the output but xdist does not like it! - res = pytester.runpytest('-s', '-n2', '-p', 'xdist') - res.assert_outcomes(passed=2) - res.stdout.fnmatch_lines_random(["*YAY*"]) # this fails! - -Nevertheless, it is possible to treat the (non-failure) report sections shown -when using ``-rA`` as "standard output" as well and parse their content. To -that end, ``test_inner_*`` should use a special fixture instead of ``print`` -as follows:: - - import textwrap - - from ._const import MAGICO - - def test_my_plugin(e2e): - e2e.makepyfile(textwrap.dedent(f''' - def test_inner_1({MAGICO}): {MAGICO}.info("YAY1") - def test_inner_2({MAGICO}): {MAGICO}.info("YAY2") - '''.strip('\n'))) - - output = e2e.xdist_run(passed=2) - assert output.messages() == ["YAY1", "YAY2"] -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import pytest - -from tests.test_testing._const import MAGICO -from tests.test_testing._util import MagicWriter - -if TYPE_CHECKING: - from collections.abc import Generator - -_MAGICAL_KEY: pytest.StashKey[MagicWriter] = pytest.StashKey() - - -@pytest.hookimpl(wrapper=True) -def pytest_runtest_setup(item: pytest.Item) -> Generator[None, None, None]: - """Initialize the magical buffer fixture for the item.""" - item.stash.setdefault(_MAGICAL_KEY, MagicWriter()) - yield - - -@pytest.hookimpl(wrapper=True) -def pytest_runtest_teardown(item: pytest.Item) -> Generator[None, None, None]: - """Write the magical buffer content as a report section.""" - # teardown of fixtures - yield - # now the fixtures have executed their teardowns - if (magicobject := item.stash.get(_MAGICAL_KEY, None)) is not None: - # must be kept in sync with the output extractor - magicobject.pytest_runtest_teardown(item) - del magicobject # be sure not to hold any reference - del item.stash[_MAGICAL_KEY] - - -@pytest.fixture(autouse=True, name=MAGICO) -def __magico_sphinx(request: pytest.FixtureRequest) -> MagicWriter: # NoQA: PT005 - # request.node.stash is not typed in pytest - stash: pytest.Stash = request.node.stash - return stash.setdefault(_MAGICAL_KEY, MagicWriter()) diff --git a/tests/test_testing/test_magico.py b/tests/test_testing/test_magico.py deleted file mode 100644 index 12c4791aec8..00000000000 --- a/tests/test_testing/test_magico.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import annotations - -import textwrap - -import pytest -from _pytest.outcomes import Failed - -from ._const import MAGICO - - -def test_native_pytest_cannot_intercept(pytester): - pytester.makepyfile(textwrap.dedent(''' - def test_inner_1(): print("YAY") - def test_inner_2(): print("YAY") - '''.strip('\n'))) - - res = pytester.runpytest('-s', '-n2', '-p', 'xdist') - res.assert_outcomes(passed=2) - - with pytest.raises(Failed): - res.stdout.fnmatch_lines_random(["*YAY*"]) - - -@pytest.mark.serial() -def test_magic_buffer_can_intercept_vars(request, e2e): - e2e.makepyfile(textwrap.dedent(f''' - def test_inner_1({MAGICO}): - {MAGICO}("a", 1) - {MAGICO}("b", -1) - {MAGICO}("b", -2) - - def test_inner_2({MAGICO}): - {MAGICO}("a", 2) - {MAGICO}("b", -3) - {MAGICO}("b", -4) - '''.strip('\n'))) - output = e2e.xdist_run(passed=2) - - assert sorted(output.findall('a', t=int)) == [1, 2] - assert sorted(output.findall('b', t=int)) == [-4, -3, -2, -1] - - assert output.find('a', nodeid='*::test_inner_1', t=int) == 1 - assert output.findall('a', nodeid='*::test_inner_1', t=int) == [1] - - assert output.find('b', nodeid='*::test_inner_1', t=int) == -1 - assert output.findall('b', nodeid='*::test_inner_1', t=int) == [-1, -2] - - assert output.find('a', nodeid='*::test_inner_2', t=int) == 2 - assert output.findall('a', nodeid='*::test_inner_2', t=int) == [2] - - assert output.find('b', nodeid='*::test_inner_2', t=int) == -3 - assert output.findall('b', nodeid='*::test_inner_2', t=int) == [-3, -4] - - -@pytest.mark.serial() -def test_magic_buffer_can_intercept_info(e2e): - e2e.makepyfile(textwrap.dedent(f''' - def test_inner_1({MAGICO}): {MAGICO}.info("YAY1") - def test_inner_2({MAGICO}): {MAGICO}.info("YAY2") - '''.strip('\n'))) - output = e2e.xdist_run(passed=2) - - assert sorted(output.messages()) == ['YAY1', 'YAY2'] - assert output.message(nodeid='*::test_inner_1') == 'YAY1' - assert output.message(nodeid='*::test_inner_2') == 'YAY2' - - -@pytest.mark.serial() -def test_magic_buffer_e2e(e2e): - e2e.write('file1', textwrap.dedent(f''' - def test1({MAGICO}): - {MAGICO}("a", 1) - {MAGICO}("b", 2.5) - {MAGICO}("b", 5.8) - '''.strip('\n'))) - - e2e.write('file2', textwrap.dedent(f''' - def test2({MAGICO}): - {MAGICO}.info("result is:", 123) - {MAGICO}.info("another message") - '''.strip('\n'))) - - output = e2e.xdist_run(passed=2) - - assert output.findall('a', t=int) == [1] - assert output.findall('b', t=float) == [2.5, 5.8] - assert output.messages() == ["result is: 123", "another message"] diff --git a/tests/test_testing/test_plugin_isolation.py b/tests/test_testing/test_plugin_isolation.py deleted file mode 100644 index 5e550397b18..00000000000 --- a/tests/test_testing/test_plugin_isolation.py +++ /dev/null @@ -1,121 +0,0 @@ -from __future__ import annotations - -import uuid - -import pytest - -from ._const import MAGICO -from ._util import SourceInfo - - -@pytest.fixture() -def random_uuid() -> str: - return uuid.uuid4().hex - - -def test_grouped_isolation_no_shared_result(e2e): - def gen(testid: str) -> str: - return f''' -@pytest.mark.parametrize('value', [1, 2]) -@pytest.mark.sphinx('dummy', testroot='basic') -@pytest.mark.isolate('grouped') -def test_group_{testid}({MAGICO}, app, value): - {MAGICO}({testid!r}, str(app.srcdir)) -''' - e2e.write(['import pytest', gen('a'), gen('b')]) - - output = e2e.run() - - srcs_a = output.findall('a', t=SourceInfo) - assert len(srcs_a) == 2 # two sub-tests - assert len(set(srcs_a)) == 1 - - srcs_b = output.findall('b', t=SourceInfo) - assert len(srcs_b) == 2 # two sub-tests - assert len(set(srcs_b)) == 1 - - srcinfo_a, srcinfo_b = srcs_a[0], srcs_b[0] - assert srcinfo_a.contnode == srcinfo_b.contnode # same namespace - assert srcinfo_a.checksum == srcinfo_b.checksum # same config - assert srcinfo_a.filename != srcinfo_b.filename # diff shared id - - -def test_shared_result(e2e, random_uuid): - def gen(testid: str) -> str: - return f''' -@pytest.mark.parametrize('value', [1, 2]) -@pytest.mark.sphinx('dummy', testroot='basic') -@pytest.mark.test_params(shared_result={random_uuid!r}) -def test_group_{testid}({MAGICO}, app, value): - {MAGICO}({testid!r}, str(app.srcdir)) -''' - e2e.write('import pytest') - e2e.write(gen('a')) - e2e.write(gen('b')) - output = e2e.run() - - srcs_a = output.findall('a', t=SourceInfo) - assert len(srcs_a) == 2 # two sub-tests - assert len(set(srcs_a)) == 1 - - srcs_b = output.findall('b', t=SourceInfo) - assert len(srcs_b) == 2 # two sub-tests - assert len(set(srcs_b)) == 1 - - assert srcs_a[0] == srcs_b[0] - - -def test_shared_result_different_config(e2e, random_uuid): - def gen(testid: str) -> str: - return f''' -@pytest.mark.parametrize('value', [1, 2]) -@pytest.mark.sphinx('dummy', testroot='basic', confoverrides={{"author": {testid!r}}}) -@pytest.mark.test_params(shared_result={random_uuid!r}) -def test_group_{testid}({MAGICO}, app, value): - {MAGICO}({testid!r}, str(app.srcdir)) -''' - e2e.write('import pytest') - e2e.write(gen('a')) - e2e.write(gen('b')) - output = e2e.run() - - srcs_a = output.findall('a', t=SourceInfo) - assert len(srcs_a) == 2 # two sub-tests - assert len(set(srcs_a)) == 1 - - srcs_b = output.findall('b', t=SourceInfo) - assert len(srcs_b) == 2 # two sub-tests - assert len(set(srcs_b)) == 1 - - srcinfo_a, srcinfo_b = srcs_a[0], srcs_b[0] - assert srcinfo_a.contnode == srcinfo_b.contnode # same namespace - assert srcinfo_a.checksum != srcinfo_b.checksum # diff config - assert srcinfo_a.filename == srcinfo_b.filename # same shared id - - -def test_shared_result_different_module(e2e, random_uuid): - def gen(testid: str) -> str: - return f''' -import pytest - -@pytest.mark.parametrize('value', [1, 2]) -@pytest.mark.sphinx('dummy', testroot='basic') -@pytest.mark.test_params(shared_result={random_uuid!r}) -def test_group_{testid}({MAGICO}, app, value): - {MAGICO}({testid!r}, str(app.srcdir)) -''' - e2e.makepytest(a=gen('a'), b=gen('b')) - output = e2e.run() - - srcs_a = output.findall('a', t=SourceInfo) - assert len(srcs_a) == 2 # two sub-tests - assert srcs_a[0] == srcs_a[1] - - srcs_b = output.findall('b', t=SourceInfo) - assert len(srcs_b) == 2 # two sub-tests - assert len(set(srcs_b)) == 1 - - srcinfo_a, srcinfo_b = srcs_a[0], srcs_b[0] - assert srcinfo_a.contnode != srcinfo_b.contnode # diff namespace - assert srcinfo_a.checksum == srcinfo_b.checksum # same config - assert srcinfo_a.filename == srcinfo_b.filename # same shared id diff --git a/tests/test_testing/test_plugin_xdist.py b/tests/test_testing/test_plugin_xdist.py deleted file mode 100644 index c0edc7f4e54..00000000000 --- a/tests/test_testing/test_plugin_xdist.py +++ /dev/null @@ -1,349 +0,0 @@ -from __future__ import annotations - -import itertools -import string -from typing import TYPE_CHECKING, NamedTuple - -import pytest - -from sphinx.testing.internal.util import UID_HEXLEN - -from ._const import MAGICO, MAGICO_PLUGIN_NAME, SPHINX_PLUGIN_NAME -from ._util import E2E, SourceInfo - -if TYPE_CHECKING: - from collections.abc import Sequence - from typing import Final, Literal - - from ._util import MagicOutput - - GroupPolicy = Literal['native', 'sphinx', 123] - - -@pytest.mark.serial() -def test_framework_no_xdist(pytester): - pytester.makepyfile(f''' -from sphinx.testing.internal.pytest_xdist import get_xdist_policy - -def test_check_setup(pytestconfig): - assert pytestconfig.pluginmanager.has_plugin({SPHINX_PLUGIN_NAME!r}) - assert pytestconfig.pluginmanager.has_plugin({MAGICO_PLUGIN_NAME!r}) - assert not pytestconfig.pluginmanager.has_plugin('xdist') - assert get_xdist_policy(pytestconfig) == 'no' -''') - assert E2E(pytester).run(passed=1) - - -@pytest.mark.serial() -def test_framework_with_xdist(pytester): - pytester.makepyfile(f''' -from sphinx.testing.internal.pytest_xdist import get_xdist_policy - -def test_check_setup(pytestconfig): - assert pytestconfig.pluginmanager.has_plugin({SPHINX_PLUGIN_NAME!r}) - assert pytestconfig.pluginmanager.has_plugin({MAGICO_PLUGIN_NAME!r}) - assert pytestconfig.pluginmanager.has_plugin('xdist') - assert get_xdist_policy(pytestconfig) == 'loadgroup' -''') - assert E2E(pytester).xdist_run(passed=1) - - -FOO: Final[str] = 'foo' -BAR: Final[str] = 'bar' -GROUP_POLICIES: Final[Sequence[GroupPolicy]] = ('native', 'sphinx', 123) - - -def _SRCDIR_VAR(testid): - return f'sid[{testid}]' - - -def _NODEID_VAR(testid): - return f'nid[{testid}]' - - -def _WORKID_VAR(testid): - return f'wid[{testid}]' - -# common header to write once - - -_FILEHEADER = r''' -import pytest - -@pytest.fixture(autouse=True) -def _add_test_id(request, app_info_extras): - app_info_extras.update(test_id=request.node.nodeid) - -@pytest.fixture() -def value(): # fake fixture that is to be replaced by a parametrization - return 0 -''' - - -def _casecontent(testid: str, *, group: GroupPolicy, parametrized: bool) -> str: - if group == 'native': - # do not use the auto strategy - xdist_group_mark = '@pytest.mark.sphinx_no_default_xdist()' - elif group == 'sphinx': - # use the auto-strategy by Sphinx - xdist_group_mark = None - else: - xdist_group_mark = f"@pytest.mark.xdist_group({str(group)!r})" - - if parametrized: - parametrize_mark = "@pytest.mark.parametrize('value', [1, 2])" - else: - parametrize_mark = None - - marks = '\n'.join(filter(None, (xdist_group_mark, parametrize_mark))) - return f''' -{marks} -@pytest.mark.sphinx('dummy') -def test_group_{testid}({MAGICO}, request, app, worker_id, value): - assert request.config.pluginmanager.has_plugin('xdist') - assert hasattr(request.config, 'workerinput') - - {MAGICO}({_SRCDIR_VAR(testid)!r}, str(app.srcdir)) - {MAGICO}({_NODEID_VAR(testid)!r}, request.node.nodeid) - {MAGICO}({_WORKID_VAR(testid)!r}, worker_id) -''' - - -class _ExtractInfo(NamedTuple): - source: SourceInfo - """The sources directory information.""" - - workid: str - """The xdist-worker ID.""" - nodeid: str - """The test node id.""" - - @property - def loader(self) -> str | None: - """The xdist-group (if any).""" - parts = self.nodeid.rsplit('@', maxsplit=1) - assert len(parts) == 2 or parts == [self.nodeid] - return parts[1] if len(parts) == 2 else None - - -def _extract_infos(output: MagicOutput, name: str, *, parametrized: bool) -> list[_ExtractInfo]: - srcs = output.findall(_SRCDIR_VAR(name), t=SourceInfo) - assert len(srcs) > 1 if parametrized else len(srcs) == 1 - assert all(srcs) - - wids = output.findall(_WORKID_VAR(name)) - assert len(wids) == len(srcs) - assert all(wids) - - nids = output.findall(_NODEID_VAR(name)) - assert len(nids) == len(srcs) - assert all(nids) - - return [ - _ExtractInfo(source, workid, nodeid) - for source, workid, nodeid in zip(srcs, wids, nids) - ] - - -def _check_parametrized_test_suite(suite: Sequence[_ExtractInfo]) -> None: - for tx, ty in itertools.combinations(suite, 2): # type: (_ExtractInfo, _ExtractInfo) - # sub-tests have different node IDs - assert tx.nodeid != ty.nodeid - # With xdist enabled, sub-tests are by default dispatched - # arbitrarily and may not have the same real path; however - # their namespace and configuration checksum must match. - assert tx.source.contnode == ty.source.contnode - assert tx.source.checksum == ty.source.checksum - assert tx.source.filename == ty.source.filename - - # the real paths of x and y only differ by their worker id - assert tx.workid in tx.source.realpath - x_to_y = tx.source.realpath.replace(tx.workid, ty.workid, 1) - assert ty.workid in ty.source.realpath - y_to_x = ty.source.realpath.replace(ty.workid, tx.workid, 1) - assert x_to_y == ty.source.realpath - assert y_to_x == tx.source.realpath - - -def _check_xdist_group(group: GroupPolicy, items: Sequence[_ExtractInfo]) -> None: - groups = {item.loader for item in items} - assert len(groups) == 1 - actual_group = groups.pop() - - if group == 'native': - # no group is specified - assert actual_group is None - elif group == 'sphinx': - # sphinx automatically generates a group using the node location - assert isinstance(actual_group, str) - assert set(actual_group).issubset(string.hexdigits) - assert len(actual_group) == UID_HEXLEN - else: - assert isinstance(group, int) - assert actual_group == str(group) - - -def _check_same_policy(group: GroupPolicy, suites: Sequence[Sequence[_ExtractInfo]]) -> None: - suite_loaders = [{item.loader for item in suite} for suite in suites] - assert all(len(loaders) == 1 for loaders in suite_loaders) - groups = [loaders.pop() for loaders in suite_loaders] - - if group == 'native': - for group_name, suite in zip(groups, suites): - assert group_name is None, suite - elif group == 'sphinx': - # the auto-generated groups are different - # because the tests are at different places - assert len(set(groups)) == len(groups) - else: - for group_name, suite in zip(groups, suites): - assert group_name == str(group), suite - - -@pytest.mark.serial() -class TestParallelTestingModule: - @staticmethod - def run(e2e: E2E, *, parametrized: bool, **groups: GroupPolicy) -> MagicOutput: - e2e.write(_FILEHEADER) - for testid, group in groups.items(): - e2e.write(_casecontent(testid, group=group, parametrized=parametrized)) - return e2e.xdist_run(jobs=len(groups)) # ensure to have distinct runners - - @pytest.mark.parametrize('policy', GROUP_POLICIES) - def test_source_for_non_parmetrized_tests(self, e2e: E2E, policy: GroupPolicy) -> None: - output = self.run(e2e, **dict.fromkeys((FOO, BAR), policy), parametrized=False) - foo = _extract_infos(output, FOO, parametrized=False)[0] - bar = _extract_infos(output, BAR, parametrized=False)[0] - - if policy in {'native', 'sphinx'}: - # by default, the worker ID will be different, hence - # the difference of paths - assert foo.source.realpath != bar.source.realpath - else: - # same group *and* same configuration implies (by default) - # the same sources directory (i.e., no side-effect expected) - assert foo.source.realpath == bar.source.realpath - - # same module, so same base node - assert foo.source.contnode == bar.source.contnode - # same configuration for this minimal test - assert foo.source.checksum == bar.source.checksum - # the sources directory name is the same since no isolation is expected - assert foo.source.filename == bar.source.filename - - # the node IDs are distinct - assert foo.nodeid != bar.nodeid - - if policy in {'native', 'sphinx'}: - # the worker IDs are distinct since no xdist group is set - assert foo.workid != bar.workid - # for non-parametrized tests, 'native' and 'sphinx' policies - # are equivalent (i.e., they do not set an xdist group) - assert foo.loader is None - assert bar.loader is None - else: - # the worker IDs are the same since they have the same group - group = str(policy) - assert foo.workid == bar.workid - assert foo.loader == group - assert bar.loader == group - - @pytest.mark.parametrize(('foo_group', 'bar_group'), [ - *zip(GROUP_POLICIES, GROUP_POLICIES), - *itertools.combinations(GROUP_POLICIES, 2), - ]) - def test_source_for_parametrized_tests( - self, e2e: E2E, foo_group: GroupPolicy, bar_group: GroupPolicy, - ) -> None: - output = self.run(e2e, **{FOO: foo_group, BAR: bar_group}, parametrized=True) - foo = _extract_infos(output, FOO, parametrized=True) - bar = _extract_infos(output, BAR, parametrized=True) - - _check_parametrized_test_suite(foo) - _check_parametrized_test_suite(bar) - - tx: _ExtractInfo - ty: _ExtractInfo - - for tx, ty in itertools.combinations((*foo, *bar), 2): - # inter-collectors also have the same source info - # except for the node location (fspath, lineno) - assert tx.source.contnode == ty.source.contnode - assert tx.source.checksum == ty.source.checksum - assert tx.source.filename == ty.source.filename - - _check_xdist_group(foo_group, foo) - _check_xdist_group(bar_group, bar) - - if (group := foo_group) == bar_group: - _check_same_policy(group, [foo, bar]) - - -@pytest.mark.serial() -class TestParallelTestingPackage: - """Same as :class:`TestParallelTestingModule` but with tests in different files.""" - - @staticmethod - def run(e2e: E2E, *, parametrized: bool, **groups: GroupPolicy) -> MagicOutput: - for testid, group in groups.items(): - source = _casecontent(testid, group=group, parametrized=parametrized) - e2e.write(testid, _FILEHEADER, source) - return e2e.xdist_run(jobs=len(groups)) # ensure to have distinct runners - - @pytest.mark.parametrize('policy', GROUP_POLICIES) - def test_source_for_non_parmetrized_tests(self, e2e: E2E, policy: GroupPolicy) -> None: - output = self.run(e2e, **dict.fromkeys((FOO, BAR), policy), parametrized=False) - foo = _extract_infos(output, FOO, parametrized=False)[0] - bar = _extract_infos(output, BAR, parametrized=False)[0] - - # Unlike for the module-scope tests, both the full path - # and the namespace ID are distinct since they are based - # on the module name (which is distinct for each suite since - # they are in different files). - assert foo.source.realpath != bar.source.realpath - assert foo.source.contnode != bar.source.contnode - - # logic blow is the same as for module-scoped tests - assert foo.source.checksum == bar.source.checksum - assert foo.source.filename == bar.source.filename - assert foo.nodeid != bar.nodeid - - if policy in {'native', 'sphinx'}: - assert foo.workid != bar.workid - assert foo.loader is None - assert bar.loader is None - else: - group = str(policy) - assert foo.workid == bar.workid - assert foo.loader == group - assert bar.loader == group - - @pytest.mark.parametrize(('foo_group', 'bar_group'), [ - *zip(GROUP_POLICIES, GROUP_POLICIES), - *itertools.combinations(GROUP_POLICIES, 2), - ]) - def test_source_for_parametrized_tests( - self, e2e: E2E, foo_group: GroupPolicy, bar_group: GroupPolicy, - ) -> None: - output = self.run(e2e, **{FOO: foo_group, BAR: bar_group}, parametrized=True) - foo = _extract_infos(output, FOO, parametrized=True) - bar = _extract_infos(output, BAR, parametrized=True) - - _check_parametrized_test_suite(foo) - _check_parametrized_test_suite(bar) - - tx: _ExtractInfo - ty: _ExtractInfo - for tx, ty in itertools.product(foo, bar): - # the base node is distinct since not in the same module (this - # was already checked previously, but here we check when we mix - # the policies whereas before we checked with identical policies) - assert tx.source.contnode != ty.source.contnode - assert tx.source.checksum == ty.source.checksum - assert tx.source.filename == ty.source.filename - - _check_xdist_group(foo_group, foo) - _check_xdist_group(bar_group, bar) - - if (group := foo_group) == bar_group: - _check_same_policy(group, [foo, bar]) diff --git a/tests/test_testing/test_testroot_finder.py b/tests/test_testing/test_testroot_finder.py index 3fae5ca6c40..235f8a90e2e 100644 --- a/tests/test_testing/test_testroot_finder.py +++ b/tests/test_testing/test_testroot_finder.py @@ -1,5 +1,7 @@ from __future__ import annotations +import contextlib +import os from typing import TYPE_CHECKING, overload import pytest @@ -7,10 +9,7 @@ from sphinx.testing.internal.pytest_util import TestRootFinder -from ._util import e2e_run - if TYPE_CHECKING: - import os from typing import Any, Literal @@ -158,8 +157,9 @@ def test_rootdir_e2e(pytester, scope, value): script1 = e2e_with_fixture_def('rootdir', 'path', value, value, scope) script2 = e2e_with_parametrize('rootdir', 'path', value, value, scope) pytester.makepyfile(test_fixture_def=script1, test_parametrize=script2) - e2e_run(pytester, passed=2) - + with open(os.devnull, 'w', encoding='utf-8') as NUL, contextlib.redirect_stdout(NUL): + res = pytester.runpytest_inprocess('-p no:xdist', plugins=[]) + res.assert_outcomes(passed=2) @pytest.mark.parametrize('scope', Scope) @pytest.mark.parametrize('value', ['my-', '', None]) @@ -168,7 +168,9 @@ def test_testroot_prefix_e2e(pytester, scope, value): script1 = e2e_with_fixture_def('testroot_prefix', 'prefix', value, expect, scope) script2 = e2e_with_parametrize('testroot_prefix', 'prefix', value, expect, scope) pytester.makepyfile(test_fixture_def=script1, test_parametrize=script2) - e2e_run(pytester, passed=2) + with open(os.devnull, 'w', encoding='utf-8') as NUL, contextlib.redirect_stdout(NUL): + res = pytester.runpytest_inprocess('-p no:xdist', plugins=[]) + res.assert_outcomes(passed=2) @pytest.mark.parametrize('scope', Scope) @@ -177,4 +179,6 @@ def test_default_testroot_e2e(pytester, scope, value): script1 = e2e_with_fixture_def('default_testroot', 'default', value, value, scope) script2 = e2e_with_parametrize('default_testroot', 'default', value, value, scope) pytester.makepyfile(test_fixture_def=script1, test_parametrize=script2) - e2e_run(pytester, passed=2) + with open(os.devnull, 'w', encoding='utf-8') as NUL, contextlib.redirect_stdout(NUL): + res = pytester.runpytest_inprocess('-p no:xdist', plugins=[]) + res.assert_outcomes(passed=2) From 33d5772a7c7acffc31d97329bd0cad2fcc5193e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 15 Mar 2024 17:41:57 +0100 Subject: [PATCH 24/47] cleanup and simplify --- tests/test_testing/test_testroot_finder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_testing/test_testroot_finder.py b/tests/test_testing/test_testroot_finder.py index 235f8a90e2e..dec01f27c89 100644 --- a/tests/test_testing/test_testroot_finder.py +++ b/tests/test_testing/test_testroot_finder.py @@ -161,6 +161,7 @@ def test_rootdir_e2e(pytester, scope, value): res = pytester.runpytest_inprocess('-p no:xdist', plugins=[]) res.assert_outcomes(passed=2) + @pytest.mark.parametrize('scope', Scope) @pytest.mark.parametrize('value', ['my-', '', None]) def test_testroot_prefix_e2e(pytester, scope, value): From 5be3c746465fe86851da57394690f692c2c9d578 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 15 Mar 2024 17:50:07 +0100 Subject: [PATCH 25/47] remove xdist for now --- sphinx/testing/fixtures.py | 33 ------ sphinx/testing/internal/pytest_xdist.py | 1 + tests/conftest.py | 130 +----------------------- 3 files changed, 3 insertions(+), 161 deletions(-) diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index ceca4a03616..fb97fd0f529 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -18,7 +18,6 @@ from sphinx.testing.internal.markers import ( AppLegacyParams, AppParams, - get_location_id, process_isolate, process_sphinx, process_test_params, @@ -90,38 +89,6 @@ def pytest_configure(config: pytest.Config) -> None: config.addinivalue_line('markers', marker) -@pytest.hookimpl(tryfirst=True) -def pytest_collection_modifyitems( - session: pytest.Session, - config: pytest.Config, - items: list[pytest.Item], -) -> None: - if not is_pytest_xdist_enabled(config): - return - - # *** IMPORTANT *** - # - # This hook is executed by every xdist worker and the items - # are NOT shared across those workers. In particular, it is - # crucial that the xdist-group that we define later is the - # same across ALL workers. In other words, the group can - # only depend on xdist-agnostic data such as the physical - # location of a test item. - # - # In addition, custom plugins that can change the meaning - # of ``@pytest.mark.parametrize`` might break this plugin, - # so use them carefully! - - for item in items: - if ( - item.get_closest_marker('parametrize') - and item.get_closest_marker('sphinx_no_default_xdist') is None - ): - fspath, lineno, _ = item.location # this is xdist-agnostic - xdist_group = get_location_id((fspath, lineno or -1)) - item.add_marker(pytest.mark.xdist_group(xdist_group), append=True) - - @pytest.hookimpl(hookwrapper=True) def pytest_runtest_teardown(item: pytest.Item) -> Generator[None, None, None]: yield # execute the fixtures teardowns diff --git a/sphinx/testing/internal/pytest_xdist.py b/sphinx/testing/internal/pytest_xdist.py index 5ec5412af7e..ab7c7f800d0 100644 --- a/sphinx/testing/internal/pytest_xdist.py +++ b/sphinx/testing/internal/pytest_xdist.py @@ -34,6 +34,7 @@ def get_xdist_policy(config: pytest.Config) -> Policy: # them as a worker input, we can retrieve it correctly even # if we are not in the controller node if hasattr(config, 'workerinput'): + # this requires our custom xdist hooks return config.workerinput['sphinx_xdist_policy'] return config.option.dist return 'no' diff --git a/tests/conftest.py b/tests/conftest.py index b0ab8ecc04c..65e9a2298cb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,7 @@ from __future__ import annotations -import fnmatch import os -import re import sys -from functools import lru_cache from pathlib import Path from typing import TYPE_CHECKING @@ -14,18 +11,13 @@ import sphinx import sphinx.locale import sphinx.pycode -from sphinx.testing.internal.pytest_util import get_tmp_path_factory, issue_warning -from sphinx.testing.internal.pytest_xdist import is_pytest_xdist_enabled -from sphinx.testing.internal.warnings import FixtureWarning +from sphinx.testing.internal.pytest_util import get_tmp_path_factory from sphinx.testing.util import _clean_up_global_state if TYPE_CHECKING: - from collections.abc import Generator, Sequence + from collections.abc import Generator from _pytest.config import Config - from _pytest.fixtures import FixtureRequest - from _pytest.main import Session - from _pytest.nodes import Item def _init_console(locale_dir=sphinx.locale._LOCALE_DIR, catalog='sphinx'): @@ -55,10 +47,6 @@ def _init_console(locale_dir=sphinx.locale._LOCALE_DIR, catalog='sphinx'): def pytest_configure(config: Config) -> None: - config.addinivalue_line('markers', 'serial(): mark a test as non-xdist friendly') - config.addinivalue_line('markers', 'unload(*pattern): unload matching modules') - config.addinivalue_line('markers', 'unload_modules(*names, raises=False): unload modules') - config.addinivalue_line( 'markers', 'apidoc(*, coderoot="test-root", excludes=[], options=[]): ' @@ -75,66 +63,6 @@ def pytest_report_header(config: Config) -> str: return '\n'.join(f'{key}: {value}' for key, value in headers.items()) -# The test modules in which tests should not be executed in parallel mode, -# unless they are explicitly marked with ``@pytest.mark.parallel()``. -# -# The keys are paths relative to the project directory and values can -# be ``None`` to indicate all tests or a list of (non-parametrized) test -# names, e.g., for a test:: -# -# @pytest.mark.parametrize('value', [1, 2]) -# def test_foo(): ... -# -# the name is ``test_foo`` and not ``test_foo[1]`` or ``test_foo[2]``. -# -# Note that a test class or function should not have '[' in its name. -_SERIAL_TESTS: dict[str, Sequence[str] | None] = { - 'tests/test_builders/test_build_linkcheck.py': None, - 'tests/test_intl/test_intl.py': None, -} - - -@lru_cache(maxsize=512) -def _serial_matching(relfspath: str, pattern: str) -> bool: - return fnmatch.fnmatch(relfspath, pattern) - - -@lru_cache(maxsize=512) -def _findall_main_keys(relfspath: str) -> tuple[str, ...]: - return tuple(key for key in _SERIAL_TESTS if _serial_matching(relfspath, key)) - - -def _test_basename(name: str) -> str: - """Get the test name without the parametrization part from an item name.""" - if name.find('[') < name.find(']'): - # drop the parametrized part - return name[:name.find('[')] - return name - - -@pytest.hookimpl(tryfirst=True) -def pytest_itemcollected(item: Item) -> None: - if item.get_closest_marker('serial'): - return - - # check whether the item should be marked with ``@pytest.mark.serial()`` - relfspath, _, _ = item.location - for key in _findall_main_keys(relfspath): - names = _SERIAL_TESTS[key] - if names is None or _test_basename(item.name) in names: - item.add_marker(pytest.mark.serial()) - - -@pytest.hookimpl(trylast=True) -def pytest_collection_modifyitems(session: Session, config: Config, items: list[Item]) -> None: - if not is_pytest_xdist_enabled(config): - # ignore ``@pytest.mark.serial()`` when ``xdist`` is inactive - return - - # only select items that are marked (manually or automatically) with 'serial' - items[:] = [item for item in items if item.get_closest_marker('serial') is None] - - ############################################################################### # fixtures ############################################################################### @@ -164,57 +92,3 @@ def _cleanup_docutils() -> Generator[None, None, None]: sys.path[:] = saved_path _clean_up_global_state() - - -@pytest.fixture(autouse=True) -def _do_unload(request: FixtureRequest) -> Generator[None, None, None]: - """Explicitly remove modules. - - The modules to remove can be specified as follows:: - - # remove any module matching one the regular expressions - @pytest.mark.unload('foo.*', 'bar.*') - def test(): ... - - # silently remove modules using exact module names - @pytest.mark.unload_modules('pkg.mod') - def test(): ... - - # remove using exact module names and fails if a module was not loaded - @pytest.mark.unload_modules('pkg.mod', raises=True) - def test(): ... - """ - # find the module names patterns - patterns: list[re.Pattern[str]] = [] - for marker in request.node.iter_markers('unload'): - patterns.extend(map(re.compile, marker.args)) - - # find the exact module names and the flag indicating whether - # to abort the test if unloading them is not possible - silent_targets: set[str] = set() - expect_targets: set[str] = set() - for marker in request.node.iter_markers('unload_modules'): - if marker.kwargs.get('raises', False): - silent_targets.update(marker.args) - else: - expect_targets.update(marker.args) - - yield # run the test - - # nothing to do - if not silent_targets and not expect_targets and not patterns: - return - - for modname in expect_targets - sys.modules.keys(): - warning = FixtureWarning(f'module was not loaded: {modname!r}', '_unload') - issue_warning(request, warning) - - # teardown by removing from the imported modules the requested modules - silent_targets.update(frozenset(sys.modules) & expect_targets) - # teardown by removing from the imported modules the matched modules - for modname in frozenset(sys.modules): - if modname in silent_targets: - silent_targets.remove(modname) - del sys.modules[modname] - elif any(p.match(modname) for p in patterns): - del sys.modules[modname] From b4f43eeffb164c5b5eeeea2ef33986bd404ef304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 15 Mar 2024 17:53:34 +0100 Subject: [PATCH 26/47] cleanup --- tests/test_testing/test_testroot_finder.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_testing/test_testroot_finder.py b/tests/test_testing/test_testroot_finder.py index dec01f27c89..26e0ed35563 100644 --- a/tests/test_testing/test_testroot_finder.py +++ b/tests/test_testing/test_testroot_finder.py @@ -158,7 +158,7 @@ def test_rootdir_e2e(pytester, scope, value): script2 = e2e_with_parametrize('rootdir', 'path', value, value, scope) pytester.makepyfile(test_fixture_def=script1, test_parametrize=script2) with open(os.devnull, 'w', encoding='utf-8') as NUL, contextlib.redirect_stdout(NUL): - res = pytester.runpytest_inprocess('-p no:xdist', plugins=[]) + res = pytester.runpytest_inprocess('-p no:xdist') res.assert_outcomes(passed=2) @@ -170,7 +170,7 @@ def test_testroot_prefix_e2e(pytester, scope, value): script2 = e2e_with_parametrize('testroot_prefix', 'prefix', value, expect, scope) pytester.makepyfile(test_fixture_def=script1, test_parametrize=script2) with open(os.devnull, 'w', encoding='utf-8') as NUL, contextlib.redirect_stdout(NUL): - res = pytester.runpytest_inprocess('-p no:xdist', plugins=[]) + res = pytester.runpytest_inprocess('-p no:xdist') res.assert_outcomes(passed=2) @@ -181,5 +181,5 @@ def test_default_testroot_e2e(pytester, scope, value): script2 = e2e_with_parametrize('default_testroot', 'default', value, value, scope) pytester.makepyfile(test_fixture_def=script1, test_parametrize=script2) with open(os.devnull, 'w', encoding='utf-8') as NUL, contextlib.redirect_stdout(NUL): - res = pytester.runpytest_inprocess('-p no:xdist', plugins=[]) + res = pytester.runpytest_inprocess('-p no:xdist') res.assert_outcomes(passed=2) From a93efe4c1cfb144987a0b13a9033afa4b579608e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 15 Mar 2024 18:25:47 +0100 Subject: [PATCH 27/47] revert some changes --- CHANGES.rst | 10 ++-------- sphinx/testing/util.py | 24 +++++++++++------------- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 8ff9bb0a622..5357a24b4ca 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -106,14 +106,8 @@ Bugs fixed Testing ------- -* #11285: :func:`!pytest.mark.sphinx` requires keyword arguments, except for - the builder name which can still be given as the first positional argument. - Patch by Bénédikt Tran. -* #11285: :func:`!pytest.mark.sphinx` accepts *warningiserror*, *keep_going* - and *verbosity* as additional keyword arguments. - Patch by Bénédikt Tran. -* #11285: :class:`!sphinx.testing.util.SphinxTestApp` *srcdir* argument is - now mandatory (previously, this was checked with an assertion). +* #11285: :func:`!pytest.mark.sphinx` and :class:`sphinx.testing.util.SphinxTestApp` + accept *warningiserror*, *keep_going* and *verbosity* as keyword arguments. Patch by Bénédikt Tran. * #11285: :class:`!sphinx.testing.util.SphinxTestApp` *status* and *warning* arguments are checked to be :class:`io.StringIO` objects (the public API diff --git a/sphinx/testing/util.py b/sphinx/testing/util.py index f403757047f..a82eeb4382f 100644 --- a/sphinx/testing/util.py +++ b/sphinx/testing/util.py @@ -98,33 +98,31 @@ def test(): directory, whereas in the latter, the user must provide it themselves. """ - # Allow the builder name to be passed as a keyword argument - # but only make it positional-only for ``pytest.mark.sphinx`` - # so that an exception can be raised if the constructor is - # directly called and multiple values for the builder name - # are given. + # 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, + 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, - freshenv: bool = False, - warningiserror: bool = False, tags: list[str] | None = None, + docutils_conf: str | None = None, # extra constructor argument verbosity: int = 0, parallel: int = 0, + # additional arguments at the end to keep the signature keep_going: bool = False, - # extra constructor arguments - builddir: Path | None = None, - docutils_conf: str | None = None, + 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 From 6c45a419114a72bc2eb7bbc486ae27c543870dcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 15 Mar 2024 18:26:23 +0100 Subject: [PATCH 28/47] remove ref --- CHANGES.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 5357a24b4ca..85cd47881b0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -106,7 +106,7 @@ Bugs fixed Testing ------- -* #11285: :func:`!pytest.mark.sphinx` and :class:`sphinx.testing.util.SphinxTestApp` +* #11285: :func:`!pytest.mark.sphinx` and :class:`!sphinx.testing.util.SphinxTestApp` accept *warningiserror*, *keep_going* and *verbosity* as keyword arguments. Patch by Bénédikt Tran. * #11285: :class:`!sphinx.testing.util.SphinxTestApp` *status* and *warning* From f60e97e191b4a61e290536ddac7d9bd66555ba47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 15 Mar 2024 18:27:44 +0100 Subject: [PATCH 29/47] revert order --- sphinx/testing/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx/testing/util.py b/sphinx/testing/util.py index a82eeb4382f..bf4a1cdac72 100644 --- a/sphinx/testing/util.py +++ b/sphinx/testing/util.py @@ -113,9 +113,9 @@ def __init__( warning: StringIO | None = None, tags: list[str] | None = None, docutils_conf: str | None = None, # extra constructor argument - verbosity: int = 0, 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 From dce556d0ba24b3a3e1be2e273ede938a68cb79e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 16 Mar 2024 11:01:41 +0100 Subject: [PATCH 30/47] fixup --- sphinx/testing/fixtures.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index 389bc87c425..301aa821c6f 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -52,14 +52,14 @@ DEFAULT_ENABLED_MARKERS: Final[list[str]] = [ # The marker signature differs from the constructor signature # since the way it is processed assumes keyword arguments for - # the 'testroot' and 'srcdir'. In addition, 'freshenv' and + # the 'testroot' and 'srcdir'. In addition, 'freshenv' and # 'isolate' are mutually exclusive arguments (and the latter # is recommended over the former). ( 'sphinx(' 'buildername="html", *, ' - 'testroot="root", srcdir=None, ' - 'confoverrides=None, freshenv=None, ' + 'testroot="root", srcdir=None, ' + 'confoverrides=None, freshenv=None, ' 'warningiserror=False, tags=None, verbosity=0, parallel=0, ' 'keep_going=False, builddir=None, docutils_conf=None, ' 'isolate=False' From a880fd03f974c70739e3b70165623fbe82052303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 16 Mar 2024 11:05:31 +0100 Subject: [PATCH 31/47] use private naming --- sphinx/testing/{internal => _internal}/__init__.py | 0 sphinx/testing/{internal => _internal}/cache.py | 0 .../testing/{internal => _internal}/isolation.py | 0 sphinx/testing/{internal => _internal}/markers.py | 10 +++++----- .../testing/{internal => _internal}/pytest_util.py | 2 +- .../{internal => _internal}/pytest_xdist.py | 0 sphinx/testing/{internal => _internal}/util.py | 2 +- sphinx/testing/{internal => _internal}/warnings.py | 0 sphinx/testing/fixtures.py | 14 +++++++------- tests/conftest.py | 2 +- tests/test_testing/test_testroot_finder.py | 2 +- 11 files changed, 16 insertions(+), 16 deletions(-) rename sphinx/testing/{internal => _internal}/__init__.py (100%) rename sphinx/testing/{internal => _internal}/cache.py (100%) rename sphinx/testing/{internal => _internal}/isolation.py (100%) rename sphinx/testing/{internal => _internal}/markers.py (97%) rename sphinx/testing/{internal => _internal}/pytest_util.py (99%) rename sphinx/testing/{internal => _internal}/pytest_xdist.py (100%) rename sphinx/testing/{internal => _internal}/util.py (97%) rename sphinx/testing/{internal => _internal}/warnings.py (100%) diff --git a/sphinx/testing/internal/__init__.py b/sphinx/testing/_internal/__init__.py similarity index 100% rename from sphinx/testing/internal/__init__.py rename to sphinx/testing/_internal/__init__.py diff --git a/sphinx/testing/internal/cache.py b/sphinx/testing/_internal/cache.py similarity index 100% rename from sphinx/testing/internal/cache.py rename to sphinx/testing/_internal/cache.py diff --git a/sphinx/testing/internal/isolation.py b/sphinx/testing/_internal/isolation.py similarity index 100% rename from sphinx/testing/internal/isolation.py rename to sphinx/testing/_internal/isolation.py diff --git a/sphinx/testing/internal/markers.py b/sphinx/testing/_internal/markers.py similarity index 97% rename from sphinx/testing/internal/markers.py rename to sphinx/testing/_internal/markers.py index bb863622a03..6b4c6904da0 100644 --- a/sphinx/testing/internal/markers.py +++ b/sphinx/testing/_internal/markers.py @@ -13,15 +13,15 @@ import pytest -from sphinx.testing.internal.isolation import Isolation, normalize_isolation_policy -from sphinx.testing.internal.pytest_util import ( +from sphinx.testing._internal.isolation import Isolation, normalize_isolation_policy +from sphinx.testing._internal.pytest_util import ( check_mark_keywords, check_mark_str_args, format_mark_failure, get_mark_parameters, get_node_location, ) -from sphinx.testing.internal.util import ( +from sphinx.testing._internal.util import ( get_container_id, get_environ_checksum, get_location_id, @@ -36,8 +36,8 @@ from _pytest.nodes import Node as PytestNode from typing_extensions import Required - from sphinx.testing.internal.isolation import NormalizableIsolation - from sphinx.testing.internal.pytest_util import TestRootFinder + from sphinx.testing._internal.isolation import NormalizableIsolation + from sphinx.testing._internal.pytest_util import TestRootFinder class SphinxMarkEnviron(TypedDict, total=False): diff --git a/sphinx/testing/internal/pytest_util.py b/sphinx/testing/_internal/pytest_util.py similarity index 99% rename from sphinx/testing/internal/pytest_util.py rename to sphinx/testing/_internal/pytest_util.py index 66b37f6ca21..5f3a0e68920 100644 --- a/sphinx/testing/internal/pytest_util.py +++ b/sphinx/testing/_internal/pytest_util.py @@ -13,7 +13,7 @@ from _pytest.nodes import Node as PytestNode from _pytest.nodes import get_fslocation_from_item -from sphinx.testing.internal.warnings import MarkWarning, NodeWarning, SphinxTestingWarning +from sphinx.testing._internal.warnings import MarkWarning, NodeWarning, SphinxTestingWarning if TYPE_CHECKING: from collections.abc import Callable, Collection, Generator, Iterable diff --git a/sphinx/testing/internal/pytest_xdist.py b/sphinx/testing/_internal/pytest_xdist.py similarity index 100% rename from sphinx/testing/internal/pytest_xdist.py rename to sphinx/testing/_internal/pytest_xdist.py diff --git a/sphinx/testing/internal/util.py b/sphinx/testing/_internal/util.py similarity index 97% rename from sphinx/testing/internal/util.py rename to sphinx/testing/_internal/util.py index 3ae5292972a..f519872d5ce 100644 --- a/sphinx/testing/internal/util.py +++ b/sphinx/testing/_internal/util.py @@ -22,7 +22,7 @@ from _pytest.nodes import Node as PytestNode - from sphinx.testing.internal.pytest_util import TestNodeLocation + from sphinx.testing._internal.pytest_util import TestNodeLocation UID_BITLEN: int = 32 r"""The bit-length of unique identifiers generated by this module. diff --git a/sphinx/testing/internal/warnings.py b/sphinx/testing/_internal/warnings.py similarity index 100% rename from sphinx/testing/internal/warnings.py rename to sphinx/testing/_internal/warnings.py diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index 301aa821c6f..4f1b3866010 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -13,21 +13,21 @@ import pytest from sphinx.deprecation import RemovedInSphinx90Warning -from sphinx.testing.internal.cache import AppInfo, LegacyModuleCache, ModuleCache -from sphinx.testing.internal.isolation import Isolation -from sphinx.testing.internal.markers import ( +from sphinx.testing._internal.cache import AppInfo, LegacyModuleCache, ModuleCache +from sphinx.testing._internal.isolation import Isolation +from sphinx.testing._internal.markers import ( AppLegacyParams, AppParams, process_isolate, process_sphinx, process_test_params, ) -from sphinx.testing.internal.pytest_util import ( +from sphinx.testing._internal.pytest_util import ( TestRootFinder, find_context, get_mark_parameters, ) -from sphinx.testing.internal.pytest_xdist import is_pytest_xdist_enabled +from sphinx.testing._internal.pytest_xdist import is_pytest_xdist_enabled from sphinx.testing.util import ( SphinxTestApp, SphinxTestAppLazyBuild, @@ -43,8 +43,8 @@ from _pytest.nodes import Node as PytestNode - from sphinx.testing.internal.isolation import IsolationPolicy - from sphinx.testing.internal.markers import TestParams + from sphinx.testing._internal.isolation import IsolationPolicy + from sphinx.testing._internal.markers import TestParams AnySphinxTestApp = Union[SphinxTestApp, SphinxTestAppWrapperForSkipBuilding] AnyAppParams = Union[AppParams, AppLegacyParams] diff --git a/tests/conftest.py b/tests/conftest.py index 65e9a2298cb..8926e05d004 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ import sphinx import sphinx.locale import sphinx.pycode -from sphinx.testing.internal.pytest_util import get_tmp_path_factory +from sphinx.testing._internal.pytest_util import get_tmp_path_factory from sphinx.testing.util import _clean_up_global_state if TYPE_CHECKING: diff --git a/tests/test_testing/test_testroot_finder.py b/tests/test_testing/test_testroot_finder.py index 26e0ed35563..e999786102e 100644 --- a/tests/test_testing/test_testroot_finder.py +++ b/tests/test_testing/test_testroot_finder.py @@ -7,7 +7,7 @@ import pytest from _pytest.scope import Scope -from sphinx.testing.internal.pytest_util import TestRootFinder +from sphinx.testing._internal.pytest_util import TestRootFinder if TYPE_CHECKING: from typing import Any, Literal From 6b43a6a8ecc1302da348d0f11da917a2bd374be2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 16 Mar 2024 12:53:57 +0100 Subject: [PATCH 32/47] Refine configuration checksum --- sphinx/testing/_internal/markers.py | 50 +++++++++++++++++++++-------- sphinx/testing/_internal/util.py | 19 ++++++++--- 2 files changed, 51 insertions(+), 18 deletions(-) diff --git a/sphinx/testing/_internal/markers.py b/sphinx/testing/_internal/markers.py index 6b4c6904da0..d87b38ad639 100644 --- a/sphinx/testing/_internal/markers.py +++ b/sphinx/testing/_internal/markers.py @@ -23,8 +23,8 @@ ) from sphinx.testing._internal.util import ( get_container_id, - get_environ_checksum, get_location_id, + get_objects_checksum, make_unique_id, ) @@ -195,6 +195,41 @@ def _get_test_srcdir( return testroot +def _get_environ_checksum( + # positional-only to avoid _get_environ_checksum(name, **kwargs) + # raising a "duplicated values for 'buildername'" ValueError + buildername: str, + /, + *, + # The default values must be kept in sync with the constructor + # default values of :class:`sphinx.testing.util.SphinxTestApp` + # + # Note that 'srcdir' and 'builddir' are not used to construct + # the checksum since otherwise the checksum is unique (and we + # only want a uniqueness that only depend on common user-defined + # values). Similarly, 'status' and 'warning' are not used to + # construct the checksum they are stream objects in general. + confoverrides: dict[str, Any] | None = None, + freshenv: bool = False, + warningiserror: bool = False, + tags: list[str] | None = None, + verbosity: int = 0, + parallel: int = 0, + keep_going: bool = False, + # extra constructor argument + docutils_conf: str | None = None, + # ignored keyword arguments when computing the checksum + **_ignored: Any, +) -> int: + return get_objects_checksum( + buildername, confoverrides=confoverrides, freshenv=freshenv, + warningiserror=warningiserror, tags=tags, verbosity=verbosity, + parallel=parallel, keep_going=keep_going, + # extra constructor arguments + docutils_conf=docutils_conf, + ) + + def process_sphinx( node: PytestNode, session_temp_dir: str | os.PathLike[str], @@ -266,18 +301,7 @@ def process_sphinx( # should be isolated accordingly). If there is a bug in the test suite, we # can reduce the number of tests that can have dependencies by adding some # isolation safeguards. - checksum = get_environ_checksum( - env['buildername'], - # The default values must be kept in sync with the constructor - # default values of :class:`sphinx.testing.util.SphinxTestApp`. - env.get('confoverrides', None), - env.get('freshenv', False), - env.get('warningiserror', False), - env.get('tags', None), - env.get('verbosity', 0), - env.get('parallel', 0), - env.get('keep_going', False), - ) + checksum = _get_environ_checksum(env['buildername'], **env) kwargs = cast(SphinxInitKwargs, env) kwargs['srcdir'] = Path(session_temp_dir, namespace, str(checksum), srcdir) diff --git a/sphinx/testing/_internal/util.py b/sphinx/testing/_internal/util.py index f519872d5ce..274287fc1bb 100644 --- a/sphinx/testing/_internal/util.py +++ b/sphinx/testing/_internal/util.py @@ -49,17 +49,26 @@ def make_unique_id(prefix: str | os.PathLike[str] | None = None) -> str: # NoQA return '-'.join((os.fsdecode(prefix), suffix)) if prefix else suffix -def get_environ_checksum(*args: Any) -> int: - """Compute a CRC-32 checksum of *args*.""" +def get_objects_checksum(*args: Any, **kwargs: Any) -> int: + """Compute a CRC-32 checksum of arbitrary objects. + + The order of the positional arguments and keyword arguments matters + when computing the checksum, hence it is recommended to only use + keyword arguments whenever possible. + + If an object cannot be pickled, its representation is based on the value + of its :func:`id`, possibly making the checksum distinct for equivalent + but non-pickable objects. + """ def default_encoder(x: object) -> str: try: return pickle.dumps(x, protocol=pickle.HIGHEST_PROTOCOL).hex() except (NotImplementedError, TypeError, ValueError): - return hex(id(x))[2:] + return format(id(x), 'x') # use the most compact JSON format - env = json.dumps(args, ensure_ascii=True, sort_keys=True, indent=None, - separators=(',', ':'), default=default_encoder) + env = json.dumps([args, kwargs], separators=(',', ':'), + default=default_encoder, sort_keys=True) # avoid using unique_object_id() since we do not really need SHA-1 entropy return binascii.crc32(env.encode('utf-8')) From f7d7b738602d2ba8675880172587a3ff6f7bf78f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 16 Mar 2024 12:54:29 +0100 Subject: [PATCH 33/47] Refine configuration checksum --- sphinx/testing/_internal/markers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sphinx/testing/_internal/markers.py b/sphinx/testing/_internal/markers.py index d87b38ad639..d433be33de2 100644 --- a/sphinx/testing/_internal/markers.py +++ b/sphinx/testing/_internal/markers.py @@ -195,8 +195,8 @@ def _get_test_srcdir( return testroot -def _get_environ_checksum( - # positional-only to avoid _get_environ_checksum(name, **kwargs) +def _get_common_config_checksum( + # positional-only to avoid _get_config_checksum(name, **kwargs) # raising a "duplicated values for 'buildername'" ValueError buildername: str, /, @@ -301,7 +301,7 @@ def process_sphinx( # should be isolated accordingly). If there is a bug in the test suite, we # can reduce the number of tests that can have dependencies by adding some # isolation safeguards. - checksum = _get_environ_checksum(env['buildername'], **env) + checksum = _get_common_config_checksum(env['buildername'], **env) kwargs = cast(SphinxInitKwargs, env) kwargs['srcdir'] = Path(session_temp_dir, namespace, str(checksum), srcdir) From d3128158006edca10098a734dfcfdd7818cb0d2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 16 Mar 2024 15:42:08 +0100 Subject: [PATCH 34/47] fixup --- sphinx/testing/_internal/util.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sphinx/testing/_internal/util.py b/sphinx/testing/_internal/util.py index 274287fc1bb..8e993910a58 100644 --- a/sphinx/testing/_internal/util.py +++ b/sphinx/testing/_internal/util.py @@ -22,7 +22,6 @@ from _pytest.nodes import Node as PytestNode - from sphinx.testing._internal.pytest_util import TestNodeLocation UID_BITLEN: int = 32 r"""The bit-length of unique identifiers generated by this module. @@ -106,10 +105,11 @@ def get_obj_name(subject: PytestNode) -> str | None: return unique_object_id(container) -def get_location_id(location: TestNodeLocation) -> str: +def get_location_id(location: tuple[str, int]) -> str: """Get a unique hexadecimal identifier out of a test location. The ID is based on the physical node location (file and line number). + The line number is a 0-based integer but can be -1 if unknown. """ fspath, lineno = location return unique_object_id(f'{fspath}:L{lineno}') From 4e06556ef881b78084dd037150b71c0fa8df3246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sun, 17 Mar 2024 12:49:52 +0100 Subject: [PATCH 35/47] fixup --- tests/conftest.py | 1 + tests/test_testing/conftest.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 8beda024c10..66ece249c53 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,7 @@ from _pytest.config import Config + def _init_console( locale_dir: str | None = sphinx.locale._LOCALE_DIR, catalog: str = 'sphinx', ) -> tuple[sphinx.locale.NullTranslations, bool]: diff --git a/tests/test_testing/conftest.py b/tests/test_testing/conftest.py index 62819a55eed..4d5d0d827f2 100644 --- a/tests/test_testing/conftest.py +++ b/tests/test_testing/conftest.py @@ -12,7 +12,6 @@ from _pytest.pytester import Pytester pytest_plugins = ['pytester'] -collect_ignore = [] # change this fixture when the rest of the test suite is changed From 0da65df3cae0ebdaba100db3feefba45024f2548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Mon, 18 Mar 2024 11:15:30 +0100 Subject: [PATCH 36/47] update comment --- sphinx/testing/_internal/pytest_util.py | 6 ++--- sphinx/testing/fixtures.py | 29 +++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/sphinx/testing/_internal/pytest_util.py b/sphinx/testing/_internal/pytest_util.py index 5f3a0e68920..b7a12b3262b 100644 --- a/sphinx/testing/_internal/pytest_util.py +++ b/sphinx/testing/_internal/pytest_util.py @@ -31,9 +31,9 @@ class TestRootFinder: finder = TestRootFinder('/foo/bar', 'test-', 'default') - describes a testroot root directory at ``/foo/bar/roots``. The name of the - directories in ``/foo/bar/roots`` consist of a *prefix* and an *ID* (in - this case, the prefix is ``test-`` and the default *ID* is ``default``). + describes a testroot root directory at ``/foo/bar/``. The name of the + directories in ``/foo/bar/`` consist of a *prefix* and an *ID* (in this + case, the prefix is ``test-`` and the default *ID* is ``default``). >>> finder = TestRootFinder('/foo/bar', 'test-', 'default') >>> finder.find() diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index 4f1b3866010..30c9abe3e68 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -278,7 +278,7 @@ def app_params( """ if sphinx_use_legacy_plugin: msg = ('legacy implementation of sphinx.testing.fixtures is ' - 'deprecated; consider redefining sphinx_legacy_plugin() ' + 'deprecated; consider redefining sphinx_use_legacy_plugin() ' 'in conftest.py to return False.') warnings.warn(msg, RemovedInSphinx90Warning, stacklevel=2) return __app_params_fixture_legacy( @@ -300,7 +300,32 @@ def app_params( @pytest.fixture() def test_params(request: pytest.FixtureRequest) -> TestParams: - """Test parameters that are specified by ``pytest.mark.test_params``.""" + """Test parameters that are specified by ``pytest.mark.test_params``. + + This ``pytest.mark.test_params`` marker takes an optional keyword argument, + namely the *shared_result*, which is a string, e.g.:: + + def test_no_shared_result(test_params): + assert test_params['shared_result'] is None + + @pytest.mark.test_params() + def test_with_random_shared_result(test_params): + assert test_params['shared_result'] == 'some-random-string' + + @pytest.mark.test_params(shared_result='foo') + def test_with_explicit_shared_result(test_params): + assert test_params['shared_result'] == 'foo' + + If the *shared_result* is provided, the ``app.status`` and ``app.warning`` + objects will be shared in the test functions, possibly parametrized, that + have the same *shared_result* value. + + .. note:: + + The *srcdir* parameter of the ``@pytest.mark.sphinx()`` marker and + the *shared_result* parameter of the ``@pytest.mark.test_params()`` + marker are mutually exclusive. + """ return process_test_params(request.node) From 31e61b539bd71a562851dec6d6b79dea909557aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 23 Mar 2024 17:30:44 +0100 Subject: [PATCH 37/47] fixup isolation deduction --- sphinx/testing/_internal/markers.py | 26 ++++++++++++++------------ tests/test_builders/test_build_html.py | 3 ++- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/sphinx/testing/_internal/markers.py b/sphinx/testing/_internal/markers.py index d433be33de2..c87c9ec3e4c 100644 --- a/sphinx/testing/_internal/markers.py +++ b/sphinx/testing/_internal/markers.py @@ -248,9 +248,17 @@ def process_sphinx( :param shared_result: An optional shared result ID. :return: The application positional and keyword arguments. """ - # 1. process pytest.mark.sphinx + # process pytest.mark.sphinx env = _get_sphinx_environ(node, default_builder) - # 1.1a. deduce the isolation policy from freshenv if possible + + # deduce the testroot ID + testroot_id = env['testroot'] = env.get('testroot', testroot_finder.default) + # deduce the srcdir name (possibly explicitly given) + srcdir_name = env.get('srcdir', None) + srcdir = _get_test_srcdir(srcdir_name, testroot_id, shared_result) + is_unique_srcdir_id = srcdir_name is not None + + # deduce the isolation policy from freshenv if possible freshenv: bool | None = env.pop('freshenv', None) if freshenv is not None: if 'isolate' in env: @@ -261,18 +269,12 @@ def process_sphinx( else: freshenv = env['freshenv'] = False - # 1.1b. deduce the final isolation policy - isolation = env.setdefault('isolate', default_isolation) + # deduce the final isolation policy + isolation = is_unique_srcdir_id or env.setdefault('isolate', default_isolation) isolation = env['isolate'] = normalize_isolation_policy(isolation) - # 1.2. deduce the testroot ID - testroot_id = env['testroot'] = env.get('testroot', testroot_finder.default) - # 1.3. deduce the srcdir name (possibly explicitly given) - srcdir_name = env.get('srcdir', None) - srcdir = _get_test_srcdir(srcdir_name, testroot_id, shared_result) - # 2. process the srcdir ID according to the isolation policy - is_unique_srcdir_id = srcdir_name is not None - if isolation is Isolation.always: + # process the srcdir ID according to the isolation policy + if isolation is Isolation.always and not is_unique_srcdir_id: # srcdir = XYZ-(RANDOM-UID) srcdir = make_unique_id(srcdir) is_unique_srcdir_id = True diff --git a/tests/test_builders/test_build_html.py b/tests/test_builders/test_build_html.py index 9a3b0c8bf00..0fe2a04768e 100644 --- a/tests/test_builders/test_build_html.py +++ b/tests/test_builders/test_build_html.py @@ -391,7 +391,8 @@ def test_html_signaturereturn_icon(app): assert ('' in content) -@pytest.mark.sphinx('html', testroot='root', srcdir=os.urandom(4).hex()) +@pytest.mark.sphinx('html', testroot='root') +@pytest.mark.isolate() # because we change the sources in-place def test_html_remove_sources_before_write_gh_issue_10786(app, warning): # see: https://github.com/sphinx-doc/sphinx/issues/10786 target = app.srcdir / 'img.png' From 86de82824f1a65807fc23b5561b4290988f1be45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 23 Mar 2024 17:31:01 +0100 Subject: [PATCH 38/47] fixup isolation deduction --- sphinx/testing/_internal/markers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx/testing/_internal/markers.py b/sphinx/testing/_internal/markers.py index c87c9ec3e4c..c167dd30264 100644 --- a/sphinx/testing/_internal/markers.py +++ b/sphinx/testing/_internal/markers.py @@ -274,7 +274,7 @@ def process_sphinx( isolation = env['isolate'] = normalize_isolation_policy(isolation) # process the srcdir ID according to the isolation policy - if isolation is Isolation.always and not is_unique_srcdir_id: + if isolation is Isolation.always and srcdir_name is None: # srcdir = XYZ-(RANDOM-UID) srcdir = make_unique_id(srcdir) is_unique_srcdir_id = True From 329b98bba3229d22d701d8231352729d3ee84063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 23 Mar 2024 19:10:07 +0100 Subject: [PATCH 39/47] cleanup --- sphinx/testing/_xdist_hooks.py | 10 ++++ sphinx/testing/fixtures.py | 104 +++++++++++++++++---------------- 2 files changed, 64 insertions(+), 50 deletions(-) diff --git a/sphinx/testing/_xdist_hooks.py b/sphinx/testing/_xdist_hooks.py index c02fd0caaa7..9be49d8b0b2 100644 --- a/sphinx/testing/_xdist_hooks.py +++ b/sphinx/testing/_xdist_hooks.py @@ -8,13 +8,23 @@ __all__ = () +import shutil from typing import TYPE_CHECKING if TYPE_CHECKING: import pytest + from execnet import XSpec from xdist.workermanage import NodeManager, WorkerController +def pytest_xdist_setupnodes(config: pytest.Config, specs: list[XSpec]) -> None: + """Setup the environment of each worker controller node.""" + columns = shutil.get_terminal_size()[0] + for spec in specs: + # ensure that the controller nodes inherit the same terminal width + spec.env.setdefault('COLUMNS', str(columns)) + + def pytest_configure_node(node: WorkerController) -> None: node_config: pytest.Config = node.config # the node's config is not the same as the controller's config diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index 30c9abe3e68..4a3dd815c77 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import os import shutil import subprocess @@ -32,8 +33,8 @@ SphinxTestApp, SphinxTestAppLazyBuild, SphinxTestAppWrapperForSkipBuilding, - strip_escseq, ) +from sphinx.util.console import _strip_escape_sequences if TYPE_CHECKING: from collections.abc import Generator @@ -95,39 +96,18 @@ def pytest_configure(config: pytest.Config) -> None: config.addinivalue_line('markers', marker) -@pytest.hookimpl(hookwrapper=True) -def pytest_runtest_teardown(item: pytest.Item) -> Generator[None, None, None]: - yield # execute the fixtures teardowns - - # after tearing down the fixtures, we add some report sections - # for later; without ``xdist``, we would have printed whatever - # we wanted during the fixture teardown but since ``xdist`` is - # not print-friendly, we must use the report sections - - if _APP_INFO_KEY in item.stash: - info = item.stash[_APP_INFO_KEY] - del item.stash[_APP_INFO_KEY] - - text = info.render(nodeid=item.nodeid) - - if ( - # do not duplicate the report info when using -rA - 'A' not in item.config.option.reportchars - and (item.config.option.capture == 'no' or item.config.get_verbosity() >= 2) - # see: https://pytest-xdist.readthedocs.io/en/stable/known-limitations.html - and not is_pytest_xdist_enabled(item.config) - ): - # use carriage returns to avoid being printed inside the progression bar - # and additionally show the node ID for visual purposes - if os.name == 'nt': - # replace some weird stuff - text = strip_escseq(text) - # replace un-encodable characters (don't know why pytest does not like that - # although it was fine when just using print outside of the report section) - text = text.encode('ascii', errors='backslashreplace').decode('ascii') - print('\n\n', text, sep='', end='') # NoQA: T201 +@pytest.hookimpl() +def pytest_runtest_makereport( + item: pytest.Item, call: pytest.CallInfo +) -> pytest.TestReport | None: + if call.when != 'teardown' or _APP_INFO_KEY not in item.stash: + return None - item.add_report_section(f'teardown [{item.nodeid}]', 'fixture %r' % 'app', text) + # handle the delayed test report when using xdist + info = item.stash[_APP_INFO_KEY] + text = _cleanup_app_info(info.render(nodeid=item.nodeid)) + item.add_report_section(call.when, 'fixture: %r' % 'app', text) + return pytest.TestReport.from_item_and_call(item, call) ############################################################################### @@ -337,8 +317,20 @@ def test_with_explicit_shared_result(test_params): _APP_INFO_KEY: pytest.StashKey[AppInfo] = pytest.StashKey() -def _get_app_info(node: PytestNode, app: SphinxTestApp, app_params: AppParams) -> AppInfo: - """Create or get the current :class:`_AppInfo` object of the node.""" +def _cleanup_app_info(text: str) -> str: + if os.name == 'nt': + text = _strip_escape_sequences(text) + text = text.encode('ascii', errors='backslashreplace').decode('ascii') + return text + + +@contextlib.contextmanager +def _app_info_context( + node: PytestNode, + app: SphinxTestApp, + app_params: AppParams, +) -> Generator[None, None, None]: + # create or get the current :class:`AppInfo` object of the node if _APP_INFO_KEY not in node.stash: node.stash[_APP_INFO_KEY] = AppInfo( builder=app.builder.name, @@ -347,17 +339,31 @@ def _get_app_info(node: PytestNode, app: SphinxTestApp, app_params: AppParams) - srcdir=os.fsdecode(app.srcdir), outdir=os.fsdecode(app.outdir), ) - return node.stash[_APP_INFO_KEY] + + app_info = node.stash[_APP_INFO_KEY] + yield + app_info.update(app) + + if not is_pytest_xdist_enabled(node.config) and node.config.option.capture == 'no': + # With xdist, we will print at the test the information but only + # if it is being used with '-s', which has no effect when used by + # xdist since the latter does not support capturing. + # + # + # In addition, use CRLF to avoid being printed inside the + # progression bar (note that we need to render it here so + # that the terminal width is correctly determined). + text = app_info.render(nodeid=node.nodeid) + print('\n', _cleanup_app_info(text), sep='', end='') # NoQA: T201 @pytest.fixture() def app_info_extras( request: pytest.FixtureRequest, - # ``app`` is not used but is marked as a dependency + # ``app`` is not used but is marked as a dependency so that + # the AppInfo() object is automatically created for *app* app: AnySphinxTestApp, # xref RemovedInSphinx90Warning: update type - # ``app_params`` is already a dependency of ``app`` - app_params: AnyAppParams, # xref RemovedInSphinx90Warning: update type - sphinx_use_legacy_plugin: bool, + sphinx_use_legacy_plugin: bool, # xref RemovedInSphinx90Warning ) -> dict[str, Any]: """Fixture to update the information to render at the end of a test. @@ -368,17 +374,15 @@ def _add_app_info_extras(app, app_info_extras): app_info_extras.update(my_extra=1234) app_info_extras.update(app_extras=app.extras) - Note that this fixture is only available if sphinx_use_legacy_plugin() - is configured to return False (i.e., if the legacy plugin is disabled). + .. note:: + + This fixture is only available if :func:`sphinx_use_legacy_plugin` is + configured to return ``False`` (i.e., the legacy plugin is disabled). """ # xref RemovedInSphinx90Warning: remove the assert assert not sphinx_use_legacy_plugin, 'legacy plugin does not support this fixture' - # xref RemovedInSphinx90Warning: remove the cast - app = cast(SphinxTestApp, app) - # xref RemovedInSphinx90Warning: remove the cast - app_params = cast(AppParams, app_params) - app_info = _get_app_info(request.node, app, app_params) - return app_info.extras + assert _APP_INFO_KEY in request.node + return request.node.stash[_APP_INFO_KEY].extras def __app_fixture( @@ -390,8 +394,8 @@ def __app_fixture( shared_result = app_params.kwargs['shared_result'] app = make_app(*app_params.args, **app_params.kwargs) - yield app - _get_app_info(request.node, app, app_params).update(app) + with _app_info_context(request.node, app, app_params): + yield app if shared_result is not None: module_cache.store(shared_result, app) From 2a5d03ad2b923407cb5628c560b31664d28a842d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 23 Mar 2024 19:15:53 +0100 Subject: [PATCH 40/47] apply ruff format --- tests/test_testing/conftest.py | 8 ++++---- tests/test_testing/test_plugin_markers.py | 14 ++++++++++---- tests/test_testing/test_testroot_finder.py | 12 ++++++------ 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/tests/test_testing/conftest.py b/tests/test_testing/conftest.py index 4d5d0d827f2..a2fea0b1ea2 100644 --- a/tests/test_testing/conftest.py +++ b/tests/test_testing/conftest.py @@ -39,18 +39,18 @@ def _pytester_pyprojecttoml(pytester: Pytester) -> None: # this since it can be configured to automatically extend ``sys.path`` with # the project's sources. The issue seems to only appear when ``pytest`` is # directly invoked from the CLI. - pytester.makepyprojecttoml(f''' + pytester.makepyprojecttoml(f""" [tool.pytest.ini_options] addopts = ["--import-mode=prepend", "--strict-config", "--strict-markers"] pythonpath = [{PROJECT_PATH!r}] xfail_strict = true -''') +""") @pytest.fixture(autouse=True) def _pytester_conftest(pytestconfig: Config, pytester: Pytester) -> None: testroot_dir = os.path.join(pytestconfig.rootpath, 'tests', 'roots') - pytester.makeconftest(f''' + pytester.makeconftest(f""" import pytest pytest_plugins = [{SPHINX_PLUGIN_NAME!r}] @@ -67,4 +67,4 @@ def rootdir(): @pytest.fixture(scope='session') def default_testroot(): return 'minimal' -''') +""") diff --git a/tests/test_testing/test_plugin_markers.py b/tests/test_testing/test_plugin_markers.py index c8fe7160759..20a9a85438e 100644 --- a/tests/test_testing/test_plugin_markers.py +++ b/tests/test_testing/test_plugin_markers.py @@ -28,10 +28,16 @@ def test_mark_sphinx_with_builder(app_params): assert kwargs['srcdir'].name == 'minimal' -@pytest.mark.parametrize(('sphinx_isolation', 'policy'), [ - (False, 'minimal'), (True, 'always'), - ('minimal', 'minimal'), ('grouped', 'grouped'), ('always', 'always'), -]) +@pytest.mark.parametrize( + ('sphinx_isolation', 'policy'), + [ + (False, 'minimal'), + (True, 'always'), + ('minimal', 'minimal'), + ('grouped', 'grouped'), + ('always', 'always'), + ], +) @pytest.mark.sphinx('dummy') def test_mark_sphinx_with_isolation(app_params, sphinx_isolation, policy): isolate = app_params.kwargs['isolate'] diff --git a/tests/test_testing/test_testroot_finder.py b/tests/test_testing/test_testroot_finder.py index e999786102e..ccb6bdfc534 100644 --- a/tests/test_testing/test_testroot_finder.py +++ b/tests/test_testing/test_testroot_finder.py @@ -93,7 +93,7 @@ def e2e_with_fixture_def( # NoQA: E704 ) -> str: ... # fmt: on def e2e_with_fixture_def( # NoQA: E302 - fixt: str, attr: str, value: Any, expect: Any, scope: Scope, + fixt: str, attr: str, value: Any, expect: Any, scope: Scope ) -> str: """A test with an attribute defined via a fixture. @@ -104,7 +104,7 @@ def e2e_with_fixture_def( # NoQA: E302 :param scope: The fixture scope. :return: The test file source. """ - return f''' + return f""" import pytest @pytest.fixture(scope={scope.value!r}) @@ -114,7 +114,7 @@ def {fixt}(): def test(testroot_finder, {fixt}): assert {fixt} == {value!r} assert testroot_finder.{attr} == {expect!r} -''' +""" # fmt: off @@ -138,17 +138,17 @@ def e2e_with_parametrize( # NoQA: E704 ) -> str: ... # fmt: on def e2e_with_parametrize( # NoQA: E302 - fixt: str, attr: str, value: Any, expect: Any, scope: Scope, + fixt: str, attr: str, value: Any, expect: Any, scope: Scope ) -> str: """A test with an attribute defined via parametrization.""" - return f''' + return f""" import pytest @pytest.mark.parametrize({fixt!r}, [{value!r}], scope={scope.value!r}) def test(testroot_finder, {fixt}): assert {fixt} == {value!r} assert testroot_finder.{attr} == {expect!r} -''' +""" @pytest.mark.parametrize('scope', Scope) From e33ba4078c3db87ff33482cfb23857dca47de88f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 23 Mar 2024 19:29:26 +0100 Subject: [PATCH 41/47] fixup --- sphinx/testing/fixtures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index 4a3dd815c77..c4609214f89 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -381,7 +381,7 @@ def _add_app_info_extras(app, app_info_extras): """ # xref RemovedInSphinx90Warning: remove the assert assert not sphinx_use_legacy_plugin, 'legacy plugin does not support this fixture' - assert _APP_INFO_KEY in request.node + assert _APP_INFO_KEY in request.node.stash return request.node.stash[_APP_INFO_KEY].extras From a7e4026dadd00bf14afe1ee9e64118a8ae67d320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Wed, 27 Mar 2024 15:44:48 +0100 Subject: [PATCH 42/47] fix lint --- sphinx/testing/util.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sphinx/testing/util.py b/sphinx/testing/util.py index e328e585f20..2e3b3a604f4 100644 --- a/sphinx/testing/util.py +++ b/sphinx/testing/util.py @@ -7,6 +7,7 @@ import contextlib import os import sys +import warnings from io import StringIO from types import MappingProxyType from typing import TYPE_CHECKING From 054cb79ad4a823308e51a49d009bc8158d8fb08e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 2 Apr 2024 18:07:51 +0200 Subject: [PATCH 43/47] fix lint --- sphinx/testing/_internal/cache.py | 2 +- sphinx/testing/_internal/markers.py | 6 ++++-- sphinx/testing/_internal/pytest_util.py | 20 +++++++++----------- sphinx/testing/_internal/util.py | 6 ++++-- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/sphinx/testing/_internal/cache.py b/sphinx/testing/_internal/cache.py index 0913285d7e8..9bf05e02ad0 100644 --- a/sphinx/testing/_internal/cache.py +++ b/sphinx/testing/_internal/cache.py @@ -134,7 +134,7 @@ class LegacyModuleCache: # kept for legacy purposes cache: dict[str, dict[str, str]] = {} def store( - self, key: str, app_: SphinxTestApp | SphinxTestAppWrapperForSkipBuilding, + self, key: str, app_: SphinxTestApp | SphinxTestAppWrapperForSkipBuilding ) -> None: if key in self.cache: return diff --git a/sphinx/testing/_internal/markers.py b/sphinx/testing/_internal/markers.py index c167dd30264..a0fedea3bea 100644 --- a/sphinx/testing/_internal/markers.py +++ b/sphinx/testing/_internal/markers.py @@ -221,6 +221,7 @@ def _get_common_config_checksum( # ignored keyword arguments when computing the checksum **_ignored: Any, ) -> int: + # fmt: off return get_objects_checksum( buildername, confoverrides=confoverrides, freshenv=freshenv, warningiserror=warningiserror, tags=tags, verbosity=verbosity, @@ -228,6 +229,7 @@ def _get_common_config_checksum( # extra constructor arguments docutils_conf=docutils_conf, ) + # fmt: on def process_sphinx( @@ -327,8 +329,8 @@ def process_test_params(node: PytestNode) -> TestParams: if m.args: pytest.fail(format_mark_failure('test_params', 'unexpected positional argument')) - check_mark_keywords('test_params', TestParams.__annotations__, - kwargs := m.kwargs, node=node, strict=True) + kwargs, allowed_keywords = m.kwargs, TestParams.__annotations__ + check_mark_keywords('test_params', allowed_keywords, kwargs, node=node, strict=True) if (shared_result_id := kwargs.get('shared_result', None)) is None: # generate a random shared_result for @pytest.mark.test_params() diff --git a/sphinx/testing/_internal/pytest_util.py b/sphinx/testing/_internal/pytest_util.py index b7a12b3262b..a649c761670 100644 --- a/sphinx/testing/_internal/pytest_util.py +++ b/sphinx/testing/_internal/pytest_util.py @@ -21,7 +21,7 @@ T = TypeVar('T') DT = TypeVar('DT') - NodeType = TypeVar('NodeType', bound="PytestNode") + NodeType = TypeVar('NodeType', bound='PytestNode') class TestRootFinder: @@ -87,7 +87,7 @@ def find(self, testroot_id: str | None = None) -> str | None: return os.path.join(path, f'{self.prefix}{testroot_id}') -ScopeName = Literal["session", "package", "module", "class", "function"] +ScopeName = Literal['session', 'package', 'module', 'class', 'function'] """Pytest scopes.""" _NODE_TYPE_BY_SCOPE: Final[dict[ScopeName, type[PytestNode]]] = { @@ -230,7 +230,7 @@ def test(request): """ args, kwargs = list(default_args), default_kwargs for info in reversed(list(node.iter_markers(marker))): - args[:len(info.args)] = info.args + args[:len(info.args)] = info.args # fmt: skip kwargs |= info.kwargs return args, kwargs @@ -260,10 +260,8 @@ def check_mark_keywords( ... ignore_private=True) True """ - extras = sorted( - key for key in set(actual).difference(expect) - if not (key.startswith('_') and ignore_private) - ) + keys = set(actual).difference(expect) + extras = sorted(key for key in keys if not (key.startswith('_') and ignore_private)) if extras and node: msg = 'unexpected keyword argument(s): %s' % ', '.join(sorted(extras)) if strict: @@ -283,12 +281,12 @@ def check_mark_str_args(mark: str, /, **kwargs: Any) -> None: """ for argname, value in kwargs.items(): if value and not isinstance(value, str) or not value and value is not None: - fmt = "expecting a non-empty string or None for %r, got: %r" + fmt = 'expecting a non-empty string or None for %r, got: %r' pytest.fail(format_mark_failure(mark, fmt % (argname, value))) def stack_pytest_markers( - marker: pytest.MarkDecorator, /, *markers: pytest.MarkDecorator, + marker: pytest.MarkDecorator, /, *markers: pytest.MarkDecorator ) -> Callable[[Callable[..., None]], Callable[..., None]]: """Create a decorator stacking pytest markers.""" stack = [marker, *markers] @@ -326,7 +324,7 @@ def issue_warning(node: PytestNode, warning: Warning, /) -> None: ... # NoQA: E def issue_warning(node: PytestNode, fmt: Any, /, *args: Any, category: type[Warning] | None = ...) -> None: ... # NoQA: E501, E704 # fmt: on def issue_warning( # NoQA: E302 - ctx: Any, fmt: Any, /, *args: Any, category: type[Warning] | None = None, + ctx: Any, fmt: Any, /, *args: Any, category: type[Warning] | None = None ) -> None: """Public helper for emitting a warning on a pytest object. @@ -375,7 +373,7 @@ def format_mark_failure(mark: str, message: str) -> str: def get_pytest_config( - subject: pytest.Config | pytest.FixtureRequest | PytestNode, /, + subject: pytest.Config | pytest.FixtureRequest | PytestNode, / ) -> pytest.Config: """Get the underlying pytest configuration of the *subject*.""" if isinstance(subject, pytest.Config): diff --git a/sphinx/testing/_internal/util.py b/sphinx/testing/_internal/util.py index 8e993910a58..d2025ff0f89 100644 --- a/sphinx/testing/_internal/util.py +++ b/sphinx/testing/_internal/util.py @@ -59,6 +59,7 @@ def get_objects_checksum(*args: Any, **kwargs: Any) -> int: of its :func:`id`, possibly making the checksum distinct for equivalent but non-pickable objects. """ + def default_encoder(x: object) -> str: try: return pickle.dumps(x, protocol=pickle.HIGHEST_PROTOCOL).hex() @@ -66,8 +67,8 @@ def default_encoder(x: object) -> str: return format(id(x), 'x') # use the most compact JSON format - env = json.dumps([args, kwargs], separators=(',', ':'), - default=default_encoder, sort_keys=True) + data = [args, kwargs] + env = json.dumps(data, separators=(',', ':'), default=default_encoder, sort_keys=True) # avoid using unique_object_id() since we do not really need SHA-1 entropy return binascii.crc32(env.encode('utf-8')) @@ -93,6 +94,7 @@ def get_container_id(node: PytestNode) -> str: The node's container is defined by all but the last component of the node's path (e.g., ``pkg.mod.test_func`` is contained in ``pkg.mod``). """ + def get_obj_name(subject: PytestNode) -> str | None: if isinstance(subject, pytest.Package): return subject.name From f68197b4540d3a027e01de23f55ca9f2b5e3f918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 6 Apr 2024 10:12:04 +0200 Subject: [PATCH 44/47] fixup --- sphinx/testing/fixtures.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index c4609214f89..2f17ec6b247 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -34,7 +34,7 @@ SphinxTestAppLazyBuild, SphinxTestAppWrapperForSkipBuilding, ) -from sphinx.util.console import _strip_escape_sequences +from sphinx.util.console import strip_escape_sequences if TYPE_CHECKING: from collections.abc import Generator @@ -319,7 +319,7 @@ def test_with_explicit_shared_result(test_params): def _cleanup_app_info(text: str) -> str: if os.name == 'nt': - text = _strip_escape_sequences(text) + text = strip_escape_sequences(text) text = text.encode('ascii', errors='backslashreplace').decode('ascii') return text From e431ce99c591d4d972cab8d63ee67292424d420c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 19 Apr 2024 09:57:43 +0200 Subject: [PATCH 45/47] cleanup --- sphinx/testing/_internal/pytest_util.py | 4 ++-- sphinx/testing/fixtures.py | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/sphinx/testing/_internal/pytest_util.py b/sphinx/testing/_internal/pytest_util.py index a649c761670..d48825279ce 100644 --- a/sphinx/testing/_internal/pytest_util.py +++ b/sphinx/testing/_internal/pytest_util.py @@ -16,7 +16,7 @@ from sphinx.testing._internal.warnings import MarkWarning, NodeWarning, SphinxTestingWarning if TYPE_CHECKING: - from collections.abc import Callable, Collection, Generator, Iterable + from collections.abc import Callable, Collection, Generator, Iterable, Iterator from typing import Any, ClassVar, Final T = TypeVar('T') @@ -301,7 +301,7 @@ def wrapper(func: Callable[..., None]) -> Callable[..., None]: @contextmanager -def pytest_not_raises(*exceptions: type[BaseException]) -> Generator[None, None, None]: +def pytest_not_raises(*exceptions: type[BaseException]) -> Iterator[None]: """Context manager asserting that no exception is raised.""" try: yield diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index af59acc0e02..69dd712be36 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -38,7 +38,7 @@ from sphinx.util.console import strip_escape_sequences if TYPE_CHECKING: - from collections.abc import Generator, Iterator + from collections.abc import Iterator from io import StringIO from pathlib import Path from typing import Any, Final, Union @@ -330,7 +330,7 @@ def _app_info_context( node: PytestNode, app: SphinxTestApp, app_params: AppParams, -) -> Generator[None, None, None]: +) -> Iterator[None]: # create or get the current :class:`AppInfo` object of the node if _APP_INFO_KEY not in node.stash: node.stash[_APP_INFO_KEY] = AppInfo( @@ -391,7 +391,7 @@ def __app_fixture( app_params: AppParams, make_app: Callable[..., SphinxTestApp], module_cache: ModuleCache, -) -> Generator[SphinxTestApp, None, None]: +) -> Iterator[SphinxTestApp]: shared_result = app_params.kwargs['shared_result'] app = make_app(*app_params.args, **app_params.kwargs) @@ -411,7 +411,7 @@ def app( module_cache: ModuleCache, shared_result: LegacyModuleCache, # xref RemovedInSphinx90Warning sphinx_use_legacy_plugin: bool, # xref RemovedInSphinx90Warning -) -> Iterator[AnySphinxTestApp, None, None]: # xref RemovedInSphinx90Warning: update type +) -> Iterator[AnySphinxTestApp]: # xref RemovedInSphinx90Warning: update type """A :class:`sphinx.application.Sphinx` object suitable for testing.""" if sphinx_use_legacy_plugin: # xref RemovedInSphinx90Warning # a warning will be emitted by the app_params fixture @@ -448,13 +448,13 @@ def make_app( test_params: TestParams, sphinx_use_legacy_plugin: bool, # xref RemovedInSphinx90Warning # xref RemovedInSphinx90Warning: narrow callable return type -) -> Iterator[Callable[..., SphinxTestApp | SphinxTestAppWrapperForSkipBuilding]]: +) -> Iterator[Callable[..., AnySphinxTestApp]]: """Fixture to create :class:`~sphinx.testing.util.SphinxTestApp` objects.""" stack: list[SphinxTestApp] = [] allow_rebuild = test_params['shared_result'] is None # xref RemovedInSphinx90Warning: narrow return type - def make(*args: Any, **kwargs: Any) -> SphinxTestApp | SphinxTestAppWrapperForSkipBuilding: + def make(*args: Any, **kwargs: Any) -> AnySphinxTestApp: if allow_rebuild: app = SphinxTestApp(*args, **kwargs) else: @@ -630,7 +630,7 @@ def __app_fixture_legacy( # xref RemovedInSphinx90Warning test_params: TestParams, make_app: Callable[..., AnySphinxTestApp], shared_result: LegacyModuleCache, -) -> Generator[AnySphinxTestApp, None, None]: +) -> Iterator[AnySphinxTestApp]: app = make_app(*app_params.args, **app_params.kwargs) yield app From 74d96a3eb68ae73a7ccbc1059f8a42b85e9dcbdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 19 Apr 2024 09:59:08 +0200 Subject: [PATCH 46/47] cleanup --- sphinx/testing/fixtures.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index 69dd712be36..14cf58451eb 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -9,7 +9,6 @@ import sys import warnings from collections.abc import Callable -from io import StringIO from typing import TYPE_CHECKING, Optional, cast import pytest From 9560a6564bcdd30a7d8ff21a77672075b5bf8ca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 19 Apr 2024 10:02:31 +0200 Subject: [PATCH 47/47] cleanup --- sphinx/testing/_internal/pytest_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx/testing/_internal/pytest_util.py b/sphinx/testing/_internal/pytest_util.py index d48825279ce..ef4d38883a9 100644 --- a/sphinx/testing/_internal/pytest_util.py +++ b/sphinx/testing/_internal/pytest_util.py @@ -16,7 +16,7 @@ from sphinx.testing._internal.warnings import MarkWarning, NodeWarning, SphinxTestingWarning if TYPE_CHECKING: - from collections.abc import Callable, Collection, Generator, Iterable, Iterator + from collections.abc import Callable, Collection, Iterable, Iterator from typing import Any, ClassVar, Final T = TypeVar('T')