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/11502.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The ``rootdir`` is no longer derived from a configuration file that is not a regular file, such as ``--config-file=/dev/null``.
1 change: 1 addition & 0 deletions changelog/13246.bugfix.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; it is now the common ancestor of that directory and the collected test paths. Keeping the configuration in a subdirectory previously broke ``conftest.py`` discovery and node IDs.
1 change: 1 addition & 0 deletions changelog/14705.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TOML configuration files passed via :option:`-c` now read their configuration from the ``[pytest]`` table instead of ``[tool.pytest.ini_options]``, which remains reserved for ``pyproject.toml``.
1 change: 1 addition & 0 deletions changelog/14716.breaking.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:option:`-c` now raises a usage error for a path that does not exist, is a directory, or is in a format pytest has no loader for. Such paths were previously either ignored or reported as an unhandled ``FileNotFoundError``.
1 change: 1 addition & 0 deletions changelog/9703.bugfix.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; it is now the common ancestor of that directory and the collected test paths. Keeping the configuration in a subdirectory previously broke ``conftest.py`` discovery and node IDs.
342 changes: 231 additions & 111 deletions src/_pytest/config/findpaths.py

Large diffs are not rendered by default.

229 changes: 227 additions & 2 deletions testing/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -763,11 +763,13 @@ def pytest_addoption(parser):
config = pytester.parseconfig("--config-file", "custom_tool_pytest_section.cfg")
assert config.getini("custom") == "1"

