From a3f7badc9b131902ec2e18d3f159d67a5449a810 Mon Sep 17 00:00:00 2001 From: vismaytiwari Date: Tue, 23 Jun 2026 17:16:05 +0530 Subject: [PATCH 1/3] Read top-level options in pytest.toml --- changelog/14638.bugfix.rst | 1 + doc/en/reference/customize.rst | 2 +- src/_pytest/config/findpaths.py | 7 ++++++- testing/test_config.py | 17 +++++++++++++++++ 4 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 changelog/14638.bugfix.rst diff --git a/changelog/14638.bugfix.rst b/changelog/14638.bugfix.rst new file mode 100644 index 00000000000..f7c194d55ba --- /dev/null +++ b/changelog/14638.bugfix.rst @@ -0,0 +1 @@ +Top-level options in ``pytest.toml`` and ``.pytest.toml`` files are now read as pytest configuration instead of being silently ignored. diff --git a/doc/en/reference/customize.rst b/doc/en/reference/customize.rst index 8f781eab4a5..080cb88dc10 100644 --- a/doc/en/reference/customize.rst +++ b/doc/en/reference/customize.rst @@ -31,13 +31,13 @@ pytest.toml ``pytest.toml`` files take precedence over other files, even when empty. Alternatively, the hidden version ``.pytest.toml`` can be used. +Options can be placed at the top level of the file, or under a ``[pytest]`` table. .. tab:: toml .. code-block:: toml # pytest.toml or .pytest.toml - [pytest] minversion = "9.0" addopts = ["-ra", "-q"] testpaths = [ diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index e74546c6e28..78b2e29e555 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -105,9 +105,14 @@ def load_config_dict_from_file( except tomllib.TOMLDecodeError as exc: raise UsageError(f"{filepath}: {exc}") from exc - # pytest.toml and .pytest.toml use [pytest] table directly. + # pytest.toml and .pytest.toml use [pytest] table directly, and also + # allow pytest options at the top level because TOML tables are optional. if filepath.name in ("pytest.toml", ".pytest.toml"): pytest_config = config.get("pytest", {}) + if not pytest_config: + pytest_config = { + k: v for k, v in config.items() if not isinstance(v, dict) + } if pytest_config: # TOML mode - preserve native TOML types. return { diff --git a/testing/test_config.py b/testing/test_config.py index 7886610242d..908a7ac4525 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -164,6 +164,23 @@ def test_toml_config_names(self, pytester: Pytester, name: str) -> None: config = pytester.parseconfig() assert config.getini("minversion") == "3.36" + @pytest.mark.parametrize("name", ["pytest.toml", ".pytest.toml"]) + def test_toml_config_names_without_section( + self, pytester: Pytester, name: str + ) -> None: + pytester.path.joinpath(name).write_text( + textwrap.dedent( + """ + minversion = "3.36" + addopts = ["-v"] + """ + ), + encoding="utf-8", + ) + config = pytester.parseconfig() + assert config.getini("minversion") == "3.36" + assert config.getini("addopts") == ["-v"] + def test_pyproject_toml(self, pytester: Pytester) -> None: pyproject_toml = pytester.makepyprojecttoml( """ From f49af115a95cbd54859a68f8c77ff813dec51572 Mon Sep 17 00:00:00 2001 From: vismaytiwari Date: Wed, 24 Jun 2026 23:05:06 +0530 Subject: [PATCH 2/3] Error on top-level pytest.toml options --- changelog/14638.bugfix.rst | 2 +- doc/en/reference/customize.rst | 2 +- src/_pytest/config/findpaths.py | 21 ++++++++++++--------- testing/test_config.py | 11 +++++++---- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/changelog/14638.bugfix.rst b/changelog/14638.bugfix.rst index f7c194d55ba..cc07b54b8b7 100644 --- a/changelog/14638.bugfix.rst +++ b/changelog/14638.bugfix.rst @@ -1 +1 @@ -Top-level options in ``pytest.toml`` and ``.pytest.toml`` files are now read as pytest configuration instead of being silently ignored. +Top-level options in ``pytest.toml`` and ``.pytest.toml`` files now raise an error instead of being silently ignored when no ``[pytest]`` table is present. diff --git a/doc/en/reference/customize.rst b/doc/en/reference/customize.rst index 080cb88dc10..8f781eab4a5 100644 --- a/doc/en/reference/customize.rst +++ b/doc/en/reference/customize.rst @@ -31,13 +31,13 @@ pytest.toml ``pytest.toml`` files take precedence over other files, even when empty. Alternatively, the hidden version ``.pytest.toml`` can be used. -Options can be placed at the top level of the file, or under a ``[pytest]`` table. .. tab:: toml .. code-block:: toml # pytest.toml or .pytest.toml + [pytest] minversion = "9.0" addopts = ["-ra", "-q"] testpaths = [ diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index 78b2e29e555..2a4bed319a9 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -105,20 +105,23 @@ def load_config_dict_from_file( except tomllib.TOMLDecodeError as exc: raise UsageError(f"{filepath}: {exc}") from exc - # pytest.toml and .pytest.toml use [pytest] table directly, and also - # allow pytest options at the top level because TOML tables are optional. + # pytest.toml and .pytest.toml use [pytest] table directly. if filepath.name in ("pytest.toml", ".pytest.toml"): - pytest_config = config.get("pytest", {}) - if not pytest_config: - pytest_config = { - k: v for k, v in config.items() if not isinstance(v, dict) - } - if pytest_config: + if "pytest" in config: # TOML mode - preserve native TOML types. return { k: ConfigValue(v, origin="file", mode="toml") - for k, v in pytest_config.items() + 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 {} diff --git a/testing/test_config.py b/testing/test_config.py index 908a7ac4525..a228493324b 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -165,7 +165,7 @@ def test_toml_config_names(self, pytester: Pytester, name: str) -> None: assert config.getini("minversion") == "3.36" @pytest.mark.parametrize("name", ["pytest.toml", ".pytest.toml"]) - def test_toml_config_names_without_section( + def test_toml_config_names_without_section_errors( self, pytester: Pytester, name: str ) -> None: pytester.path.joinpath(name).write_text( @@ -177,9 +177,12 @@ def test_toml_config_names_without_section( ), encoding="utf-8", ) - config = pytester.parseconfig() - assert config.getini("minversion") == "3.36" - assert config.getini("addopts") == ["-v"] + result = pytester.runpytest() + assert result.ret != 0 + assert ( + "pytest configuration must be under a [pytest] table " + "(found top-level options: minversion, addopts)" in result.stderr.str() + ) def test_pyproject_toml(self, pytester: Pytester) -> None: pyproject_toml = pytester.makepyprojecttoml( From 53920c1ac38438ecc9f53305a6ac689d22209b3b Mon Sep 17 00:00:00 2001 From: vismaytiwari Date: Wed, 24 Jun 2026 23:12:07 +0530 Subject: [PATCH 3/3] Cover pytest.toml error in-process --- testing/test_config.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/testing/test_config.py b/testing/test_config.py index a228493324b..9583c9131fa 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -168,7 +168,8 @@ def test_toml_config_names(self, pytester: Pytester, name: str) -> None: def test_toml_config_names_without_section_errors( self, pytester: Pytester, name: str ) -> None: - pytester.path.joinpath(name).write_text( + config_path = pytester.path.joinpath(name) + config_path.write_text( textwrap.dedent( """ minversion = "3.36" @@ -177,11 +178,12 @@ def test_toml_config_names_without_section_errors( ), encoding="utf-8", ) - result = pytester.runpytest() - assert result.ret != 0 - assert ( + with pytest.raises(UsageError) as excinfo: + pytester.parseconfig() + assert str(excinfo.value) == ( + f"{config_path}: " "pytest configuration must be under a [pytest] table " - "(found top-level options: minversion, addopts)" in result.stderr.str() + "(found top-level options: minversion, addopts)" ) def test_pyproject_toml(self, pytester: Pytester) -> None: