Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/13246.breaking.rst
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions changelog/9703.breaking.rst
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 9 additions & 1 deletion doc/en/reference/customize.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion src/_pytest/config/findpaths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
143 changes: 143 additions & 0 deletions testing/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading