diff --git a/changelog/13946.bugfix.rst b/changelog/13946.bugfix.rst new file mode 100644 index 00000000000..d6cc5b703e4 --- /dev/null +++ b/changelog/13946.bugfix.rst @@ -0,0 +1,3 @@ +The private ``config.inicfg`` attribute was changed in a breaking manner in pytest 9.0.0. +Due to its usage in the ecosystem, it is now restored to working order using a compatibility shim. +It will be deprecated in pytest 9.1 and removed in pytest 10. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 9b2afe3e8b4..39817aaa523 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -11,6 +11,7 @@ from collections.abc import Iterable from collections.abc import Iterator from collections.abc import Mapping +from collections.abc import MutableMapping from collections.abc import Sequence import contextlib import copy @@ -47,6 +48,7 @@ from .compat import PathAwareHookProxy from .exceptions import PrintHelp as PrintHelp from .exceptions import UsageError as UsageError +from .findpaths import ConfigValue from .findpaths import determine_setup from _pytest import __version__ import _pytest._code @@ -980,6 +982,30 @@ def _iter_rewritable_modules(package_files: Iterable[str]) -> Iterator[str]: yield from _iter_rewritable_modules(new_package_files) +class _DeprecatedInicfgProxy(MutableMapping[str, Any]): + """Compatibility proxy for the deprecated Config.inicfg.""" + + __slots__ = ("_config",) + + def __init__(self, config: Config) -> None: + self._config = config + + def __getitem__(self, key: str) -> Any: + return self._config._inicfg[key].value + + def __setitem__(self, key: str, value: Any) -> None: + self._config._inicfg[key] = ConfigValue(value, origin="override", mode="toml") + + def __delitem__(self, key: str) -> None: + del self._config._inicfg[key] + + def __iter__(self) -> Iterator[str]: + return iter(self._config._inicfg) + + def __len__(self) -> int: + return len(self._config._inicfg) + + @final class Config: """Access to configuration values, pluginmanager and plugin hooks. @@ -1100,6 +1126,10 @@ def __init__( self.args_source = Config.ArgsSource.ARGS self.args: list[str] = [] + @property + def inicfg(self) -> _DeprecatedInicfgProxy: + return _DeprecatedInicfgProxy(self) + @property def rootpath(self) -> pathlib.Path: """The path to the :ref:`rootdir `. @@ -1376,7 +1406,7 @@ def pytest_collection(self) -> Generator[None, object, object]: def _checkversion(self) -> None: import pytest - minver_ini_value = self.inicfg.get("minversion", None) + minver_ini_value = self._inicfg.get("minversion", None) minver = minver_ini_value.value if minver_ini_value is not None else None if minver: # Imported lazily to improve start-up time. @@ -1440,7 +1470,7 @@ def _warn_or_fail_if_strict(self, message: str) -> None: def _get_unknown_ini_keys(self) -> set[str]: known_keys = self._parser._inidict.keys() | self._parser._ini_aliases.keys() - return self.inicfg.keys() - known_keys + return self._inicfg.keys() - known_keys def parse(self, args: list[str], addopts: bool = True) -> None: # Parse given cmdline arguments into this config object. @@ -1471,7 +1501,7 @@ def parse(self, args: list[str], addopts: bool = True) -> None: self._rootpath = rootpath self._inipath = inipath self._ignored_config_files = ignored_config_files - self.inicfg = inicfg + self._inicfg = inicfg self._parser.extra_info["rootdir"] = str(self.rootpath) self._parser.extra_info["inifile"] = str(self.inipath) @@ -1648,14 +1678,14 @@ def _getini(self, name: str): except KeyError as e: raise ValueError(f"unknown configuration value: {name!r}") from e - # Collect all possible values (canonical name + aliases) from inicfg. + # Collect all possible values (canonical name + aliases) from _inicfg. # Each candidate is (ConfigValue, is_canonical). candidates = [] - if canonical_name in self.inicfg: - candidates.append((self.inicfg[canonical_name], True)) + if canonical_name in self._inicfg: + candidates.append((self._inicfg[canonical_name], True)) for alias, target in self._parser._ini_aliases.items(): - if target == canonical_name and alias in self.inicfg: - candidates.append((self.inicfg[alias], False)) + if target == canonical_name and alias in self._inicfg: + candidates.append((self._inicfg[alias], False)) if not candidates: return default diff --git a/testing/test_config.py b/testing/test_config.py index 98555e04452..d28b9110413 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -60,7 +60,7 @@ def test_getcfg_and_config( _, _, cfg, _ = locate_config(Path.cwd(), [sub]) assert cfg["name"] == ConfigValue("value", origin="file", mode="ini") config = pytester.parseconfigure(str(sub)) - assert config.inicfg["name"] == ConfigValue("value", origin="file", mode="ini") + assert config._inicfg["name"] == ConfigValue("value", origin="file", mode="ini") def test_setupcfg_uses_toolpytest_with_pytest(self, pytester: Pytester) -> None: p1 = pytester.makepyfile("def test(): pass") @@ -1434,10 +1434,10 @@ def test_inifilename(self, tmp_path: Path) -> None: # this indicates this is the file used for getting configuration values assert config.inipath == inipath - assert config.inicfg.get("name") == ConfigValue( + assert config._inicfg.get("name") == ConfigValue( "value", origin="file", mode="ini" ) - assert config.inicfg.get("should_not_be_set") is None + assert config._inicfg.get("should_not_be_set") is None def test_options_on_small_file_do_not_blow_up(pytester: Pytester) -> None: @@ -2277,7 +2277,7 @@ def test_addopts_before_initini( monkeypatch.setenv("PYTEST_ADDOPTS", f"-o cache_dir={cache_dir}") config = _config_for_test config.parse([], addopts=True) - assert config.inicfg.get("cache_dir") == ConfigValue( + assert config._inicfg.get("cache_dir") == ConfigValue( cache_dir, origin="override", mode="ini" ) @@ -2318,7 +2318,7 @@ def test_override_ini_does_not_contain_paths( """Check that -o no longer swallows all options after it (#3103)""" config = _config_for_test config.parse(["-o", "cache_dir=/cache", "/some/test/path"]) - assert config.inicfg.get("cache_dir") == ConfigValue( + assert config._inicfg.get("cache_dir") == ConfigValue( "/cache", origin="override", mode="ini" ) @@ -2999,3 +2999,45 @@ def pytest_addoption(parser): with pytest.raises(TypeError, match=r"expects a string.*got int"): config.getini("string_not_string") + + +class TestInicfgDeprecation: + """Tests for the upcoming deprecation of config.inicfg.""" + + def test_inicfg_deprecated(self, pytester: Pytester) -> None: + """Test that accessing config.inicfg issues a deprecation warning (not yet).""" + pytester.makeini( + """ + [pytest] + minversion = 3.0 + """ + ) + config = pytester.parseconfig() + + inicfg = config.inicfg + + assert config.getini("minversion") == "3.0" + assert inicfg["minversion"] == "3.0" + assert inicfg.get("minversion") == "3.0" + del inicfg["minversion"] + inicfg["minversion"] = "4.0" + assert list(inicfg.keys()) == ["minversion"] + assert list(inicfg.items()) == [("minversion", "4.0")] + assert len(inicfg) == 1 + + def test_issue_13946_setting_bool_no_longer_crashes( + self, pytester: Pytester + ) -> None: + """Regression test for #13946 - setting inicfg doesn't cause a crash.""" + pytester.makepyfile( + """ + def pytest_configure(config): + config.inicfg["xfail_strict"] = True + + def test(): + pass + """ + ) + + result = pytester.runpytest() + assert result.ret == 0