From 4cda2e5b09ef6d4e7cb7f0e2fe3243653fdb844c Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Thu, 6 Aug 2026 05:31:57 +0200 Subject: [PATCH] fix: the config file's directory no longer decides the rootdir alone Passing -c/--config-file set the rootdir to the config file's parent directory. A config kept in a subdirectory -- the common `-c config/pytest.ini` layout -- therefore moved the rootdir into that subdirectory, which broke conftest discovery and node ids: conftests collected an empty path prefix, so same-named fixtures from sibling directories shadowed each other and node ids came out as `config::test2` instead of `tests/tests2/test2.py::test2`. The config file's location is evidence about where the project lives, not a decision on its own. Take the common ancestor of the config file's directory and the collected test paths instead, falling back to the invocation directory in place of the test paths when none were given. If the two live in unrelated trees the ancestor degrades to the filesystem root, in which case the config file's directory is kept, as before. This covers cases neither of the two open proposals handled alone: scenario before #14454 #14579 now -c config/pytest.ini (no args) config/ ok config/ ok -c config/pytest.ini tests/ config/ ok ok ok sibling config, invoked from a subdirectory config/ sub/ ok ok unrelated working directory config/ unrel. ok ok #14454 (@EternalRights) found the root cause and drove the design discussion; its `invocation_dir` rule is right for the no-args case but follows the working directory even when that is unrelated to the project. #14579 (@hariharan077) contributed the common-ancestor form, which is most of the answer but leaves the no-args case unfixed. Neither is needed once the ancestor also considers the invocation directory, and no ambiguity warning is required because the ancestor is derived rather than guessed. This is a breaking change: it alters documented behaviour, so doc/en/reference/customize.rst is updated along with it, and the changelog entries are filed as breaking rather than as bugfixes. Closes #13246 Closes #9703 Co-Authored-By: Claude Opus 5 (1M context) --- changelog/13246.breaking.rst | 1 + changelog/9703.breaking.rst | 1 + doc/en/reference/customize.rst | 10 ++- src/_pytest/config/findpaths.py | 18 +++- testing/test_config.py | 143 ++++++++++++++++++++++++++++++++ 5 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 changelog/13246.breaking.rst create mode 100644 changelog/9703.breaking.rst diff --git a/changelog/13246.breaking.rst b/changelog/13246.breaking.rst new file mode 100644 index 00000000000..e36d0093c45 --- /dev/null +++ b/changelog/13246.breaking.rst @@ -0,0 +1 @@ +The directory of a configuration file passed via :option:`-c` no longer determines the ``rootdir`` on its own: the ``rootdir`` is now the common ancestor of that directory and the collected test paths, falling back to the invocation directory when no paths are given. Keeping the configuration in a subdirectory -- the common ``-c config/pytest.ini`` layout -- previously moved the ``rootdir`` into that subdirectory, which broke ``conftest.py`` discovery and node IDs. diff --git a/changelog/9703.breaking.rst b/changelog/9703.breaking.rst new file mode 100644 index 00000000000..e36d0093c45 --- /dev/null +++ b/changelog/9703.breaking.rst @@ -0,0 +1 @@ +The directory of a configuration file passed via :option:`-c` no longer determines the ``rootdir`` on its own: the ``rootdir`` is now the common ancestor of that directory and the collected test paths, falling back to the invocation directory when no paths are given. Keeping the configuration in a subdirectory -- the common ``-c config/pytest.ini`` layout -- previously moved the ``rootdir`` into that subdirectory, which broke ``conftest.py`` discovery and node IDs. diff --git a/doc/en/reference/customize.rst b/doc/en/reference/customize.rst index 7a4030d081a..57c4ece447f 100644 --- a/doc/en/reference/customize.rst +++ b/doc/en/reference/customize.rst @@ -187,7 +187,15 @@ Finding the ``rootdir`` Here is the algorithm which finds the rootdir from ``args``: -- If :option:`-c` is passed in the command-line, use that as configuration file, and its directory as ``rootdir``. +- If :option:`-c` is passed in the command-line, use that as configuration file, and the common ancestor + of its directory and the ``args`` that are recognised as existing paths as ``rootdir``. When no such + paths are given, the directory pytest was invoked from takes their place; when the configuration file + and the paths share no ancestor other than the root of the file system, the configuration file's + directory is used. + + .. versionchanged:: 10.0 + The configuration file's directory used to become the ``rootdir`` on its own, which moved the + ``rootdir`` into e.g. ``config/`` for the common ``-c config/pytest.ini`` layout. - Determine the common ancestor directory for the specified ``args`` that are recognised as paths that exist in the file system. If no such paths are diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index 337e279cdc3..62958a13420 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -450,7 +450,23 @@ def determine_setup( inicfg = {} if rootdir_cmd_arg is None: if inipath_.is_file(): - rootdir = inipath_.parent + # 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: # Such a path also says nothing about where the project lives, # so the rootdir must not be derived from it -- otherwise diff --git a/testing/test_config.py b/testing/test_config.py index 282e66409f7..a6ee02a31cf 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -2514,6 +2514,149 @@ def test_non_regular_config_file_honours_explicit_rootdir( ) assert rootpath == explicit + 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 + + 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: