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/changelog/13946.deprecation.rst b/changelog/13946.deprecation.rst new file mode 100644 index 00000000000..88371c4cc1c --- /dev/null +++ b/changelog/13946.deprecation.rst @@ -0,0 +1,4 @@ +The private ``config.inicfg`` attribute is now deprecated. +Use :meth:`config.getini() ` to access configuration values instead. + +See :ref:`config-inicfg` for more details. diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index f2a665a6267..bdea7c65886 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -15,6 +15,43 @@ Below is a complete list of all pytest features which are considered deprecated. :class:`~pytest.PytestWarning` or subclasses, which can be filtered using :ref:`standard warning filters `. +.. _config-inicfg: + +``config.inicfg`` +~~~~~~~~~~~~~~~~~ + +.. deprecated:: 9.0 + +The private ``config.inicfg`` attribute is deprecated. +Use :meth:`config.getini() ` to access configuration values instead. + +``config.inicfg`` was never documented and it should have had a ``_`` prefix from the start. +Pytest performs caching, transformation and aliasing on configuration options which make direct access to the raw ``config.inicfg`` untenable. + +**Reading configuration values:** + +Instead of accessing ``config.inicfg`` directly, use :meth:`config.getini() `: + +.. code-block:: python + + # Deprecated + value = config.inicfg["some_option"] + + # Use this instead + value = config.getini("some_option") + +**Setting configuration values:** + +Setting or deleting configuration values after initialization is not supported. +If you need to override configuration values, use the ``-o`` command line option: + +.. code-block:: bash + + pytest -o some_option=value + +or set them in your configuration file instead. + + .. _parametrize-iterators: Non-Collection iterables in ``@pytest.mark.parametrize`` diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 812daed88f2..a17e246845a 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 @@ -48,6 +49,8 @@ from .compat import PathAwareHookProxy from .exceptions import PrintHelp as PrintHelp from .exceptions import UsageError as UsageError +from .findpaths import ConfigDict +from .findpaths import ConfigValue from .findpaths import determine_setup from _pytest import __version__ import _pytest._code @@ -56,6 +59,7 @@ from _pytest._code.code import TracebackStyle from _pytest._io import TerminalWriter from _pytest.compat import assert_never +from _pytest.compat import deprecated from _pytest.compat import NOTSET from _pytest.config.argparsing import Argument from _pytest.config.argparsing import FILE_OR_DIR @@ -979,6 +983,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. @@ -1089,6 +1117,7 @@ def __init__( self.trace = self.pluginmanager.trace.root.get("config") self.hook: pluggy.HookRelay = PathAwareHookProxy(self.pluginmanager.hook) # type: ignore[assignment] self._inicache: dict[str, Any] = {} + self._inicfg: ConfigDict = {} self._cleanup_stack = contextlib.ExitStack() self.pluginmanager.register(self, "pytestconfig") self._configured = False @@ -1098,6 +1127,24 @@ def __init__( self.args_source = Config.ArgsSource.ARGS self.args: list[str] = [] + if TYPE_CHECKING: + + @deprecated( + "config.inicfg is deprecated, use config.getini() to access configuration values instead.", + ) + @property + def inicfg(self) -> _DeprecatedInicfgProxy: + raise NotImplementedError() + else: + + @property + def inicfg(self) -> _DeprecatedInicfgProxy: + warnings.warn( + _pytest.deprecated.CONFIG_INICFG, + stacklevel=2, + ) + return _DeprecatedInicfgProxy(self) + @property def rootpath(self) -> pathlib.Path: """The path to the :ref:`rootdir `. @@ -1428,7 +1475,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. @@ -1459,7 +1506,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) @@ -1636,14 +1683,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/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index a8be4881433..271b4cf50dd 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -83,6 +83,11 @@ "See https://docs.pytest.org/en/stable/deprecations.html#parametrize-iterators", ) +CONFIG_INICFG = PytestRemovedIn10Warning( + "config.inicfg is deprecated, use config.getini() to access configuration values instead.\n" + "See https://docs.pytest.org/en/stable/deprecations.html#config-inicfg" +) + # You want to make some `__init__` or function "private". # # def my_private_function(some, args): diff --git a/testing/test_config.py b/testing/test_config.py index 65bf94ae19b..74bb6e7be1f 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -30,6 +30,7 @@ from _pytest.monkeypatch import MonkeyPatch from _pytest.pathlib import absolutepath from _pytest.pytester import Pytester +from _pytest.warning_types import PytestDeprecationWarning import pytest @@ -60,7 +61,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") @@ -1433,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: @@ -2276,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" ) @@ -2317,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" ) @@ -2998,3 +2999,48 @@ 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 deprecation of config.inicfg.""" + + def test_inicfg_deprecated(self, pytester: Pytester) -> None: + """Test that accessing config.inicfg issues a deprecation warning.""" + pytester.makeini( + """ + [pytest] + minversion = 3.0 + """ + ) + config = pytester.parseconfig() + + with pytest.warns( + PytestDeprecationWarning, match=r"config\.inicfg is deprecated" + ): + inicfg = config.inicfg # type: ignore[deprecated] + + 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