diff --git a/changelog/11502.bugfix.rst b/changelog/11502.bugfix.rst new file mode 100644 index 00000000000..49e2b7e3b0e --- /dev/null +++ b/changelog/11502.bugfix.rst @@ -0,0 +1 @@ +The ``rootdir`` is no longer derived from a configuration file that is not a regular file, such as ``--config-file=/dev/null``. diff --git a/changelog/13246.bugfix.rst b/changelog/13246.bugfix.rst new file mode 100644 index 00000000000..2c4a42ce310 --- /dev/null +++ b/changelog/13246.bugfix.rst @@ -0,0 +1 @@ +The directory of a configuration file passed via :option:`-c` no longer determines the ``rootdir`` on its own; it is now the common ancestor of that directory and the collected test paths. Keeping the configuration in a subdirectory previously broke ``conftest.py`` discovery and node IDs. diff --git a/changelog/14705.bugfix.rst b/changelog/14705.bugfix.rst new file mode 100644 index 00000000000..c784899fb19 --- /dev/null +++ b/changelog/14705.bugfix.rst @@ -0,0 +1 @@ +TOML configuration files passed via :option:`-c` now read their configuration from the ``[pytest]`` table instead of ``[tool.pytest.ini_options]``, which remains reserved for ``pyproject.toml``. diff --git a/changelog/14716.breaking.rst b/changelog/14716.breaking.rst new file mode 100644 index 00000000000..58aca2c6b96 --- /dev/null +++ b/changelog/14716.breaking.rst @@ -0,0 +1 @@ +:option:`-c` now raises a usage error for a path that does not exist, is a directory, or is in a format pytest has no loader for. Such paths were previously either ignored or reported as an unhandled ``FileNotFoundError``. diff --git a/changelog/9703.bugfix.rst b/changelog/9703.bugfix.rst new file mode 100644 index 00000000000..2c4a42ce310 --- /dev/null +++ b/changelog/9703.bugfix.rst @@ -0,0 +1 @@ +The directory of a configuration file passed via :option:`-c` no longer determines the ``rootdir`` on its own; it is now the common ancestor of that directory and the collected test paths. Keeping the configuration in a subdirectory previously broke ``conftest.py`` discovery and node IDs. diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index 2a4bed319a9..ecba6f326f6 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable from collections.abc import Iterable from collections.abc import Sequence from dataclasses import dataclass @@ -55,114 +56,198 @@ def _parse_ini_config(path: Path) -> iniconfig.IniConfig: raise UsageError(str(exc)) from exc -def load_config_dict_from_file( - filepath: Path, -) -> ConfigDict | None: - """Load pytest configuration from the given file path, if supported. +def _parse_toml_file(path: Path) -> dict[str, object]: + """Parse the given '.toml' file, returning the decoded document. - Return None if the file does not contain valid pytest configuration. + Raise UsageError if the file cannot be parsed. """ - # Configuration from ini files are obtained from the [pytest] section, if present. - if filepath.suffix == ".ini": - iniconfig = _parse_ini_config(filepath) - - if "pytest" in iniconfig: - return { - k: ConfigValue(v, origin="file", mode="ini") - for k, v in iniconfig["pytest"].items() - } - else: - # "pytest.ini" files are always the source of configuration, even if empty. - if filepath.name in {"pytest.ini", ".pytest.ini"}: - return {} - - # '.cfg' files are considered if they contain a "[tool:pytest]" section. - elif filepath.suffix == ".cfg": - iniconfig = _parse_ini_config(filepath) - - if "tool:pytest" in iniconfig.sections: - return { - k: ConfigValue(v, origin="file", mode="ini") - for k, v in iniconfig["tool:pytest"].items() - } - elif "pytest" in iniconfig.sections: - # If a setup.cfg contains a "[pytest]" section, we raise a failure to indicate users that - # plain "[pytest]" sections in setup.cfg files is no longer supported (#3086). - fail(CFG_PYTEST_SECTION.format(filename="setup.cfg"), pytrace=False) - - # '.toml' files are considered if they contain a [tool.pytest] table (toml mode) - # or [tool.pytest.ini_options] table (ini mode) for pyproject.toml, - # or [pytest] table (toml mode) for pytest.toml/.pytest.toml. - elif filepath.suffix == ".toml": - if sys.version_info >= (3, 11): - import tomllib - else: - import tomli as tomllib + if sys.version_info >= (3, 11): + import tomllib + else: + import tomli as tomllib - toml_text = filepath.read_text(encoding="utf-8") - try: - config = tomllib.loads(toml_text) - except tomllib.TOMLDecodeError as exc: - raise UsageError(f"{filepath}: {exc}") from exc - - # pytest.toml and .pytest.toml use [pytest] table directly. - if filepath.name in ("pytest.toml", ".pytest.toml"): - if "pytest" in config: - # TOML mode - preserve native TOML types. - return { - k: ConfigValue(v, origin="file", mode="toml") - for k, v in config["pytest"].items() - } - top_level_options = [ - key for key, value in config.items() if not isinstance(value, dict) - ] - if top_level_options: - raise UsageError( - f"{filepath}: pytest configuration must be under a " - f"[pytest] table (found top-level options: " - f"{', '.join(top_level_options)})" - ) - # "pytest.toml" files are always the source of configuration, even if empty. - return {} + toml_text = path.read_text(encoding="utf-8") + try: + return tomllib.loads(toml_text) + except tomllib.TOMLDecodeError as exc: + raise UsageError(f"{path}: {exc}") from exc - # pyproject.toml uses [tool.pytest] or [tool.pytest.ini_options]. - else: - tool_pytest = config.get("tool", {}).get("pytest", {}) - # Check for toml mode config: [tool.pytest] with content outside of ini_options. - toml_config = {k: v for k, v in tool_pytest.items() if k != "ini_options"} - # Check for ini mode config: [tool.pytest.ini_options]. - ini_config = tool_pytest.get("ini_options", None) +def _parse_pytest_ini(path: Path) -> ConfigDict | None: + """Parse a dedicated pytest INI file (``pytest.ini``/``.pytest.ini``). - if toml_config and ini_config: - raise UsageError( - f"{filepath}: Cannot use both [tool.pytest] (native TOML types) and " - "[tool.pytest.ini_options] (string-based INI format) simultaneously. " - "Please use [tool.pytest] with native TOML types (recommended) " - "or [tool.pytest.ini_options] for backwards compatibility." - ) + These files are always the source of configuration, even if they lack a + ``[pytest]`` section, in which case an empty config is returned. + """ + iniconfig = _parse_ini_config(path) + + if "pytest" in iniconfig: + return { + k: ConfigValue(v, origin="file", mode="ini") + for k, v in iniconfig["pytest"].items() + } + return {} + + +def _parse_ini_file(path: Path) -> ConfigDict | None: + """Parse a generic '.ini' file (e.g. ``tox.ini``). + + Only considered if it contains a ``[pytest]`` section. + """ + iniconfig = _parse_ini_config(path) + + if "pytest" in iniconfig: + return { + k: ConfigValue(v, origin="file", mode="ini") + for k, v in iniconfig["pytest"].items() + } + return None + + +def _parse_cfg_file(path: Path) -> ConfigDict | None: + """Parse a '.cfg' file (e.g. ``setup.cfg``). + + Only considered if it contains a ``[tool:pytest]`` section. + """ + iniconfig = _parse_ini_config(path) + + if "tool:pytest" in iniconfig.sections: + return { + k: ConfigValue(v, origin="file", mode="ini") + for k, v in iniconfig["tool:pytest"].items() + } + elif "pytest" in iniconfig.sections: + # If a setup.cfg contains a "[pytest]" section, we raise a failure to indicate users that + # plain "[pytest]" sections in setup.cfg files is no longer supported (#3086). + fail(CFG_PYTEST_SECTION.format(filename="setup.cfg"), pytrace=False) + return None + + +def _parse_pytest_toml(path: Path) -> ConfigDict | None: + """Parse a dedicated pytest TOML file (``pytest.toml``/``.pytest.toml``). + + Configuration is read from the ``[pytest]`` table in TOML mode. These files + are always the source of configuration, even if empty. + """ + config = _parse_toml_file(path) - if toml_config: - # TOML mode - preserve native TOML types. - return { - k: ConfigValue(v, origin="file", mode="toml") - for k, v in toml_config.items() - } + if "pytest" in config: + # TOML mode - preserve native TOML types. + return { + k: ConfigValue(v, origin="file", mode="toml") + for k, v in config["pytest"].items() # type: ignore[attr-defined] + } - elif ini_config is not None: - # INI mode - TOML supports richer data types than INI files, but we need to - # convert all scalar values to str for compatibility with the INI system. - def make_scalar(v: object) -> str | list[str]: - return v if isinstance(v, list) else str(v) + top_level_options = [ + key for key, value in config.items() if not isinstance(value, dict) + ] + if top_level_options: + raise UsageError( + f"{path}: pytest configuration must be under a " + f"[pytest] table (found top-level options: " + f"{', '.join(top_level_options)})" + ) + # "pytest.toml" files are always the source of configuration, even if empty. + return {} + + +def _parse_pyproject_toml(path: Path) -> ConfigDict | None: + """Parse a ``pyproject.toml``-style file. + + Configuration is read from ``[tool.pytest]`` (TOML mode) or + ``[tool.pytest.ini_options]`` (INI mode). + """ + config = _parse_toml_file(path) + + tool_pytest = config.get("tool", {}).get("pytest", {}) # type: ignore[attr-defined] + + # Check for toml mode config: [tool.pytest] with content outside of ini_options. + toml_config = {k: v for k, v in tool_pytest.items() if k != "ini_options"} + # Check for ini mode config: [tool.pytest.ini_options]. + ini_config = tool_pytest.get("ini_options", None) - return { - k: ConfigValue(make_scalar(v), origin="file", mode="ini") - for k, v in ini_config.items() - } + if toml_config and ini_config: + raise UsageError( + f"{path}: Cannot use both [tool.pytest] (native TOML types) and " + "[tool.pytest.ini_options] (string-based INI format) simultaneously. " + "Please use [tool.pytest] with native TOML types (recommended) " + "or [tool.pytest.ini_options] for backwards compatibility." + ) + + if toml_config: + # TOML mode - preserve native TOML types. + return { + k: ConfigValue(v, origin="file", mode="toml") + for k, v in toml_config.items() + } + + if ini_config is not None: + # INI mode - TOML supports richer data types than INI files, but we need to + # convert all scalar values to str for compatibility with the INI system. + def make_scalar(v: object) -> str | list[str]: + return v if isinstance(v, list) else str(v) + + return { + k: ConfigValue(make_scalar(v), origin="file", mode="ini") + for k, v in ini_config.items() + } return None +#: Loaders for the config files pytest discovers by name, in precedence order. +#: +#: This mapping is the single source of truth for both *which* files are +#: considered during rootdir discovery (see :func:`locate_config`) and *how* +#: each of them is parsed. +CONFIG_LOADERS: dict[str, Callable[[Path], ConfigDict | None]] = { + "pytest.toml": _parse_pytest_toml, + ".pytest.toml": _parse_pytest_toml, + "pytest.ini": _parse_pytest_ini, + ".pytest.ini": _parse_pytest_ini, + "pyproject.toml": _parse_pyproject_toml, + "tox.ini": _parse_ini_file, + "setup.cfg": _parse_cfg_file, +} + +#: Loaders for files that are not one of the names above, keyed by suffix. +#: +#: These apply to files passed explicitly via ``-c``/``--config-file``, which +#: may have an arbitrary name. ``[tool.pytest]`` is reserved for the project +#: file, so a custom TOML file reads its configuration from ``[pytest]`` +#: directly, like ``pytest.toml`` does (#14705). +CONFIG_SUFFIXES: dict[str, Callable[[Path], ConfigDict | None]] = { + ".ini": _parse_ini_file, + ".cfg": _parse_cfg_file, + ".toml": _parse_pytest_toml, +} + + +def _get_config_loader(filepath: Path) -> Callable[[Path], ConfigDict | None] | None: + """Return the loader responsible for the given path, if any. + + Dispatch on the file name first, falling back to the file suffix, so that + files with a dedicated meaning (``pytest.ini``, ``pyproject.toml``, ...) + keep their semantics while arbitrary files passed via ``-c`` are handled + generically. + """ + return CONFIG_LOADERS.get(filepath.name) or CONFIG_SUFFIXES.get(filepath.suffix) + + +def load_config_dict_from_file( + filepath: Path, +) -> ConfigDict | None: + """Load pytest configuration from the given file path, if supported. + + Return None if the file is not a supported configuration file, or does not + contain pytest configuration. + """ + loader = _get_config_loader(filepath) + if loader is None: + return None + return loader(filepath) + + def locate_config( invocation_dir: Path, args: Iterable[Path], @@ -171,15 +256,7 @@ def locate_config( and return a tuple of (rootdir, inifile, cfg-dict, ignored-config-files), where ignored-config-files is a list of config basenames found that contain pytest configuration but were ignored.""" - config_names = [ - "pytest.toml", - ".pytest.toml", - "pytest.ini", - ".pytest.ini", - "pyproject.toml", - "tox.ini", - "setup.cfg", - ] + config_names = list(CONFIG_LOADERS) args = [x for x in args if not str(x).startswith("-")] if not args: args = [invocation_dir] @@ -189,19 +266,18 @@ def locate_config( for arg in args: argpath = absolutepath(arg) for base in (argpath, *argpath.parents): - for config_name in config_names: + for index, (config_name, loader) in enumerate(CONFIG_LOADERS.items()): p = base / config_name if p.is_file(): if p.name == "pyproject.toml" and found_pyproject_toml is None: found_pyproject_toml = p - ini_config = load_config_dict_from_file(p) + ini_config = loader(p) if ini_config is not None: - index = config_names.index(config_name) for remainder in config_names[index + 1 :]: p2 = base / remainder if ( p2.is_file() - and load_config_dict_from_file(p2) is not None + and CONFIG_LOADERS[remainder](p2) is not None ): ignored_config_files.append(remainder) return base, p, ini_config, ignored_config_files @@ -313,10 +389,54 @@ def determine_setup( if inifile: inipath_ = absolutepath(inifile) + if not inipath_.exists(): + raise UsageError( + f"Config file '{inipath_}' not found. " + f"Check your '-c/--config-file' option." + ) + if inipath_.is_dir(): + raise UsageError( + f"Config file '{inipath_}' is a directory. " + f"Check your '-c/--config-file' option." + ) inipath: Path | None = inipath_ - inicfg = load_config_dict_from_file(inipath_) or {} - if rootdir_cmd_arg is None: - rootdir = inipath_.parent + if inipath_.is_file(): + if _get_config_loader(inipath_) is None: + supported = ", ".join(sorted(CONFIG_SUFFIXES)) + raise UsageError( + f"Config file '{inipath_}' has an unsupported format. " + f"Supported extensions are: {supported}." + ) + inicfg = load_config_dict_from_file(inipath_) or {} + if rootdir_cmd_arg is None: + # The config file's directory takes part in determining the + # rootdir, but must not decide it on its own: a config kept in + # a subdirectory -- or in a sibling of the directory pytest was + # invoked from -- would otherwise drag the rootdir along with + # it, breaking conftest discovery and node ids (#13246, #9703). + # Without test paths to anchor against, the invocation + # directory plays their part. + candidates = ( + [inipath_.parent, *dirs] + if dirs + else [inipath_.parent, invocation_dir] + ) + rootdir = get_common_ancestor(invocation_dir, candidates) + if is_fs_root(rootdir): + # Config file and test paths live in unrelated trees; keep + # the config file's directory rather than the whole disk. + rootdir = inipath_.parent + else: + # The config file exists but is not a regular file, as with + # ``--config-file=/dev/null`` to explicitly load no configuration. + # Such a path says nothing about where the project lives, so the + # rootdir must not be derived from it -- otherwise the cache plugin + # tries to write to e.g. ``/dev/.pytest_cache`` (#11502). + inicfg = {} + if rootdir_cmd_arg is None: + rootdir = get_common_ancestor(invocation_dir, dirs) + if is_fs_root(rootdir): + rootdir = invocation_dir else: ancestor = get_common_ancestor(invocation_dir, dirs) rootdir, inipath, inicfg, ignored_config_files = locate_config( diff --git a/testing/test_config.py b/testing/test_config.py index 5d19627bca7..56ab68afd0a 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -763,11 +763,13 @@ def pytest_addoption(parser): config = pytester.parseconfig("--config-file", "custom_tool_pytest_section.cfg") assert config.getini("custom") == "1" + # Custom TOML files read their configuration from [pytest] directly; + # [tool.pytest] is reserved for pyproject.toml (#14705). pytester.makefile( ".toml", custom=""" - [tool.pytest.ini_options] - custom = 1 + [pytest] + custom = "1" value = [ ] # this is here on purpose, as it makes this an invalid '.ini' file """, @@ -777,6 +779,53 @@ def pytest_addoption(parser): config = pytester.parseconfig("--config-file", "custom.toml") assert config.getini("custom") == "1" + @pytest.mark.parametrize( + "name", ["missing.ini", "missing.in", "missing.toml", "missing"] + ) + def test_explicitly_specified_config_file_missing( + self, pytester: Pytester, name: str + ) -> None: + """A nonexistent -c path is a UsageError, whatever its extension (#14716). + + Previously this either silently proceeded with an empty configuration + (unrecognized extension) or crashed with a raw FileNotFoundError + traceback (recognized extension). + """ + with pytest.raises(UsageError, match=r"Config file .* not found"): + pytester.parseconfig("-c", name) + + def test_explicitly_specified_config_file_unsupported_format( + self, pytester: Pytester + ) -> None: + """An existing -c path with an unsupported extension is a UsageError (#14716).""" + pytester.makefile(".in", config="[pytest]\naddopts = -v\n") + with pytest.raises(UsageError, match="unsupported format"): + pytester.parseconfig("-c", "config.in") + + def test_explicitly_specified_config_file_is_a_directory( + self, pytester: Pytester + ) -> None: + """A directory passed to -c is a UsageError rather than a confusing no-op.""" + pytester.mkdir("somedir") + with pytest.raises(UsageError, match="is a directory"): + pytester.parseconfig("-c", "somedir") + + @pytest.mark.skipif( + sys.platform.startswith("win32"), reason="requires a POSIX null device" + ) + def test_explicitly_specified_config_file_not_a_regular_file( + self, pytester: Pytester + ) -> None: + """``--config-file=/dev/null`` loads no config and does not set rootdir to /dev. + + Deriving the rootdir from a character device made the cache plugin try to + write to ``/dev/.pytest_cache`` (#11502). + """ + pytester.makepyfile(test_it="def test(): pass") + config = pytester.parseconfig("--config-file", os.devnull, str(pytester.path)) + assert config.rootpath == pytester.path + assert config.inipath == Path(os.devnull) + def test_absolute_win32_path(self, pytester: Pytester) -> None: temp_ini_file = pytester.makeini("[pytest]") from os.path import normpath @@ -2281,6 +2330,182 @@ def test_explicit_config_file_sets_rootdir( assert rootpath == tmp_path assert found_inipath == inipath + def test_config_file_in_subdir_keeps_project_rootdir( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``-c config/pytest.ini`` must not move the rootdir into ``config/``. + + Doing so broke conftest discovery and node ids (#13246, #9703). The + config file's directory takes part in the rootdir decision but does not + make it alone. + """ + config_dir = tmp_path / "config" + config_dir.mkdir() + inipath = config_dir / "pytest.ini" + inipath.touch() + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + monkeypatch.chdir(tmp_path) + + # With test paths: common ancestor of the config dir and the paths. + rootpath, found_inipath, *_ = determine_setup( + inifile=str(inipath), + override_ini=None, + args=[str(tests_dir)], + rootdir_cmd_arg=None, + invocation_dir=tmp_path, + ) + assert rootpath == tmp_path + assert found_inipath == inipath + + # Without test paths the invocation dir stands in for them. + rootpath, _, *_ = determine_setup( + inifile=str(inipath), + override_ini=None, + args=[], + rootdir_cmd_arg=None, + invocation_dir=tmp_path, + ) + assert rootpath == tmp_path + + def test_config_file_sibling_of_invocation_dir( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Invoking from a subdir with a sibling config dir roots at their ancestor.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + inipath = config_dir / "pytest.ini" + inipath.touch() + sub = tmp_path / "sub" + sub.mkdir() + sub_tests = sub / "tests" + sub_tests.mkdir() + monkeypatch.chdir(sub) + + rootpath, *_ = determine_setup( + inifile=str(inipath), + override_ini=None, + args=[str(sub_tests)], + rootdir_cmd_arg=None, + invocation_dir=sub, + ) + assert rootpath == tmp_path + + def test_config_file_with_unrelated_invocation_dir(self, tmp_path: Path) -> None: + """The rootdir follows the project, not an unrelated working directory.""" + project = tmp_path / "proj" + config_dir = project / "config" + config_dir.mkdir(parents=True) + inipath = config_dir / "pytest.ini" + inipath.touch() + tests_dir = project / "tests" + tests_dir.mkdir() + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + + rootpath, *_ = determine_setup( + inifile=str(inipath), + override_ini=None, + args=[str(tests_dir)], + rootdir_cmd_arg=None, + invocation_dir=elsewhere, + ) + assert rootpath == project + + def test_config_file_and_args_in_unrelated_trees(self, tmp_path: Path) -> None: + """With no meaningful common ancestor, keep the config file's directory. + + An argument at the filesystem root leaves nothing but the root itself in + common with the config file, and the whole disk is never a useful rootdir. + """ + config_dir = tmp_path / "config" + config_dir.mkdir() + inipath = config_dir / "pytest.ini" + inipath.touch() + + rootpath, *_ = determine_setup( + inifile=str(inipath), + override_ini=None, + args=[tmp_path.anchor], + rootdir_cmd_arg=None, + invocation_dir=tmp_path, + ) + assert rootpath == config_dir + + @pytest.mark.skipif( + sys.platform.startswith("win32"), reason="requires a POSIX null device" + ) + def test_non_regular_config_file_with_unrelated_args(self, tmp_path: Path) -> None: + """A non-regular config file plus rootless args falls back to the invocation dir.""" + rootpath, *_ = determine_setup( + inifile=os.devnull, + override_ini=None, + args=[tmp_path.anchor], + rootdir_cmd_arg=None, + invocation_dir=tmp_path, + ) + assert rootpath == tmp_path + + @pytest.mark.skipif( + sys.platform.startswith("win32"), reason="requires a POSIX null device" + ) + def test_non_regular_config_file_honours_explicit_rootdir( + self, tmp_path: Path + ) -> None: + """``--rootdir`` still wins when the config file is not a regular file.""" + explicit = tmp_path / "explicit" + explicit.mkdir() + + rootpath, *_ = determine_setup( + inifile=os.devnull, + override_ini=None, + args=[str(tmp_path)], + rootdir_cmd_arg=str(explicit), + invocation_dir=tmp_path, + ) + assert rootpath == explicit + + def test_config_file_in_subdir_nodeids(self, pytester: Pytester) -> None: + """Node ids stay relative to the project root, and per-directory + conftests keep their own fixtures (#9703).""" + tests_dir = pytester.mkdir("tests") + tests_dir.joinpath("test_file1.py").write_text( + textwrap.dedent( + """\ + import pytest + + @pytest.fixture(autouse=True) + def some_fixture(): + print("Fixture called") + + def test_in_file1(): + pass + """ + ), + encoding="utf-8", + ) + tests_dir.joinpath("test_file2.py").write_text( + "def test_in_file2():\n pass\n", encoding="utf-8" + ) + config_dir = pytester.mkdir("config") + config_dir.joinpath("pytest.ini").write_text("[pytest]\n", encoding="utf-8") + + result = pytester.runpytest( + "-c", + "config/pytest.ini", + "-v", + "tests/test_file1.py", + "tests/test_file2.py", + ) + assert result.ret == 0 + result.stdout.fnmatch_lines( + [ + f"rootdir: {pytester.path}", + "*tests/test_file1.py::test_in_file1*", + "*tests/test_file2.py::test_in_file2*", + ] + ) + def test_with_arg_outside_cwd_without_inifile( self, tmp_path: Path, monkeypatch: MonkeyPatch ) -> None: diff --git a/testing/test_doctest.py b/testing/test_doctest.py index efa4c4fea78..a2b91bc4096 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -1613,14 +1613,16 @@ def func(): encoding="utf-8", ) - # The conftest is both the config file and at the rootdir, and the + testing.joinpath("pytest.ini").write_text("[pytest]\n", encoding="utf-8") + + # The config file sits next to the conftest at the rootdir, and the # collection argument (``xclim``) is a *parent* of the rootdir # (``xclim/testing``) -- the exact setup from #14683. result = pytester.runpytest( "--rootdir", str(testing), "--config-file", - str(testing / "conftest.py"), + str(testing / "pytest.ini"), "--doctest-modules", "xclim", ) diff --git a/testing/test_findpaths.py b/testing/test_findpaths.py index aea7b1f9a4d..8ecf93d29fa 100644 --- a/testing/test_findpaths.py +++ b/testing/test_findpaths.py @@ -73,7 +73,7 @@ def test_invalid_toml_file(self, tmp_path: Path) -> None: load_config_dict_from_file(fn) def test_custom_toml_file(self, tmp_path: Path) -> None: - """.toml files without [tool.pytest] are not considered for configuration.""" + """Custom .toml files without a [pytest] table hold no configuration.""" fn = tmp_path / "myconfig.toml" fn.write_text( dedent( @@ -84,12 +84,46 @@ def test_custom_toml_file(self, tmp_path: Path) -> None: ), encoding="utf-8", ) - assert load_config_dict_from_file(fn) is None + assert load_config_dict_from_file(fn) == {} - def test_valid_toml_file(self, tmp_path: Path) -> None: - """.toml files with [tool.pytest.ini_options] are read correctly, including changing - data types to str/list for compatibility with other configuration options.""" + def test_custom_toml_file_reads_pytest_table(self, tmp_path: Path) -> None: + """Custom .toml files read native configuration from [pytest] (#14705).""" + fn = tmp_path / "myconfig.toml" + fn.write_text( + dedent( + """ + [pytest] + xfail_strict = true + testpaths = ["tests", "integration"] + """ + ), + encoding="utf-8", + ) + assert load_config_dict_from_file(fn) == { + "xfail_strict": ConfigValue(True, origin="file", mode="toml"), + "testpaths": ConfigValue( + ["tests", "integration"], origin="file", mode="toml" + ), + } + + def test_custom_toml_file_ignores_tool_pytest(self, tmp_path: Path) -> None: + """[tool.pytest] is reserved for pyproject.toml, not custom files (#14705).""" fn = tmp_path / "myconfig.toml" + fn.write_text( + dedent( + """ + [tool.pytest] + xfail_strict = true + """ + ), + encoding="utf-8", + ) + assert load_config_dict_from_file(fn) == {} + + def test_valid_toml_file(self, tmp_path: Path) -> None: + """pyproject.toml files with [tool.pytest.ini_options] are read correctly, including + changing data types to str/list for compatibility with other configuration options.""" + fn = tmp_path / "pyproject.toml" fn.write_text( dedent( """