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')