From 5ff1af4e99e2a7135b25acaf2fcdfdc3d97367be Mon Sep 17 00:00:00 2001 From: Samuel Gaist Date: Tue, 7 Oct 2025 21:09:34 +0200 Subject: [PATCH 1/3] feat: add warning when pytest.ini and pyproject.toml are present The behaviour does not change however users will be notified that pytest.ini is selected over pyproject.toml and any configuration related to pytest it may contain. --- AUTHORS | 1 + changelog/13330.improvement.rst | 3 +++ src/_pytest/terminal.py | 9 +++++++- testing/test_terminal.py | 41 +++++++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 changelog/13330.improvement.rst diff --git a/AUTHORS b/AUTHORS index cb8420e4a02..9539e8dc4f4 100644 --- a/AUTHORS +++ b/AUTHORS @@ -403,6 +403,7 @@ Sadra Barikbin Saiprasad Kale Samuel Colvin Samuel Dion-Girardeau +Samuel Gaist Samuel Jirovec Samuel Searles-Bryant Samuel Therrien (Avasam) diff --git a/changelog/13330.improvement.rst b/changelog/13330.improvement.rst new file mode 100644 index 00000000000..226b908881d --- /dev/null +++ b/changelog/13330.improvement.rst @@ -0,0 +1,3 @@ +Having both ``pytest.ini`` and ``pyproject.toml`` will now print a warning to make it clearer to the user that the former takes precedence over the latter. + +-- by :user:`sgaist` diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 71ea4b1bab9..f752e2cccc8 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -879,7 +879,14 @@ def pytest_report_header(self, config: Config) -> list[str]: result = [f"rootdir: {config.rootpath}"] if config.inipath: - result.append("configfile: " + bestrelpath(config.rootpath, config.inipath)) + warning = "" + if config.inipath.name in ["pytest.ini", ".pytest.ini"]: + pyproject = config.rootpath / "pyproject.toml" + if pyproject.exists(): + warning = " (WARNING: ignoring pytest config in pyproject.toml!)" + result.append( + "configfile: " + bestrelpath(config.rootpath, config.inipath) + warning + ) if config.args_source == Config.ArgsSource.TESTPATHS: testpaths: list[str] = config.getini("testpaths") diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 44ce7aff563..f530135cc88 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -2893,6 +2893,47 @@ def test_format_trimmed() -> None: assert _format_trimmed(" ({}) ", msg, len(msg) + 3) == " (unconditional ...) " +def test_warning_when_init_trumps_pyproject_toml( + pytester: Pytester, monkeypatch: MonkeyPatch +) -> None: + """Regression test for #7814.""" + tests = pytester.path.joinpath("tests") + tests.mkdir() + pytester.makepyprojecttoml( + f""" + [tool.pytest.ini_options] + testpaths = ['{tests}'] + """ + ) + pytester.makefile(".ini", pytest="") + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!)", + ] + ) + + +def test_no_warning_on_terminal_with_a_single_config_file( + pytester: Pytester, monkeypatch: MonkeyPatch +) -> None: + """Regression test for #7814.""" + tests = pytester.path.joinpath("tests") + tests.mkdir() + pytester.makepyprojecttoml( + f""" + [tool.pytest.ini_options] + testpaths = ['{tests}'] + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "configfile: pyproject.toml", + ] + ) + + class TestFineGrainedTestCase: DEFAULT_FILE_CONTENTS = """ import pytest From 6eab536e0e19d74c3f88894cf6055fb8c025009b Mon Sep 17 00:00:00 2001 From: Samuel Gaist Date: Tue, 7 Oct 2025 23:13:06 +0200 Subject: [PATCH 2/3] refactor: move warning info into Config Doing so, Terminal does not need to know anything about the configuration files. --- src/_pytest/config/__init__.py | 11 ++++++++++- src/_pytest/config/findpaths.py | 27 +++++++++++++++++++-------- src/_pytest/terminal.py | 6 ++---- testing/test_config.py | 24 ++++++++++++------------ testing/test_terminal.py | 21 +++++++++++++++++++++ 5 files changed, 64 insertions(+), 25 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 38fb1ee6d27..037eb867595 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1107,6 +1107,14 @@ def inipath(self) -> pathlib.Path | None: """ return self._inipath + @property + def should_warn(self) -> bool: + """Whether a warning should be emitted for the configuration. + + .. versionadded:: 8.6 + """ + return self._should_warn + def add_cleanup(self, func: Callable[[], None]) -> None: """Add a function to be called when the config object gets out of use (usually coinciding with pytest_unconfigure). @@ -1242,7 +1250,7 @@ def _initini(self, args: Sequence[str]) -> None: ns, unknown_args = self._parser.parse_known_and_unknown_args( args, namespace=copy.copy(self.option) ) - rootpath, inipath, inicfg = determine_setup( + rootpath, inipath, inicfg, should_warn = determine_setup( inifile=ns.inifilename, args=ns.file_or_dir + unknown_args, rootdir_cmd_arg=ns.rootdir or None, @@ -1250,6 +1258,7 @@ def _initini(self, args: Sequence[str]) -> None: ) self._rootpath = rootpath self._inipath = inipath + self._should_warn = should_warn self.inicfg = inicfg self._parser.extra_info["rootdir"] = str(self.rootpath) self._parser.extra_info["inifile"] = str(self.inipath) diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index 763803adbe7..ebd2587f991 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -91,7 +91,7 @@ def make_scalar(v: object) -> str | list[str]: def locate_config( invocation_dir: Path, args: Iterable[Path], -) -> tuple[Path | None, Path | None, ConfigDict]: +) -> tuple[Path | None, Path | None, ConfigDict, bool]: """Search in the list of arguments for a valid ini-file for pytest, and return a tuple of (rootdir, inifile, cfg-dict).""" config_names = [ @@ -105,6 +105,7 @@ def locate_config( if not args: args = [invocation_dir] found_pyproject_toml: Path | None = None + for arg in args: argpath = absolutepath(arg) for base in (argpath, *argpath.parents): @@ -115,10 +116,17 @@ def locate_config( found_pyproject_toml = p ini_config = load_config_dict_from_file(p) if ini_config is not None: - return base, p, ini_config + should_warn = False + if p.name in ["pytest.ini", ".pytest.ini"]: + pyproject = base / "pyproject.toml" + if pyproject.is_file(): + should_warn = ( + load_config_dict_from_file(pyproject) is not None + ) + return base, p, ini_config, should_warn if found_pyproject_toml is not None: - return found_pyproject_toml.parent, found_pyproject_toml, {} - return None, None, {} + return found_pyproject_toml.parent, found_pyproject_toml, {}, False + return None, None, {}, False def get_common_ancestor( @@ -178,7 +186,7 @@ def determine_setup( args: Sequence[str], rootdir_cmd_arg: str | None, invocation_dir: Path, -) -> tuple[Path, Path | None, ConfigDict]: +) -> tuple[Path, Path | None, ConfigDict, bool]: """Determine the rootdir, inifile and ini configuration values from the command line arguments. @@ -193,6 +201,7 @@ def determine_setup( """ rootdir = None dirs = get_dirs_from_args(args) + should_warn = False if inifile: inipath_ = absolutepath(inifile) inipath: Path | None = inipath_ @@ -201,7 +210,9 @@ def determine_setup( rootdir = inipath_.parent else: ancestor = get_common_ancestor(invocation_dir, dirs) - rootdir, inipath, inicfg = locate_config(invocation_dir, [ancestor]) + rootdir, inipath, inicfg, should_warn = locate_config( + invocation_dir, [ancestor] + ) if rootdir is None and rootdir_cmd_arg is None: for possible_rootdir in (ancestor, *ancestor.parents): if (possible_rootdir / "setup.py").is_file(): @@ -209,7 +220,7 @@ def determine_setup( break else: if dirs != [ancestor]: - rootdir, inipath, inicfg = locate_config(invocation_dir, dirs) + rootdir, inipath, inicfg, _ = locate_config(invocation_dir, dirs) if rootdir is None: rootdir = get_common_ancestor( invocation_dir, [invocation_dir, ancestor] @@ -223,7 +234,7 @@ def determine_setup( f"Directory '{rootdir}' not found. Check your '--rootdir' option." ) assert rootdir is not None - return rootdir, inipath, inicfg or {} + return rootdir, inipath, inicfg or {}, should_warn def is_fs_root(p: Path) -> bool: diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index f752e2cccc8..e3a8505039f 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -880,10 +880,8 @@ def pytest_report_header(self, config: Config) -> list[str]: if config.inipath: warning = "" - if config.inipath.name in ["pytest.ini", ".pytest.ini"]: - pyproject = config.rootpath / "pyproject.toml" - if pyproject.exists(): - warning = " (WARNING: ignoring pytest config in pyproject.toml!)" + if config.should_warn: + warning = " (WARNING: ignoring pytest config in pyproject.toml!)" result.append( "configfile: " + bestrelpath(config.rootpath, config.inipath) + warning ) diff --git a/testing/test_config.py b/testing/test_config.py index f2cc139dffa..b3f55e7d25e 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -56,7 +56,7 @@ def test_getcfg_and_config( ), encoding="utf-8", ) - _, _, cfg = locate_config(Path.cwd(), [sub]) + _, _, cfg, _ = locate_config(Path.cwd(), [sub]) assert cfg["name"] == "value" config = pytester.parseconfigure(str(sub)) assert config.inicfg["name"] == "value" @@ -1635,7 +1635,7 @@ def test_with_ini(self, tmp_path: Path, name: str, contents: str) -> None: b = a / "b" b.mkdir() for args in ([str(tmp_path)], [str(a)], [str(b)]): - rootpath, parsed_inipath, _ = determine_setup( + rootpath, parsed_inipath, *_ = determine_setup( inifile=None, args=args, rootdir_cmd_arg=None, @@ -1643,7 +1643,7 @@ def test_with_ini(self, tmp_path: Path, name: str, contents: str) -> None: ) assert rootpath == tmp_path assert parsed_inipath == inipath - rootpath, parsed_inipath, ini_config = determine_setup( + rootpath, parsed_inipath, ini_config, _ = determine_setup( inifile=None, args=[str(b), str(a)], rootdir_cmd_arg=None, @@ -1660,7 +1660,7 @@ def test_pytestini_overrides_empty_other(self, tmp_path: Path, name: str) -> Non a = tmp_path / "a" a.mkdir() (a / name).touch() - rootpath, parsed_inipath, _ = determine_setup( + rootpath, parsed_inipath, *_ = determine_setup( inifile=None, args=[str(a)], rootdir_cmd_arg=None, @@ -1674,7 +1674,7 @@ def test_setuppy_fallback(self, tmp_path: Path) -> None: a.mkdir() (a / "setup.cfg").touch() (tmp_path / "setup.py").touch() - rootpath, inipath, inicfg = determine_setup( + rootpath, inipath, inicfg, _ = determine_setup( inifile=None, args=[str(a)], rootdir_cmd_arg=None, @@ -1686,7 +1686,7 @@ def test_setuppy_fallback(self, tmp_path: Path) -> None: def test_nothing(self, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) - rootpath, inipath, inicfg = determine_setup( + rootpath, inipath, inicfg, _ = determine_setup( inifile=None, args=[str(tmp_path)], rootdir_cmd_arg=None, @@ -1713,7 +1713,7 @@ def test_with_specific_inifile( p = tmp_path / name p.touch() p.write_text(contents, encoding="utf-8") - rootpath, inipath, ini_config = determine_setup( + rootpath, inipath, ini_config, _ = determine_setup( inifile=str(p), args=[str(tmp_path)], rootdir_cmd_arg=None, @@ -1761,7 +1761,7 @@ def test_with_arg_outside_cwd_without_inifile( a.mkdir() b = tmp_path / "b" b.mkdir() - rootpath, inifile, _ = determine_setup( + rootpath, inifile, *_ = determine_setup( inifile=None, args=[str(a), str(b)], rootdir_cmd_arg=None, @@ -1777,7 +1777,7 @@ def test_with_arg_outside_cwd_with_inifile(self, tmp_path: Path) -> None: b.mkdir() inipath = a / "pytest.ini" inipath.touch() - rootpath, parsed_inipath, _ = determine_setup( + rootpath, parsed_inipath, *_ = determine_setup( inifile=None, args=[str(a), str(b)], rootdir_cmd_arg=None, @@ -1791,7 +1791,7 @@ def test_with_non_dir_arg( self, dirs: Sequence[str], tmp_path: Path, monkeypatch: MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) - rootpath, inipath, _ = determine_setup( + rootpath, inipath, *_ = determine_setup( inifile=None, args=dirs, rootdir_cmd_arg=None, @@ -1807,7 +1807,7 @@ def test_with_existing_file_in_subdir( a.mkdir() (a / "exists").touch() monkeypatch.chdir(tmp_path) - rootpath, inipath, _ = determine_setup( + rootpath, inipath, *_ = determine_setup( inifile=None, args=["a/exist"], rootdir_cmd_arg=None, @@ -1826,7 +1826,7 @@ def test_with_config_also_in_parent_directory( (tmp_path / "myproject" / "tests").mkdir() monkeypatch.chdir(tmp_path / "myproject") - rootpath, inipath, _ = determine_setup( + rootpath, inipath, *_ = determine_setup( inifile=None, args=["tests/"], rootdir_cmd_arg=None, diff --git a/testing/test_terminal.py b/testing/test_terminal.py index f530135cc88..4f9b1b8d776 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -2914,6 +2914,27 @@ def test_warning_when_init_trumps_pyproject_toml( ) +def test_no_warning_when_init_but_pyproject_toml_has_no_entry( + pytester: Pytester, monkeypatch: MonkeyPatch +) -> None: + """Regression test for #7814.""" + tests = pytester.path.joinpath("tests") + tests.mkdir() + pytester.makepyprojecttoml( + f""" + [tool] + testpaths = ['{tests}'] + """ + ) + pytester.makefile(".ini", pytest="") + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "configfile: pytest.ini", + ] + ) + + def test_no_warning_on_terminal_with_a_single_config_file( pytester: Pytester, monkeypatch: MonkeyPatch ) -> None: From 9bdbec5c60c1357211b986cfdf09d5e6d9b51839 Mon Sep 17 00:00:00 2001 From: Samuel Gaist Date: Tue, 7 Oct 2025 23:44:13 +0200 Subject: [PATCH 3/3] refactor: simplify ini check Do it the other way around, check if the file suffix is ini rather than listing the possible names. --- src/_pytest/config/findpaths.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index ebd2587f991..c12a1516b06 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -117,7 +117,7 @@ def locate_config( ini_config = load_config_dict_from_file(p) if ini_config is not None: should_warn = False - if p.name in ["pytest.ini", ".pytest.ini"]: + if ".ini" in p.suffixes: pyproject = base / "pyproject.toml" if pyproject.is_file(): should_warn = (