From 7052ad24fbbf78d2f67ff6e1a57d1caf0893cdeb Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 31 Jul 2026 07:12:36 +0200 Subject: [PATCH 1/3] refactor: dispatch config file loading by filename, then suffix Knowledge about config files was spread across three places that had to be kept in sync by hand: load_config_dict_from_file dispatched on suffix with filename checks nested inside those branches, locate_config re-declared the discovery order in its own hardcoded list, and adding a format meant editing both in the right order. Introduce two tables instead. CONFIG_LOADERS maps a config file *name* to its loader and doubles as the discovery order used by locate_config, so order and parsing can no longer drift apart. CONFIG_SUFFIXES maps a *suffix* to a loader for files passed explicitly via -c/--config-file, which may be named anything. load_config_dict_from_file now looks up the name first and falls back to the suffix. The per-format rules move out of nested conditionals into named functions -- _parse_pytest_ini, _parse_ini_file, _parse_cfg_file, _parse_pytest_toml and _parse_pyproject_toml -- each documenting the rule it implements. This is a pure refactor: no test needed changing, and running load_config_dict_from_file over a matrix of every supported name crossed with present/absent/empty/malformed sections (plus unsupported and extension-less files) yields identical values, modes, origins and exceptions before and after. Two pre-existing warts are deliberately preserved rather than fixed here: the CFG_PYTEST_SECTION message still names setup.cfg for any .cfg file, and a scalar `pytest` key still raises AttributeError. This redoes the structural half of #8358 on top of current main; that PR bundled it with the abandoned setup.cfg deprecation from #3523. Co-Authored-By: Claude Opus 5 (1M context) --- src/_pytest/config/findpaths.py | 292 ++++++++++++++++++++------------ 1 file changed, 180 insertions(+), 112 deletions(-) diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index 2a4bed319a9..0eae29db8a2 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable from collections.abc import Iterable from collections.abc import Sequence from dataclasses import dataclass @@ -55,6 +56,173 @@ def _parse_ini_config(path: Path) -> iniconfig.IniConfig: raise UsageError(str(exc)) from exc +def _parse_toml_file(path: Path) -> dict[str, object]: + """Parse the given '.toml' file, returning the decoded document. + + Raise UsageError if the file cannot be parsed. + """ + if sys.version_info >= (3, 11): + import tomllib + else: + import tomli as tomllib + + toml_text = path.read_text(encoding="utf-8") + try: + return tomllib.loads(toml_text) + except tomllib.TOMLDecodeError as exc: + raise UsageError(f"{path}: {exc}") from exc + + +def _load_pytest_ini(path: Path) -> ConfigDict | None: + """Load a dedicated pytest INI file (``pytest.ini``/``.pytest.ini``). + + These files are always the source of configuration, even if they lack a + ``[pytest]`` section, in which case an empty config is returned. + """ + iniconfig = _parse_ini_config(path) + + if "pytest" in iniconfig: + return { + k: ConfigValue(v, origin="file", mode="ini") + for k, v in iniconfig["pytest"].items() + } + return {} + + +def _load_ini_file(path: Path) -> ConfigDict | None: + """Load a generic '.ini' file (e.g. ``tox.ini``). + + Only considered if it contains a ``[pytest]`` section. + """ + iniconfig = _parse_ini_config(path) + + if "pytest" in iniconfig: + return { + k: ConfigValue(v, origin="file", mode="ini") + for k, v in iniconfig["pytest"].items() + } + return None + + +def _load_cfg_file(path: Path) -> ConfigDict | None: + """Load a '.cfg' file (e.g. ``setup.cfg``). + + Only considered if it contains a ``[tool:pytest]`` section. + """ + iniconfig = _parse_ini_config(path) + + if "tool:pytest" in iniconfig.sections: + return { + k: ConfigValue(v, origin="file", mode="ini") + for k, v in iniconfig["tool:pytest"].items() + } + elif "pytest" in iniconfig.sections: + # If a setup.cfg contains a "[pytest]" section, we raise a failure to indicate users that + # plain "[pytest]" sections in setup.cfg files is no longer supported (#3086). + fail(CFG_PYTEST_SECTION.format(filename="setup.cfg"), pytrace=False) + return None + + +def _load_pytest_toml(path: Path) -> ConfigDict | None: + """Load a dedicated pytest TOML file (``pytest.toml``/``.pytest.toml``). + + Configuration is read from the ``[pytest]`` table in TOML mode. These files + are always the source of configuration, even if empty. + """ + config = _parse_toml_file(path) + + if "pytest" in config: + # TOML mode - preserve native TOML types. + return { + k: ConfigValue(v, origin="file", mode="toml") + for k, v in config["pytest"].items() # type: ignore[attr-defined] + } + + top_level_options = [ + key for key, value in config.items() if not isinstance(value, dict) + ] + if top_level_options: + raise UsageError( + f"{path}: pytest configuration must be under a " + f"[pytest] table (found top-level options: " + f"{', '.join(top_level_options)})" + ) + # "pytest.toml" files are always the source of configuration, even if empty. + return {} + + +def _load_pyproject_toml(path: Path) -> ConfigDict | None: + """Load a ``pyproject.toml``-style file. + + Configuration is read from ``[tool.pytest]`` (TOML mode) or + ``[tool.pytest.ini_options]`` (INI mode). + """ + config = _parse_toml_file(path) + + tool_pytest = config.get("tool", {}).get("pytest", {}) # type: ignore[attr-defined] + + # Check for toml mode config: [tool.pytest] with content outside of ini_options. + toml_config = {k: v for k, v in tool_pytest.items() if k != "ini_options"} + # Check for ini mode config: [tool.pytest.ini_options]. + ini_config = tool_pytest.get("ini_options", None) + + if toml_config and ini_config: + raise UsageError( + f"{path}: Cannot use both [tool.pytest] (native TOML types) and " + "[tool.pytest.ini_options] (string-based INI format) simultaneously. " + "Please use [tool.pytest] with native TOML types (recommended) " + "or [tool.pytest.ini_options] for backwards compatibility." + ) + + if toml_config: + # TOML mode - preserve native TOML types. + return { + k: ConfigValue(v, origin="file", mode="toml") + for k, v in toml_config.items() + } + + if ini_config is not None: + # INI mode - TOML supports richer data types than INI files, but we need to + # convert all scalar values to str for compatibility with the INI system. + def make_scalar(v: object) -> str | list[str]: + return v if isinstance(v, list) else str(v) + + return { + k: ConfigValue(make_scalar(v), origin="file", mode="ini") + for k, v in ini_config.items() + } + + return None + + +#: Loaders for the config files pytest discovers by name, in precedence order. +#: +#: This mapping is the single source of truth for both *which* files are +#: considered during rootdir discovery (see :func:`locate_config`) and *how* +#: each of them is parsed. +CONFIG_LOADERS_BY_NAME: dict[str, Callable[[Path], ConfigDict | None]] = { + "pytest.toml": _load_pytest_toml, + ".pytest.toml": _load_pytest_toml, + "pytest.ini": _load_pytest_ini, + ".pytest.ini": _load_pytest_ini, + "pyproject.toml": _load_pyproject_toml, + "tox.ini": _load_ini_file, + "setup.cfg": _load_cfg_file, +} + +#: Fallback loaders keyed by suffix, for files that are not one of the names +#: above. +#: +#: These apply to files passed explicitly via ``-c``/``--config-file``, which +#: may have an arbitrary name. Names win over suffixes, so files with a +#: dedicated meaning keep their semantics wherever they are passed from. +CONFIG_LOADERS_BY_SUFFIX: dict[str, Callable[[Path], ConfigDict | None]] = { + ".ini": _load_ini_file, + ".cfg": _load_cfg_file, + ".toml": _load_pyproject_toml, +} + + def load_config_dict_from_file( filepath: Path, ) -> ConfigDict | None: @@ -62,105 +230,12 @@ def load_config_dict_from_file( Return None if the file does not contain valid pytest configuration. """ - # Configuration from ini files are obtained from the [pytest] section, if present. - if filepath.suffix == ".ini": - iniconfig = _parse_ini_config(filepath) - - if "pytest" in iniconfig: - return { - k: ConfigValue(v, origin="file", mode="ini") - for k, v in iniconfig["pytest"].items() - } - else: - # "pytest.ini" files are always the source of configuration, even if empty. - if filepath.name in {"pytest.ini", ".pytest.ini"}: - return {} - - # '.cfg' files are considered if they contain a "[tool:pytest]" section. - elif filepath.suffix == ".cfg": - iniconfig = _parse_ini_config(filepath) - - if "tool:pytest" in iniconfig.sections: - return { - k: ConfigValue(v, origin="file", mode="ini") - for k, v in iniconfig["tool:pytest"].items() - } - elif "pytest" in iniconfig.sections: - # If a setup.cfg contains a "[pytest]" section, we raise a failure to indicate users that - # plain "[pytest]" sections in setup.cfg files is no longer supported (#3086). - fail(CFG_PYTEST_SECTION.format(filename="setup.cfg"), pytrace=False) - - # '.toml' files are considered if they contain a [tool.pytest] table (toml mode) - # or [tool.pytest.ini_options] table (ini mode) for pyproject.toml, - # or [pytest] table (toml mode) for pytest.toml/.pytest.toml. - elif filepath.suffix == ".toml": - if sys.version_info >= (3, 11): - import tomllib - else: - import tomli as tomllib - - toml_text = filepath.read_text(encoding="utf-8") - try: - config = tomllib.loads(toml_text) - except tomllib.TOMLDecodeError as exc: - raise UsageError(f"{filepath}: {exc}") from exc - - # pytest.toml and .pytest.toml use [pytest] table directly. - if filepath.name in ("pytest.toml", ".pytest.toml"): - if "pytest" in config: - # TOML mode - preserve native TOML types. - return { - k: ConfigValue(v, origin="file", mode="toml") - for k, v in config["pytest"].items() - } - top_level_options = [ - key for key, value in config.items() if not isinstance(value, dict) - ] - if top_level_options: - raise UsageError( - f"{filepath}: pytest configuration must be under a " - f"[pytest] table (found top-level options: " - f"{', '.join(top_level_options)})" - ) - # "pytest.toml" files are always the source of configuration, even if empty. - return {} - - # pyproject.toml uses [tool.pytest] or [tool.pytest.ini_options]. - else: - tool_pytest = config.get("tool", {}).get("pytest", {}) - - # Check for toml mode config: [tool.pytest] with content outside of ini_options. - toml_config = {k: v for k, v in tool_pytest.items() if k != "ini_options"} - # Check for ini mode config: [tool.pytest.ini_options]. - ini_config = tool_pytest.get("ini_options", None) - - if toml_config and ini_config: - raise UsageError( - f"{filepath}: Cannot use both [tool.pytest] (native TOML types) and " - "[tool.pytest.ini_options] (string-based INI format) simultaneously. " - "Please use [tool.pytest] with native TOML types (recommended) " - "or [tool.pytest.ini_options] for backwards compatibility." - ) - - if toml_config: - # TOML mode - preserve native TOML types. - return { - k: ConfigValue(v, origin="file", mode="toml") - for k, v in toml_config.items() - } - - elif ini_config is not None: - # INI mode - TOML supports richer data types than INI files, but we need to - # convert all scalar values to str for compatibility with the INI system. - def make_scalar(v: object) -> str | list[str]: - return v if isinstance(v, list) else str(v) - - return { - k: ConfigValue(make_scalar(v), origin="file", mode="ini") - for k, v in ini_config.items() - } - - return None + loader = CONFIG_LOADERS_BY_NAME.get(filepath.name) + if loader is None: + loader = CONFIG_LOADERS_BY_SUFFIX.get(filepath.suffix) + if loader is None: + return None + return loader(filepath) def locate_config( @@ -171,15 +246,7 @@ def locate_config( and return a tuple of (rootdir, inifile, cfg-dict, ignored-config-files), where ignored-config-files is a list of config basenames found that contain pytest configuration but were ignored.""" - config_names = [ - "pytest.toml", - ".pytest.toml", - "pytest.ini", - ".pytest.ini", - "pyproject.toml", - "tox.ini", - "setup.cfg", - ] + config_names = list(CONFIG_LOADERS_BY_NAME) args = [x for x in args if not str(x).startswith("-")] if not args: args = [invocation_dir] @@ -189,19 +256,20 @@ def locate_config( for arg in args: argpath = absolutepath(arg) for base in (argpath, *argpath.parents): - for config_name in config_names: + for index, (config_name, loader) in enumerate( + CONFIG_LOADERS_BY_NAME.items() + ): p = base / config_name if p.is_file(): if p.name == "pyproject.toml" and found_pyproject_toml is None: found_pyproject_toml = p - ini_config = load_config_dict_from_file(p) + ini_config = loader(p) if ini_config is not None: - index = config_names.index(config_name) for remainder in config_names[index + 1 :]: p2 = base / remainder if ( p2.is_file() - and load_config_dict_from_file(p2) is not None + and CONFIG_LOADERS_BY_NAME[remainder](p2) is not None ): ignored_config_files.append(remainder) return base, p, ini_config, ignored_config_files From d5ef28ce51cf883669401766cbdaf504f3de7df2 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 5 Aug 2026 23:57:01 +0200 Subject: [PATCH 2/3] fix: validate -c/--config-file and read [pytest] from custom TOML files Consolidates three open reports that all bottom out in how an explicitly given config file is located and parsed. The preceding refactor turns each of them into a small change rather than another special case in a conditional. Custom TOML files also read [pytest] (#14705) pytest documents [pytest] as the table its own TOML configuration files use, but a TOML file passed via -c was parsed with pyproject.toml semantics, so a [pytest] table in it was silently ignored. Such files now read [pytest] as well, while the [tool.pytest]/[tool.pytest.ini_options] tables they were previously restricted to keep working -- writing both styles into one file is a UsageError. Suffix dispatch makes this one loader; pyproject.toml itself is unaffected, as name dispatch wins. -c/--config-file validates its argument (#14716) Previously an invalid path either silently produced an empty configuration -- while still reporting `configfile:` in the header -- or crashed with a raw FileNotFoundError traceback, depending on its extension. Now a path that does not exist, a directory, and a regular file pytest has no loader for are each a UsageError. The supported extensions in the message are derived from CONFIG_LOADERS_BY_SUFFIX rather than restated in a separate constant. This is breaking for invocations that passed an unparsable file to -c and relied on it being ignored. The #14683 regression test did exactly that with a conftest.py and now uses a real config file; it passes --rootdir explicitly, so the config file was incidental to what it covers. The rootdir is not derived from a non-regular config file (#11502) --config-file=/dev/null is a common way to load no configuration at all. Deriving the rootdir from its parent made the rootdir /dev, and the cache plugin then warned on every run that it could not create /dev/.pytest_cache. Such a path says nothing about where the project lives, so fall back to the usual common-ancestor logic. Whether a path is parsed and whether its directory decides the rootdir are kept separate: a loader runs whenever one matches the name or suffix, so a config file that happens to be a fifo is still read, and only a path with no loader *and* no chance of holding configuration -- a character device such as /dev/null -- means "no configuration" instead of an error. The diagnoses come from #14707 (@DebadityaHait), #14723 (@wanxiankai) and #14671 (@apoorvdarshan); the implementations differ because the table-based dispatch makes each one smaller. Closes #14705 Closes #14716 Closes #11502 Co-Authored-By: Claude Opus 5 (1M context) --- changelog/11502.bugfix.rst | 1 + changelog/14705.bugfix.rst | 1 + changelog/14716.breaking.rst | 1 + src/_pytest/config/findpaths.py | 125 ++++++++++++++++++++++++++------ testing/test_config.py | 96 ++++++++++++++++++++++++ testing/test_doctest.py | 6 +- testing/test_findpaths.py | 47 +++++++++++- 7 files changed, 252 insertions(+), 25 deletions(-) create mode 100644 changelog/11502.bugfix.rst create mode 100644 changelog/14705.bugfix.rst create mode 100644 changelog/14716.breaking.rst diff --git a/changelog/11502.bugfix.rst b/changelog/11502.bugfix.rst new file mode 100644 index 00000000000..49e2b7e3b0e --- /dev/null +++ b/changelog/11502.bugfix.rst @@ -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``. diff --git a/changelog/14705.bugfix.rst b/changelog/14705.bugfix.rst new file mode 100644 index 00000000000..196d33d2dee --- /dev/null +++ b/changelog/14705.bugfix.rst @@ -0,0 +1 @@ +TOML configuration files passed via :option:`-c` now also read their configuration from the ``[pytest]`` table, the one pytest's own configuration files use. The ``[tool.pytest]`` and ``[tool.pytest.ini_options]`` tables such files were previously restricted to keep working; using both styles in one file is an error. diff --git a/changelog/14716.breaking.rst b/changelog/14716.breaking.rst new file mode 100644 index 00000000000..58aca2c6b96 --- /dev/null +++ b/changelog/14716.breaking.rst @@ -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``. diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index 0eae29db8a2..2f00d2a0458 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -123,14 +123,15 @@ def _load_cfg_file(path: Path) -> ConfigDict | None: return None -def _load_pytest_toml(path: Path) -> ConfigDict | None: - """Load a dedicated pytest TOML file (``pytest.toml``/``.pytest.toml``). +def _config_from_pytest_table( + path: Path, config: dict[str, object] +) -> ConfigDict | None: + """Return the configuration in the ``[pytest]`` table of a parsed TOML + document, or None if it has none. - Configuration is read from the ``[pytest]`` table in TOML mode. These files - are always the source of configuration, even if empty. + Raise UsageError for options written outside of any table, which is the + usual way of getting the table wrong. """ - config = _parse_toml_file(path) - if "pytest" in config: # TOML mode - preserve native TOML types. return { @@ -147,18 +148,14 @@ def _load_pytest_toml(path: Path) -> ConfigDict | None: f"[pytest] table (found top-level options: " f"{', '.join(top_level_options)})" ) - # "pytest.toml" files are always the source of configuration, even if empty. - return {} - - -def _load_pyproject_toml(path: Path) -> ConfigDict | None: - """Load a ``pyproject.toml``-style file. + return None - Configuration is read from ``[tool.pytest]`` (TOML mode) or - ``[tool.pytest.ini_options]`` (INI mode). - """ - config = _parse_toml_file(path) +def _config_from_tool_pytest( + path: Path, config: dict[str, object] +) -> ConfigDict | None: + """Return the configuration in the ``[tool.pytest]`` tables of a parsed + TOML document, or None if it has none.""" tool_pytest = config.get("tool", {}).get("pytest", {}) # type: ignore[attr-defined] # Check for toml mode config: [tool.pytest] with content outside of ini_options. @@ -195,6 +192,50 @@ def make_scalar(v: object) -> str | list[str]: return None +def _load_pytest_toml(path: Path) -> ConfigDict | None: + """Load a dedicated pytest TOML file (``pytest.toml``/``.pytest.toml``). + + Configuration is read from the ``[pytest]`` table in TOML mode. These files + are always the source of configuration, even if empty. + """ + config = _config_from_pytest_table(path, _parse_toml_file(path)) + return config if config is not None else {} + + +def _load_custom_toml(path: Path) -> ConfigDict | None: + """Load a TOML file with an arbitrary name, as passed via ``-c``. + + Such a file reads its configuration from ``[pytest]``, like ``pytest.toml`` + does -- the table pytest documents for its own files (#14705). The + ``pyproject.toml`` tables ``[tool.pytest]``/``[tool.pytest.ini_options]``, + which arbitrary TOML files used to be parsed with exclusively, keep + working; using both styles in one file is an error. + """ + document = _parse_toml_file(path) + + tool_pytest_config = _config_from_tool_pytest(path, document) + if tool_pytest_config is None: + return _config_from_pytest_table(path, document) + + if "pytest" in document: + raise UsageError( + f"{path}: Cannot use both [pytest] and [tool.pytest]/" + "[tool.pytest.ini_options] in the same file. Please use [pytest], " + "which is what pytest's own configuration files use; the " + "[tool.pytest] tables are meant for pyproject.toml." + ) + return tool_pytest_config + + +def _load_pyproject_toml(path: Path) -> ConfigDict | None: + """Load a ``pyproject.toml``-style file. + + Configuration is read from ``[tool.pytest]`` (TOML mode) or + ``[tool.pytest.ini_options]`` (INI mode). + """ + return _config_from_tool_pytest(path, _parse_toml_file(path)) + + #: Loaders for the config files pytest discovers by name, in precedence order. #: #: This mapping is the single source of truth for both *which* files are @@ -219,10 +260,18 @@ def make_scalar(v: object) -> str | list[str]: CONFIG_LOADERS_BY_SUFFIX: dict[str, Callable[[Path], ConfigDict | None]] = { ".ini": _load_ini_file, ".cfg": _load_cfg_file, - ".toml": _load_pyproject_toml, + ".toml": _load_custom_toml, } +def _get_config_loader(filepath: Path) -> Callable[[Path], ConfigDict | None] | None: + """Return the loader responsible for the given path, if any.""" + loader = CONFIG_LOADERS_BY_NAME.get(filepath.name) + if loader is None: + loader = CONFIG_LOADERS_BY_SUFFIX.get(filepath.suffix) + return loader + + def load_config_dict_from_file( filepath: Path, ) -> ConfigDict | None: @@ -230,9 +279,7 @@ def load_config_dict_from_file( Return None if the file does not contain valid pytest configuration. """ - loader = CONFIG_LOADERS_BY_NAME.get(filepath.name) - if loader is None: - loader = CONFIG_LOADERS_BY_SUFFIX.get(filepath.suffix) + loader = _get_config_loader(filepath) if loader is None: return None return loader(filepath) @@ -381,10 +428,44 @@ def determine_setup( if inifile: inipath_ = absolutepath(inifile) + if not inipath_.exists(): + raise UsageError( + f"Config file '{inipath_}' not found. " + f"Check your '-c/--config-file' option." + ) + if inipath_.is_dir(): + raise UsageError( + f"Config file '{inipath_}' is a directory. " + f"Check your '-c/--config-file' option." + ) inipath: Path | None = inipath_ - inicfg = load_config_dict_from_file(inipath_) or {} + loader = _get_config_loader(inipath_) + if loader is not None: + inicfg = loader(inipath_) or {} + elif inipath_.is_file(): + supported = ", ".join(sorted(CONFIG_LOADERS_BY_SUFFIX)) + raise UsageError( + f"Config file '{inipath_}' has an unsupported format. " + f"Supported extensions are: {supported}." + ) + else: + # A file pytest has no loader for is an error, but a path that is + # not a regular file to begin with cannot hold configuration at + # all: it is the way to ask for no configuration, as with + # ``--config-file=/dev/null``. + inicfg = {} if rootdir_cmd_arg is None: - rootdir = inipath_.parent + if inipath_.is_file(): + 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 + # ``--config-file=/dev/null`` roots at ``/dev`` and the cache + # plugin warns that it cannot write ``/dev/.pytest_cache`` + # (#11502). + rootdir = get_common_ancestor(invocation_dir, dirs) + if is_fs_root(rootdir): + rootdir = invocation_dir else: ancestor = get_common_ancestor(invocation_dir, dirs) rootdir, inipath, inicfg, ignored_config_files = locate_config( diff --git a/testing/test_config.py b/testing/test_config.py index 5d19627bca7..c5b4ae7b974 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -777,6 +777,69 @@ def pytest_addoption(parser): config = pytester.parseconfig("--config-file", "custom.toml") assert config.getini("custom") == "1" + # A custom TOML file also reads [pytest], the table pytest's own + # configuration files use (#14705). + pytester.makefile( + ".toml", + custom_pytest_table=""" + [pytest] + custom = "1" + value = [ + ] # this is here on purpose, as it makes this an invalid '.ini' file + """, + ) + config = pytester.parseconfig("-c", "custom_pytest_table.toml") + assert config.getini("custom") == "1" + config = pytester.parseconfig("--config-file", "custom_pytest_table.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 @@ -2281,6 +2344,39 @@ def test_explicit_config_file_sets_rootdir( assert rootpath == tmp_path assert found_inipath == inipath + @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_with_arg_outside_cwd_without_inifile( self, tmp_path: Path, monkeypatch: MonkeyPatch ) -> None: diff --git a/testing/test_doctest.py b/testing/test_doctest.py index efa4c4fea78..a2b91bc4096 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -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", ) diff --git a/testing/test_findpaths.py b/testing/test_findpaths.py index aea7b1f9a4d..cae2b2883fe 100644 --- a/testing/test_findpaths.py +++ b/testing/test_findpaths.py @@ -72,8 +72,53 @@ def test_invalid_toml_file(self, tmp_path: Path) -> None: with pytest.raises(UsageError): load_config_dict_from_file(fn) + def test_custom_toml_file_reads_pytest_table(self, tmp_path: Path) -> None: + """.toml files with an arbitrary name read [pytest], like pytest.toml (#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_with_both_table_styles(self, tmp_path: Path) -> None: + """[pytest] and [tool.pytest] in one file is ambiguous (#14705).""" + fn = tmp_path / "myconfig.toml" + fn.write_text( + dedent( + """ + [pytest] + xfail_strict = true + + [tool.pytest.ini_options] + xfail_strict = "false" + """ + ), + encoding="utf-8", + ) + with pytest.raises(UsageError, match="Cannot use both"): + load_config_dict_from_file(fn) + + def test_custom_toml_file_with_top_level_options(self, tmp_path: Path) -> None: + """Options outside of any table name the table that was meant.""" + fn = tmp_path / "myconfig.toml" + fn.write_text("xfail_strict = true\n", encoding="utf-8") + with pytest.raises(UsageError, match=r"must be under a \[pytest\] table"): + 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.""" + """.toml files with neither [pytest] nor [tool.pytest] hold no configuration.""" fn = tmp_path / "myconfig.toml" fn.write_text( dedent( From 7cbfbd8d0fe2a55e7a9389ce72e9ba5d6f398782 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Thu, 6 Aug 2026 05:31:57 +0200 Subject: [PATCH 3/3] 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 2f00d2a0458..3e027dcff57 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -456,7 +456,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 c5b4ae7b974..767dc858426 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -2377,6 +2377,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: