diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b533b67e41b..2c22e7c66ad 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -49,6 +49,10 @@ jobs: - uses: actions/checkout@v4 with: persist-credentials: false + - name: Mount the test roots as read-only + run: | + mkdir -p ./tests/roots-read-only + sudo mount -v --bind --read-only ./tests/roots ./tests/roots-read-only - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v5 with: diff --git a/sphinx/testing/fixtures.py b/sphinx/testing/fixtures.py index 163a3b65b82..ec143faccf4 100644 --- a/sphinx/testing/fixtures.py +++ b/sphinx/testing/fixtures.py @@ -104,12 +104,16 @@ def app_params( test_root = kwargs.pop('testroot', 'root') kwargs['srcdir'] = srcdir = sphinx_test_tempdir / kwargs.get('srcdir', test_root) + copy_test_root = not {'srcdir', 'copy_test_root'}.isdisjoint(kwargs) # special support for sphinx/tests if rootdir is not None: test_root_path = rootdir / f'test-{test_root}' - if test_root_path.is_dir() and not srcdir.exists(): - shutil.copytree(test_root_path, srcdir) + if copy_test_root: + if test_root_path.is_dir(): + shutil.copytree(test_root_path, srcdir, dirs_exist_ok=True) + else: + kwargs['srcdir'] = test_root_path # always write to the temporary directory kwargs.setdefault('builddir', srcdir / '_build') diff --git a/sphinx/testing/util.py b/sphinx/testing/util.py index 66a2a22fa03..218cb3f7bb7 100644 --- a/sphinx/testing/util.py +++ b/sphinx/testing/util.py @@ -223,7 +223,11 @@ def warning(self) -> StringIO: def cleanup(self, doctrees: bool = False) -> None: sys.path[:] = self._saved_path _clean_up_global_state() - self.docutils_conf_path.unlink(missing_ok=True) + try: + self.docutils_conf_path.unlink(missing_ok=True) + except OSError as exc: + if exc.errno != 30: # Ignore "read-only file system" errors + raise def __repr__(self) -> str: return f'<{self.__class__.__name__} buildername={self._builder_name!r}>' diff --git a/tests/conftest.py b/tests/conftest.py index e5e1d85a8b8..9febb9c90f2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,7 +19,10 @@ from collections.abc import Iterator _TESTS_ROOT = Path(__file__).resolve().parent -_ROOTS_DIR = _TESTS_ROOT / 'roots' +if 'CI' in os.environ and (_TESTS_ROOT / 'roots-read-only').is_dir(): + _ROOTS_DIR = _TESTS_ROOT / 'roots-read-only' +else: + _ROOTS_DIR = _TESTS_ROOT / 'roots' def _init_console( diff --git a/tests/test_builders/test_build_linkcheck.py b/tests/test_builders/test_build_linkcheck.py index 56d42afea45..6bf06a75ca8 100644 --- a/tests/test_builders/test_build_linkcheck.py +++ b/tests/test_builders/test_build_linkcheck.py @@ -257,6 +257,7 @@ def test_too_many_retries(app: SphinxTestApp) -> None: 'linkcheck', testroot='linkcheck-raw-node', freshenv=True, + copy_test_root=True, ) def test_raw_node(app: SphinxTestApp) -> None: with serve_application(app, OKHandler) as address: diff --git a/tests/test_environment/test_environment.py b/tests/test_environment/test_environment.py index a5059e3e930..49736be1144 100644 --- a/tests/test_environment/test_environment.py +++ b/tests/test_environment/test_environment.py @@ -20,7 +20,7 @@ ) -@pytest.mark.sphinx('dummy', testroot='basic') +@pytest.mark.sphinx('dummy', testroot='basic', copy_test_root=True) def test_config_status(make_app, app_params): args, kwargs = app_params diff --git a/tests/test_extensions/test_ext_autodoc_configs.py b/tests/test_extensions/test_ext_autodoc_configs.py index f56948c3d23..66681f696bc 100644 --- a/tests/test_extensions/test_ext_autodoc_configs.py +++ b/tests/test_extensions/test_ext_autodoc_configs.py @@ -1033,6 +1033,7 @@ def test_autodoc_typehints_description(app): 'autodoc_typehints': 'description', 'autodoc_typehints_description_target': 'documented', }, + copy_test_root=True, ) def test_autodoc_typehints_description_no_undoc(app): # No :type: or :rtype: will be injected for `incr`, which does not have @@ -1085,6 +1086,7 @@ def test_autodoc_typehints_description_no_undoc(app): 'autodoc_typehints': 'description', 'autodoc_typehints_description_target': 'documented_params', }, + copy_test_root=True, ) def test_autodoc_typehints_description_no_undoc_doc_rtype(app): # No :type: will be injected for `incr`, which does not have a description @@ -1154,6 +1156,7 @@ def test_autodoc_typehints_description_no_undoc_doc_rtype(app): 'text', testroot='ext-autodoc', confoverrides={'autodoc_typehints': 'description'}, + copy_test_root=True, ) def test_autodoc_typehints_description_with_documented_init(app): with overwrite_file( @@ -1198,6 +1201,7 @@ def test_autodoc_typehints_description_with_documented_init(app): 'autodoc_typehints': 'description', 'autodoc_typehints_description_target': 'documented', }, + copy_test_root=True, ) def test_autodoc_typehints_description_with_documented_init_no_undoc(app): with overwrite_file( @@ -1232,6 +1236,7 @@ def test_autodoc_typehints_description_with_documented_init_no_undoc(app): 'autodoc_typehints': 'description', 'autodoc_typehints_description_target': 'documented_params', }, + copy_test_root=True, ) def test_autodoc_typehints_description_with_documented_init_no_undoc_doc_rtype(app): # see test_autodoc_typehints_description_with_documented_init_no_undoc @@ -1276,6 +1281,7 @@ def test_autodoc_typehints_description_for_invalid_node(app): 'text', testroot='ext-autodoc', confoverrides={'autodoc_typehints': 'both'}, + copy_test_root=True, ) def test_autodoc_typehints_both(app): with overwrite_file( diff --git a/tests/test_extensions/test_ext_autosummary.py b/tests/test_extensions/test_ext_autosummary.py index 47589718fdc..750b6cf54aa 100644 --- a/tests/test_extensions/test_ext_autosummary.py +++ b/tests/test_extensions/test_ext_autosummary.py @@ -151,6 +151,7 @@ def test_extract_summary(capsys): 'dummy', testroot='ext-autosummary-ext', confoverrides=defaults.copy(), + copy_test_root=True, ) def test_get_items_summary(make_app, app_params): import sphinx.ext.autosummary @@ -227,6 +228,7 @@ def str_content(elem: Element) -> str: 'xml', testroot='ext-autosummary-ext', confoverrides=defaults.copy(), + copy_test_root=True, ) def test_escaping(app): app.build(force_all=True) @@ -238,7 +240,7 @@ def test_escaping(app): assert str_content(title) == 'underscore_module_' -@pytest.mark.sphinx('html', testroot='ext-autosummary') +@pytest.mark.sphinx('html', testroot='ext-autosummary', copy_test_root=True) def test_autosummary_generate_content_for_module(app): import autosummary_dummy_module # type: ignore[import-not-found] @@ -298,7 +300,7 @@ def test_autosummary_generate_content_for_module(app): assert context['objtype'] == 'module' -@pytest.mark.sphinx('html', testroot='ext-autosummary') +@pytest.mark.sphinx('html', testroot='ext-autosummary', copy_test_root=True) def test_autosummary_generate_content_for_module___all__(app): import autosummary_dummy_module @@ -343,7 +345,7 @@ def test_autosummary_generate_content_for_module___all__(app): assert context['objtype'] == 'module' -@pytest.mark.sphinx('html', testroot='ext-autosummary') +@pytest.mark.sphinx('html', testroot='ext-autosummary', copy_test_root=True) def test_autosummary_generate_content_for_module_skipped(app): import autosummary_dummy_module @@ -389,7 +391,7 @@ def skip_member(app, what, name, obj, skip, options): assert context['exceptions'] == [] -@pytest.mark.sphinx('html', testroot='ext-autosummary') +@pytest.mark.sphinx('html', testroot='ext-autosummary', copy_test_root=True) def test_autosummary_generate_content_for_module_imported_members(app): import autosummary_dummy_module @@ -455,7 +457,7 @@ def test_autosummary_generate_content_for_module_imported_members(app): assert context['objtype'] == 'module' -@pytest.mark.sphinx('html', testroot='ext-autosummary') +@pytest.mark.sphinx('html', testroot='ext-autosummary', copy_test_root=True) def test_autosummary_generate_content_for_module_imported_members_inherited_module(app): import autosummary_dummy_inherited_module # type: ignore[import-not-found] @@ -501,7 +503,7 @@ def test_autosummary_generate_content_for_module_imported_members_inherited_modu assert context['objtype'] == 'module' -@pytest.mark.sphinx('dummy', testroot='ext-autosummary') +@pytest.mark.sphinx('dummy', testroot='ext-autosummary', copy_test_root=True) def test_autosummary_generate(app): app.build(force_all=True) @@ -650,6 +652,7 @@ def test_autosummary_generate(app): 'dummy', testroot='ext-autosummary', confoverrides={'autosummary_generate_overwrite': False}, + copy_test_root=True, ) def test_autosummary_generate_overwrite1(app_params, make_app): args, kwargs = app_params @@ -669,6 +672,7 @@ def test_autosummary_generate_overwrite1(app_params, make_app): 'dummy', testroot='ext-autosummary', confoverrides={'autosummary_generate_overwrite': True}, + copy_test_root=True, ) def test_autosummary_generate_overwrite2(app_params, make_app): args, kwargs = app_params @@ -684,7 +688,7 @@ def test_autosummary_generate_overwrite2(app_params, make_app): assert 'autosummary_dummy_module.rst' not in app._warning.getvalue() -@pytest.mark.sphinx('dummy', testroot='ext-autosummary-recursive') +@pytest.mark.sphinx('dummy', testroot='ext-autosummary-recursive', copy_test_root=True) @pytest.mark.usefixtures('rollback_sysmodules') def test_autosummary_recursive(app): sys.modules.pop('package', None) # unload target module to clear the module cache @@ -738,7 +742,11 @@ def test_autosummary_recursive_skips_mocked_modules(app): assert not (app.srcdir / 'generated' / 'package.package.module.rst').exists() -@pytest.mark.sphinx('dummy', testroot='ext-autosummary-filename-map') +@pytest.mark.sphinx( + 'dummy', + testroot='ext-autosummary-filename-map', + copy_test_root=True, +) def test_autosummary_filename_map(app): app.build() @@ -756,6 +764,7 @@ def test_autosummary_filename_map(app): 'latex', testroot='ext-autosummary-ext', confoverrides=defaults.copy(), + copy_test_root=True, ) def test_autosummary_latex_table_colspec(app): app.build(force_all=True) @@ -793,7 +802,11 @@ def test_import_by_name(): assert modname == 'sphinx.ext.autosummary' -@pytest.mark.sphinx('dummy', testroot='ext-autosummary-mock_imports') +@pytest.mark.sphinx( + 'dummy', + testroot='ext-autosummary-mock_imports', + copy_test_root=True, +) def test_autosummary_mock_imports(app): try: app.build() @@ -805,7 +818,11 @@ def test_autosummary_mock_imports(app): sys.modules.pop('foo', None) # unload foo module -@pytest.mark.sphinx('dummy', testroot='ext-autosummary-imported_members') +@pytest.mark.sphinx( + 'dummy', + testroot='ext-autosummary-imported_members', + copy_test_root=True, +) def test_autosummary_imported_members(app): try: app.build() @@ -820,7 +837,11 @@ def test_autosummary_imported_members(app): sys.modules.pop('autosummary_dummy_package', None) -@pytest.mark.sphinx('dummy', testroot='ext-autosummary-module_all') +@pytest.mark.sphinx( + 'dummy', + testroot='ext-autosummary-module_all', + copy_test_root=True, +) def test_autosummary_module_all(app): try: app.build() @@ -839,7 +860,11 @@ def test_autosummary_module_all(app): sys.modules.pop('autosummary_dummy_package_all', None) -@pytest.mark.sphinx('dummy', testroot='ext-autosummary-module_empty_all') +@pytest.mark.sphinx( + 'dummy', + testroot='ext-autosummary-module_empty_all', + copy_test_root=True, +) def test_autosummary_module_empty_all(app): try: app.build() @@ -867,6 +892,7 @@ def test_autosummary_module_empty_all(app): 'html', testroot='ext-autodoc', confoverrides={'extensions': ['sphinx.ext.autosummary']}, + copy_test_root=True, ) def test_generate_autosummary_docs_property(app): with patch('sphinx.ext.autosummary.generate.find_autosummary_in_files') as mock: @@ -886,7 +912,11 @@ def test_generate_autosummary_docs_property(app): ) -@pytest.mark.sphinx('html', testroot='ext-autosummary-skip-member') +@pytest.mark.sphinx( + 'html', + testroot='ext-autosummary-skip-member', + copy_test_root=True, +) def test_autosummary_skip_member(app): app.build() @@ -895,7 +925,7 @@ def test_autosummary_skip_member(app): assert 'Foo._privatemeth' in content -@pytest.mark.sphinx('html', testroot='ext-autosummary-template') +@pytest.mark.sphinx('html', testroot='ext-autosummary-template', copy_test_root=True) def test_autosummary_template(app): app.build() diff --git a/tests/test_extensions/test_ext_autosummary_imports.py b/tests/test_extensions/test_ext_autosummary_imports.py index 7abee757e3c..b50360e58de 100644 --- a/tests/test_extensions/test_ext_autosummary_imports.py +++ b/tests/test_extensions/test_ext_autosummary_imports.py @@ -59,7 +59,11 @@ def test_autosummary_import_cycle(app): assert expected in app.warning.getvalue() -@pytest.mark.sphinx('dummy', testroot='ext-autosummary-module_prefix') +@pytest.mark.sphinx( + 'dummy', + testroot='ext-autosummary-module_prefix', + copy_test_root=True, +) @pytest.mark.usefixtures('rollback_sysmodules') def test_autosummary_generate_prefixes(app): app.build() diff --git a/tests/test_extensions/test_ext_intersphinx.py b/tests/test_extensions/test_ext_intersphinx.py index a51ab5abd9c..b5d9d62fb2a 100644 --- a/tests/test_extensions/test_ext_intersphinx.py +++ b/tests/test_extensions/test_ext_intersphinx.py @@ -696,7 +696,7 @@ def log_message(*args, **kwargs): assert stderr == '' -@pytest.mark.sphinx('html', testroot='ext-intersphinx-role') +@pytest.mark.sphinx('html', testroot='ext-intersphinx-role', copy_test_root=True) def test_intersphinx_role(app): inv_file = app.srcdir / 'inventory' inv_file.write_bytes(INVENTORY_V2) diff --git a/tests/test_intl/test_intl.py b/tests/test_intl/test_intl.py index 398d9d18b88..f4a0e42377a 100644 --- a/tests/test_intl/test_intl.py +++ b/tests/test_intl/test_intl.py @@ -699,7 +699,7 @@ def test_gettext_buildr_ignores_only_directive(app): @sphinx_intl -@pytest.mark.sphinx('html', testroot='intl') +@pytest.mark.sphinx('html', testroot='intl', copy_test_root=True) def test_node_translated_attribute(app): app.build(filenames=[app.srcdir / 'translation_progress.txt']) @@ -713,7 +713,7 @@ def test_node_translated_attribute(app): @sphinx_intl -@pytest.mark.sphinx('html', testroot='intl') +@pytest.mark.sphinx('html', testroot='intl', copy_test_root=True) def test_translation_progress_substitution(app): app.build(filenames=[app.srcdir / 'translation_progress.txt']) @@ -732,6 +732,7 @@ def test_translation_progress_substitution(app): 'gettext_compact': False, 'translation_progress_classes': True, }, + copy_test_root=True, ) def test_translation_progress_classes_true(app): app.build(filenames=[app.srcdir / 'translation_progress.txt']) @@ -862,6 +863,7 @@ def mock_write_mo(self, locale, use_fuzzy=False): 'dummy', testroot='builder-gettext-dont-rebuild-mo', freshenv=True, + copy_test_root=True, ) def test_dummy_should_rebuild_mo(mock_time_and_i18n, make_app, app_params): mock, clock = mock_time_and_i18n @@ -924,6 +926,7 @@ def test_dummy_should_rebuild_mo(mock_time_and_i18n, make_app, app_params): 'gettext', testroot='builder-gettext-dont-rebuild-mo', freshenv=True, + copy_test_root=True, ) def test_gettext_dont_rebuild_mo(mock_time_and_i18n, app): mock, clock = mock_time_and_i18n @@ -1677,6 +1680,7 @@ def test_additional_targets_should_be_translated(app): 'image', ], }, + copy_test_root=True, ) def test_additional_targets_should_be_translated_substitution_definitions(app): app.build(force_all=True) @@ -1713,6 +1717,7 @@ def test_text_references(app): 'locale_dirs': ['.'], 'gettext_compact': False, }, + copy_test_root=True, ) def test_text_prolog_epilog_substitution(app): app.build() @@ -1946,6 +1951,7 @@ def test_gettext_disallow_fuzzy_translations(app): 'html', testroot='basic', confoverrides={'language': 'de', 'html_sidebars': {'**': ['searchbox.html']}}, + copy_test_root=True, ) def test_customize_system_message(make_app, app_params): try: diff --git a/tests/test_theming/test_templating.py b/tests/test_theming/test_templating.py index c508716b7c7..b2d6f0d6eae 100644 --- a/tests/test_theming/test_templating.py +++ b/tests/test_theming/test_templating.py @@ -7,7 +7,7 @@ from sphinx.ext.autosummary.generate import setup_documenters -@pytest.mark.sphinx('html', testroot='templating') +@pytest.mark.sphinx('html', testroot='templating', copy_test_root=True) def test_layout_overloading(make_app, app_params): args, kwargs = app_params app = make_app(*args, **kwargs) @@ -18,7 +18,7 @@ def test_layout_overloading(make_app, app_params): assert '' in result -@pytest.mark.sphinx('html', testroot='templating') +@pytest.mark.sphinx('html', testroot='templating', copy_test_root=True) def test_autosummary_class_template_overloading(make_app, app_params): args, kwargs = app_params app = make_app(*args, **kwargs) @@ -36,6 +36,7 @@ def test_autosummary_class_template_overloading(make_app, app_params): 'html', testroot='templating', confoverrides={'autosummary_context': {'sentence': 'foobar'}}, + copy_test_root=True, ) def test_autosummary_context(make_app, app_params): args, kwargs = app_params diff --git a/tests/test_writers/test_docutilsconf.py b/tests/test_writers/test_docutilsconf.py index 4201c9df831..32e9d13343c 100644 --- a/tests/test_writers/test_docutilsconf.py +++ b/tests/test_writers/test_docutilsconf.py @@ -29,6 +29,7 @@ def test_html_with_default_docutilsconf(app): testroot='docutilsconf', freshenv=True, docutils_conf='[restructuredtext parser]\ntrim_footnote_reference_space: true\n', + copy_test_root=True, ) def test_html_with_docutilsconf(app): with patch_docutils(app.confdir):