# Custom TOML files read their configuration from [pytest] directly;
# [tool.pytest] is reserved for pyproject.toml (#14705).
pytester.makefile(
".toml",
custom="""
[tool.pytest.ini_options]
custom = 1
[pytest]
custom = "1"
value = [
] # this is here on purpose, as it makes this an invalid '.ini' file
""",
Expand All @@ -777,6 +779,53 @@ def pytest_addoption(parser):
config = pytester.parseconfig("--config-file", "custom.toml")
assert config.getini("custom") == "1"

@pytest.mark.parametrize(
"name", ["missing.ini", "missing.in", "missing.toml", "missing"]
)
def test_explicitly_specified_config_file_missing(
self, pytester: Pytester, name: str
) -> None:
"""A nonexistent -c path is a UsageError, whatever its extension (#14716).

Previously this either silently proceeded with an empty configuration
(unrecognized extension) or crashed with a raw FileNotFoundError
traceback (recognized extension).
"""
with pytest.raises(UsageError, match=r"Config file .* not found"):
pytester.parseconfig("-c", name)

def test_explicitly_specified_config_file_unsupported_format(
self, pytester: Pytester
) -> None:
"""An existing -c path with an unsupported extension is a UsageError (#14716)."""
pytester.makefile(".in", config="[pytest]\naddopts = -v\n")
with pytest.raises(UsageError, match="unsupported format"):
pytester.parseconfig("-c", "config.in")

def test_explicitly_specified_config_file_is_a_directory(
self, pytester: Pytester
) -> None:
"""A directory passed to -c is a UsageError rather than a confusing no-op."""
pytester.mkdir("somedir")
with pytest.raises(UsageError, match="is a directory"):
pytester.parseconfig("-c", "somedir")

@pytest.mark.skipif(
sys.platform.startswith("win32"), reason="requires a POSIX null device"
)
def test_explicitly_specified_config_file_not_a_regular_file(
self, pytester: Pytester
) -> None:
"""``--config-file=/dev/null`` loads no config and does not set rootdir to /dev.

Deriving the rootdir from a character device made the cache plugin try to
write to ``/dev/.pytest_cache`` (#11502).
"""
pytester.makepyfile(test_it="def test(): pass")
config = pytester.parseconfig("--config-file", os.devnull, str(pytester.path))
assert config.rootpath == pytester.path
assert config.inipath == Path(os.devnull)

def test_absolute_win32_path(self, pytester: Pytester) -> None:
temp_ini_file = pytester.makeini("[pytest]")
from os.path import normpath
Expand Down Expand Up @@ -2281,6 +2330,182 @@ def test_explicit_config_file_sets_rootdir(
assert rootpath == tmp_path
assert found_inipath == inipath

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

@pytest.mark.skipif(
sys.platform.startswith("win32"), reason="requires a POSIX null device"
)
def test_non_regular_config_file_with_unrelated_args(self, tmp_path: Path) -> None:
"""A non-regular config file plus rootless args falls back to the invocation dir."""
rootpath, *_ = determine_setup(
inifile=os.devnull,
override_ini=None,
args=[tmp_path.anchor],
rootdir_cmd_arg=None,
invocation_dir=tmp_path,
)
assert rootpath == tmp_path

@pytest.mark.skipif(
sys.platform.startswith("win32"), reason="requires a POSIX null device"
)
def test_non_regular_config_file_honours_explicit_rootdir(
self, tmp_path: Path
) -> None:
"""``--rootdir`` still wins when the config file is not a regular file."""
explicit = tmp_path / "explicit"
explicit.mkdir()

rootpath, *_ = determine_setup(
inifile=os.devnull,
override_ini=None,
args=[str(tmp_path)],
rootdir_cmd_arg=str(explicit),
invocation_dir=tmp_path,
)
assert rootpath == explicit

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
6 changes: 4 additions & 2 deletions testing/test_doctest.py
Original file line number Diff line number Diff line change
Expand Up @@ -1613,14 +1613,16 @@ def func():
encoding="utf-8",
)

# The conftest is both the config file and at the rootdir, and the
testing.joinpath("pytest.ini").write_text("[pytest]\n", encoding="utf-8")

# The config file sits next to the conftest at the rootdir, and the
# collection argument (``xclim``) is a *parent* of the rootdir
# (``xclim/testing``) -- the exact setup from #14683.
result = pytester.runpytest(
"--rootdir",
str(testing),
"--config-file",
str(testing / "conftest.py"),
str(testing / "pytest.ini"),
"--doctest-modules",
"xclim",
)
Expand Down
44 changes: 39 additions & 5 deletions testing/test_findpaths.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def test_invalid_toml_file(self, tmp_path: Path) -> None:
load_config_dict_from_file(fn)

def test_custom_toml_file(self, tmp_path: Path) -> None:
""".toml files without [tool.pytest] are not considered for configuration."""
"""Custom .toml files without a [pytest] table hold no configuration."""
fn = tmp_path / "myconfig.toml"
fn.write_text(
dedent(
Expand All @@ -84,12 +84,46 @@ def test_custom_toml_file(self, tmp_path: Path) -> None:
),
encoding="utf-8",
)
assert load_config_dict_from_file(fn) is None
assert load_config_dict_from_file(fn) == {}

def test_valid_toml_file(self, tmp_path: Path) -> None:
""".toml files with [tool.pytest.ini_options] are read correctly, including changing
data types to str/list for compatibility with other configuration options."""
def test_custom_toml_file_reads_pytest_table(self, tmp_path: Path) -> None:
"""Custom .toml files read native configuration from [pytest] (#14705)."""
fn = tmp_path / "myconfig.toml"
fn.write_text(
dedent(
"""
[pytest]
xfail_strict = true
testpaths = ["tests", "integration"]
"""
),
encoding="utf-8",
)
assert load_config_dict_from_file(fn) == {
"xfail_strict": ConfigValue(True, origin="file", mode="toml"),
"testpaths": ConfigValue(
["tests", "integration"], origin="file", mode="toml"
),
}

def test_custom_toml_file_ignores_tool_pytest(self, tmp_path: Path) -> None:
"""[tool.pytest] is reserved for pyproject.toml, not custom files (#14705)."""
fn = tmp_path / "myconfig.toml"
fn.write_text(
dedent(
"""
[tool.pytest]
xfail_strict = true
"""
),
encoding="utf-8",
)
assert load_config_dict_from_file(fn) == {}

def test_valid_toml_file(self, tmp_path: Path) -> None:
"""pyproject.toml files with [tool.pytest.ini_options] are read correctly, including
changing data types to str/list for compatibility with other configuration options."""
fn = tmp_path / "pyproject.toml"
fn.write_text(
dedent(
"""
Expand Down