| """Sphinx test fixtures for pytest""" |
| |
| from __future__ import annotations |
| |
| import shutil |
| import subprocess |
| import sys |
| from collections import namedtuple |
| from io import StringIO |
| from typing import TYPE_CHECKING, Any, Callable |
| |
| import pytest |
| |
| from sphinx.testing.util import SphinxTestApp, SphinxTestAppWrapperForSkipBuilding |
| |
| if TYPE_CHECKING: |
| from collections.abc import Generator |
| from pathlib import Path |
| |
| DEFAULT_ENABLED_MARKERS = [ |
| ( |
| 'sphinx(builder, testroot=None, freshenv=False, confoverrides=None, tags=None,' |
| ' docutilsconf=None, parallel=0): arguments to initialize the sphinx test application.' |
| ), |
| 'test_params(shared_result=...): test parameters.', |
| ] |
| |
| |
| def pytest_configure(config): |
| """Register custom markers""" |
| for marker in DEFAULT_ENABLED_MARKERS: |
| config.addinivalue_line('markers', marker) |
| |
| |
| @pytest.fixture(scope='session') |
| def rootdir() -> str | None: |
| return None |
| |
| |
| class SharedResult: |
| cache: dict[str, dict[str, str]] = {} |
| |
| def store(self, key: str, app_: SphinxTestApp) -> Any: |
| if key in self.cache: |
| return |
| data = { |
| 'status': app_._status.getvalue(), |
| 'warning': app_._warning.getvalue(), |
| } |
| self.cache[key] = data |
| |
| def restore(self, key: str) -> dict[str, StringIO]: |
| if key not in self.cache: |
| return {} |
| data = self.cache[key] |
| return { |
| 'status': StringIO(data['status']), |
| 'warning': StringIO(data['warning']), |
| } |
| |
| |
| @pytest.fixture() |
| def app_params(request: Any, test_params: dict, shared_result: SharedResult, |
| sphinx_test_tempdir: str, rootdir: str) -> _app_params: |
| """ |
| Parameters that are specified by 'pytest.mark.sphinx' for |
| sphinx.application.Sphinx initialization |
| """ |
| |
| # ##### process pytest.mark.sphinx |
| |
| pargs = {} |
| kwargs: dict[str, Any] = {} |
| |
| # to avoid stacking positional args |
| for info in reversed(list(request.node.iter_markers("sphinx"))): |
| for i, a in enumerate(info.args): |
| pargs[i] = a |
| kwargs.update(info.kwargs) |
| |
| args = [pargs[i] for i in sorted(pargs.keys())] |
| |
| # ##### process pytest.mark.test_params |
| if test_params['shared_result']: |
| if 'srcdir' in kwargs: |
| msg = 'You can not specify shared_result and srcdir in same time.' |
| raise pytest.Exception(msg) |
| kwargs['srcdir'] = test_params['shared_result'] |
| restore = shared_result.restore(test_params['shared_result']) |
| kwargs.update(restore) |
| |
| # ##### prepare Application params |
| |
| testroot = kwargs.pop('testroot', 'root') |
| kwargs['srcdir'] = srcdir = sphinx_test_tempdir / kwargs.get('srcdir', testroot) |
| |
| # special support for sphinx/tests |
| if rootdir and not srcdir.exists(): |
| testroot_path = rootdir / ('test-' + testroot) |
| shutil.copytree(testroot_path, srcdir) |
| |
| return _app_params(args, kwargs) |
| |
| |
| _app_params = namedtuple('_app_params', 'args,kwargs') |
| |
| |
| @pytest.fixture() |
| def test_params(request: Any) -> dict: |
| """ |
| Test parameters that are specified by 'pytest.mark.test_params' |
| |
| :param Union[str] shared_result: |
| If the value is provided, app._status and app._warning objects will be |
| shared in the parametrized test functions and/or test functions that |
| have same 'shared_result' value. |
| **NOTE**: You can not specify both shared_result and srcdir. |
| """ |
| env = request.node.get_closest_marker('test_params') |
| kwargs = env.kwargs if env else {} |
| result = { |
| 'shared_result': None, |
| } |
| result.update(kwargs) |
| |
| if result['shared_result'] and not isinstance(result['shared_result'], str): |
| msg = 'You can only provide a string type of value for "shared_result"' |
| raise pytest.Exception(msg) |
| return result |
| |
| |
| @pytest.fixture() |
| def app(test_params: dict, app_params: tuple[dict, dict], make_app: Callable, |
| shared_result: SharedResult) -> Generator[SphinxTestApp, None, None]: |
| """ |
| Provides the 'sphinx.application.Sphinx' object |
| """ |
| args, kwargs = app_params |
| app_ = make_app(*args, **kwargs) |
| yield app_ |
| |
| print('# testroot:', kwargs.get('testroot', 'root')) |
| print('# builder:', app_.builder.name) |
| print('# srcdir:', app_.srcdir) |
| print('# outdir:', app_.outdir) |
| print('# status:', '\n' + app_._status.getvalue()) |
| print('# warning:', '\n' + app_._warning.getvalue()) |
| |
| if test_params['shared_result']: |
| shared_result.store(test_params['shared_result'], app_) |
| |
| |
| @pytest.fixture() |
| def status(app: SphinxTestApp) -> StringIO: |
| """ |
| Back-compatibility for testing with previous @with_app decorator |
| """ |
| return app._status |
| |
| |
| @pytest.fixture() |
| def warning(app: SphinxTestApp) -> StringIO: |
| """ |
| Back-compatibility for testing with previous @with_app decorator |
| """ |
| return app._warning |
| |
| |
| @pytest.fixture() |
| def make_app(test_params: dict, monkeypatch: Any) -> Generator[Callable, None, None]: |
| """ |
| Provides make_app function to initialize SphinxTestApp instance. |
| if you want to initialize 'app' in your test function. please use this |
| instead of using SphinxTestApp class directory. |
| """ |
| apps = [] |
| syspath = sys.path[:] |
| |
| def make(*args, **kwargs): |
| status, warning = StringIO(), StringIO() |
| kwargs.setdefault('status', status) |
| kwargs.setdefault('warning', warning) |
| app_: Any = SphinxTestApp(*args, **kwargs) |
| apps.append(app_) |
| if test_params['shared_result']: |
| app_ = SphinxTestAppWrapperForSkipBuilding(app_) |
| return app_ |
| yield make |
| |
| sys.path[:] = syspath |
| for app_ in reversed(apps): # clean up applications from the new ones |
| app_.cleanup() |
| |
| |
| @pytest.fixture() |
| def shared_result() -> SharedResult: |
| return SharedResult() |
| |
| |
| @pytest.fixture(scope='module', autouse=True) |
| def _shared_result_cache() -> None: |
| SharedResult.cache.clear() |
| |
| |
| @pytest.fixture() |
| def if_graphviz_found(app: SphinxTestApp) -> None: # NoQA: PT004 |
| """ |
| The test will be skipped when using 'if_graphviz_found' fixture and graphviz |
| dot command is not found. |
| """ |
| graphviz_dot = getattr(app.config, 'graphviz_dot', '') |
| try: |
| if graphviz_dot: |
| subprocess.run([graphviz_dot, '-V'], capture_output=True) # show version |
| return |
| except OSError: # No such file or directory |
| pass |
| |
| pytest.skip('graphviz "dot" is not available') |
| |
| |
| @pytest.fixture(scope='session') |
| def sphinx_test_tempdir(tmp_path_factory: Any) -> Path: |
| """Temporary directory.""" |
| return tmp_path_factory.getbasetemp() |
| |
| |
| @pytest.fixture() |
| def rollback_sysmodules(): # NoQA: PT004 |
| """ |
| Rollback sys.modules to its value before testing to unload modules |
| during tests. |
| |
| For example, used in test_ext_autosummary.py to permit unloading the |
| target module to clear its cache. |
| """ |
| try: |
| sysmodules = list(sys.modules) |
| yield |
| finally: |
| for modname in list(sys.modules): |
| if modname not in sysmodules: |
| sys.modules.pop(modname) |