diff --git a/AUTHORS b/AUTHORS index 33ee644ea68..3b16051ecc7 100644 --- a/AUTHORS +++ b/AUTHORS @@ -507,6 +507,7 @@ Vlad Radziuk Vladyslav Rachek Volodymyr Kochetkov Volodymyr Piskun +Wan Xiankai Warren Markham Wei Lin Wil Cooley diff --git a/changelog/14716.bugfix.rst b/changelog/14716.bugfix.rst new file mode 100644 index 00000000000..c68673d36b9 --- /dev/null +++ b/changelog/14716.bugfix.rst @@ -0,0 +1 @@ +Explicit ``-c``/``--config-file`` paths now raise a clean usage error when the file is missing or uses an unsupported extension. diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index 2a4bed319a9..c83cf090dba 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -43,6 +43,9 @@ class ConfigValue: ConfigDict: TypeAlias = dict[str, ConfigValue] +SUPPORTED_CONFIG_FILE_EXTENSIONS = {".cfg", ".ini", ".toml"} + + def _parse_ini_config(path: Path) -> iniconfig.IniConfig: """Parse the given generic '.ini' file using legacy IniConfig parser, returning the parsed object. @@ -313,6 +316,17 @@ def determine_setup( if inifile: inipath_ = absolutepath(inifile) + if not inipath_.is_file(): + raise UsageError( + f"Config file '{inipath_}' not found. " + "Check your '-c/--config-file' option." + ) + if inipath_.suffix not in SUPPORTED_CONFIG_FILE_EXTENSIONS: + supported = ", ".join(sorted(SUPPORTED_CONFIG_FILE_EXTENSIONS)) + raise UsageError( + f"Config file '{inipath_}' has unsupported extension " + f"{inipath_.suffix!r}. Supported extensions are: {supported}." + ) inipath: Path | None = inipath_ inicfg = load_config_dict_from_file(inipath_) or {} if rootdir_cmd_arg is None: diff --git a/testing/test_config.py b/testing/test_config.py index 9583c9131fa..70a979d54f9 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -787,6 +787,40 @@ def test_absolute_win32_path(self, pytester: Pytester) -> None: ret = pytest.main(["--config-file", temp_ini_file_norm]) assert ret == ExitCode.NO_TESTS_COLLECTED + @pytest.mark.parametrize( + ("filename", "contents", "expected_error"), + [ + pytest.param( + "custom.in", + "[pytest]", + "unsupported extension '.in'", + id="unsupported-extension", + ), + pytest.param( + "missing.ini", + None, + "not found", + id="missing-file", + ), + ], + ) + @pytest.mark.parametrize("option", ("-c", "--config-file")) + def test_explicitly_specified_invalid_config_file_errors( + self, + pytester: Pytester, + option: str, + filename: str, + contents: str | None, + expected_error: str, + ) -> None: + if contents is not None: + pytester.path.joinpath(filename).write_text(contents, encoding="utf-8") + + result = pytester.runpytest(option, filename) + + assert result.ret == ExitCode.USAGE_ERROR + result.stderr.fnmatch_lines([f"ERROR: *{expected_error}*"]) + class TestConfigAPI: def test_config_trace(self, pytester: Pytester) -> None: @@ -2058,6 +2092,38 @@ def test_with_specific_inifile( assert inipath == p assert ini_config["x"] == ConfigValue("10", origin="file", mode="ini") + @pytest.mark.parametrize( + ("filename", "contents", "expected_error"), + [ + pytest.param( + "config.in", + "[pytest]\nx=10", + "unsupported extension '.in'", + id="unsupported-extension", + ), + pytest.param("missing.ini", None, "not found", id="missing-file"), + ], + ) + def test_with_invalid_specific_inifile( + self, + tmp_path: Path, + filename: str, + contents: str | None, + expected_error: str, + ) -> None: + p = tmp_path / filename + if contents is not None: + p.write_text(contents, encoding="utf-8") + + with pytest.raises(UsageError, match=re.escape(expected_error)): + determine_setup( + inifile=str(p), + override_ini=None, + args=[str(tmp_path)], + rootdir_cmd_arg=None, + invocation_dir=Path.cwd(), + ) + def test_explicit_config_file_sets_rootdir( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: