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: