From 49344608a588009e4a4a9fd05b5a04c255918b2e Mon Sep 17 00:00:00 2001 From: Dextheking1 Date: Wed, 23 Sep 2026 19:07:01 +0200 Subject: [PATCH] Issue a fresh PytestWarning instance per warn() call Convert the module-level deprecation constants in _pytest.deprecated from shared PytestWarning instances to UnformattedWarning, formatted into a fresh instance at each warn site. Reusing the same instance across warnings.warn() calls made CPython append to the instance's existing __traceback__ on every raise under -W error, corrupting failure reports with stale frames from previous raises. Fixes #14912. --- changelog/14912.bugfix.rst | 6 +++ src/_pytest/config/__init__.py | 6 +-- src/_pytest/deprecated.py | 71 +++++++++++++++++++++------------- src/_pytest/fixtures.py | 10 ++--- src/_pytest/monkeypatch.py | 2 +- src/_pytest/pastebin.py | 2 +- src/_pytest/python.py | 2 +- testing/deprecated_test.py | 45 +++++++++++++++++++++ 8 files changed, 106 insertions(+), 38 deletions(-) create mode 100644 changelog/14912.bugfix.rst diff --git a/changelog/14912.bugfix.rst b/changelog/14912.bugfix.rst new file mode 100644 index 00000000000..a856f8e09ea --- /dev/null +++ b/changelog/14912.bugfix.rst @@ -0,0 +1,6 @@ +Fixed :class:`pytest.PytestWarning` deprecation constants in :mod:`_pytest.deprecated` +being shared instances reused across ``warnings.warn()`` calls: under ``-W error`` +each raise appended to the instance's existing ``__traceback__``, corrupting +failure reports with stale frames from previous raises. All constants are now +:class:`~_pytest.warning_types.UnformattedWarning`, formatted into a fresh +instance at each warn site. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index c7bd3e1afab..97fb22dd3ff 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -303,7 +303,7 @@ def console_main() -> int: from _pytest.deprecated import CONSOLE_MAIN - warnings.warn(CONSOLE_MAIN, stacklevel=2) + warnings.warn(CONSOLE_MAIN.format(), stacklevel=2) return _console_main() @@ -1241,7 +1241,7 @@ def inicfg(self) -> _DeprecatedInicfgProxy: @property def inicfg(self) -> _DeprecatedInicfgProxy: warnings.warn( - _pytest.deprecated.CONFIG_INICFG, + _pytest.deprecated.CONFIG_INICFG.format(), stacklevel=2, ) return _DeprecatedInicfgProxy(self) @@ -2000,7 +2000,7 @@ def _getini_ini( elif type == "string": if not isinstance(value, str): warnings.warn( - _pytest.deprecated.INI_STRING_TYPE_NON_STR_VALUE, + _pytest.deprecated.INI_STRING_TYPE_NON_STR_VALUE.format(), stacklevel=2, ) return value diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index 0370240ee80..fc967809c8f 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -4,9 +4,13 @@ Keeping it in a central location makes it easy to track what is deprecated and should be removed when the time comes. -All constants defined in this module should be either instances of -:class:`PytestWarning`, or :class:`UnformattedWarning` -in case of warnings which need to format their messages. +All constants defined in this module should be :class:`UnformattedWarning`, +formatted at the warn site with ``.format(...)``. Do NOT store shared +:class:`PytestWarning` instances here: reusing the same instance across +``warnings.warn()`` calls makes CPython append to the instance's existing +``__traceback__`` on every raise under ``-W error``, corrupting failure +reports with stale frames from previous raises (see #14912). +``UnformattedWarning.format()`` returns a fresh warning instance per call. """ from __future__ import annotations @@ -29,9 +33,10 @@ # This could have been removed pytest 8, but it's harmless and common, so no rush to remove. -YIELD_FIXTURE = PytestRemovedIn10Warning( +YIELD_FIXTURE = UnformattedWarning( + PytestRemovedIn10Warning, "@pytest.yield_fixture is deprecated.\n" - "Use @pytest.fixture instead; they are the same." + "Use @pytest.fixture instead; they are the same.", ) CLASS_FIXTURE_INSTANCE_METHOD = UnformattedWarning( @@ -44,7 +49,9 @@ ) # This deprecation is never really meant to be removed. -PRIVATE = PytestDeprecationWarning("A private pytest class or function was used.") +PRIVATE = UnformattedWarning( + PytestDeprecationWarning, "A private pytest class or function was used." +) HOOK_LEGACY_MARKING = UnformattedWarning( @@ -56,11 +63,12 @@ "#configuring-hook-specs-impls-using-markers", ) -MONKEYPATCH_LEGACY_NAMESPACE_PACKAGES = PytestRemovedIn10Warning( +MONKEYPATCH_LEGACY_NAMESPACE_PACKAGES = UnformattedWarning( + PytestRemovedIn10Warning, "monkeypatch.syspath_prepend() called with pkg_resources legacy namespace packages detected.\n" "Legacy namespace packages (using pkg_resources.declare_namespace) are deprecated.\n" "Please use native namespace packages (PEP 420) instead.\n" - "See https://docs.pytest.org/en/stable/deprecations.html#monkeypatch-fixup-namespace-packages" + "See https://docs.pytest.org/en/stable/deprecations.html#monkeypatch-fixup-namespace-packages", ) PARAMETRIZE_NON_COLLECTION_ITERABLE = UnformattedWarning( @@ -71,15 +79,17 @@ "See https://docs.pytest.org/en/stable/deprecations.html#parametrize-iterators", ) -CONSOLE_MAIN = PytestRemovedIn10Warning( +CONSOLE_MAIN = UnformattedWarning( + PytestRemovedIn10Warning, "pytest.console_main() is deprecated and will be removed in pytest 10.\n" "It was never intended for programmatic use; use pytest.main() instead.\n" - "See https://docs.pytest.org/en/stable/deprecations.html#console-main" + "See https://docs.pytest.org/en/stable/deprecations.html#console-main", ) -CONFIG_INICFG = PytestRemovedIn10Warning( +CONFIG_INICFG = UnformattedWarning( + 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" + "See https://docs.pytest.org/en/stable/deprecations.html#config-inicfg", ) FIXTURE_GETFIXTUREVALUE_DURING_TEARDOWN = UnformattedWarning( @@ -90,18 +100,20 @@ "See https://docs.pytest.org/en/stable/deprecations.html#dynamic-fixture-request-during-teardown", ) -PASTEBIN = PytestRemovedIn10Warning( +PASTEBIN = UnformattedWarning( + PytestRemovedIn10Warning, "The --pastebin option is deprecated. " "The functionality is now available in an external plugin package, pytest-pastebin.\n" - "See https://docs.pytest.org/en/stable/deprecations.html#the-pastebin-option" + "See https://docs.pytest.org/en/stable/deprecations.html#the-pastebin-option", ) -INI_STRING_TYPE_NON_STR_VALUE = PytestRemovedIn10Warning( +INI_STRING_TYPE_NON_STR_VALUE = UnformattedWarning( + PytestRemovedIn10Warning, "Passing a value that is not a string to a 'string'-typed ini option is deprecated.\n" "In a future version this will raise a TypeError, matching the behavior of the " "corresponding TOML config path.\n" "If your plugin intentionally accepts non-string values, declare an explicit type " - '(e.g. type="args") instead of relying on the implicit string default.' + '(e.g. type="args") instead of relying on the implicit string default.', ) # You want to make some `__init__` or function "private". @@ -123,33 +135,38 @@ # the warning (possibly error in the future). -FIXTURE_BASEID_DEPRECATED = PytestRemovedIn10Warning( - "Passing baseid to FixtureDef is deprecated. Pass node instead for fixture scoping." +FIXTURE_BASEID_DEPRECATED = UnformattedWarning( + PytestRemovedIn10Warning, + "Passing baseid to FixtureDef is deprecated. Pass node instead for fixture scoping.", ) -FIXTURE_NODEID_DEPRECATED = PytestRemovedIn10Warning( +FIXTURE_NODEID_DEPRECATED = UnformattedWarning( + PytestRemovedIn10Warning, "Passing nodeid to _register_fixture is deprecated. " - "Pass node instead for fixture scoping." + "Pass node instead for fixture scoping.", ) -FIXTUREDEF_HAS_LOCATION_DEPRECATED = PytestRemovedIn10Warning( +FIXTUREDEF_HAS_LOCATION_DEPRECATED = UnformattedWarning( + PytestRemovedIn10Warning, "FixtureDef.has_location is deprecated and will be removed in pytest 10. " - "See https://docs.pytest.org/en/stable/deprecations.html#fixturedef-has-location-deprecated" + "See https://docs.pytest.org/en/stable/deprecations.html#fixturedef-has-location-deprecated", ) -PARSEFACTORIES_NODEID_DEPRECATED = PytestRemovedIn10Warning( +PARSEFACTORIES_NODEID_DEPRECATED = UnformattedWarning( + PytestRemovedIn10Warning, "Passing nodeid string to parsefactories is deprecated. " - "Use parsefactories(holder=obj, node=node) instead." + "Use parsefactories(holder=obj, node=node) instead.", ) -CALLSPEC2_RENAMED = PytestRemovedIn10Warning( +CALLSPEC2_RENAMED = UnformattedWarning( + PytestRemovedIn10Warning, "_pytest.python.CallSpec2 has been renamed to CallSpec.\n" "The CallSpec2 alias will be removed in pytest 10.\n" "Update imports to use CallSpec instead.\n" - "See https://docs.pytest.org/en/stable/deprecations.html#callspec2-renamed" + "See https://docs.pytest.org/en/stable/deprecations.html#callspec2-renamed", ) def check_ispytest(ispytest: bool) -> None: if not ispytest: - warn(PRIVATE, stacklevel=3) + warn(PRIVATE.format(), stacklevel=3) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 30f44d44dfc..20e43f89132 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1132,7 +1132,7 @@ def __init__( check_ispytest(_ispytest) # Emit deprecation warning if deprecated baseid string is used. if node is NOTSET: - warnings.warn(FIXTURE_BASEID_DEPRECATED, stacklevel=2) + warnings.warn(FIXTURE_BASEID_DEPRECATED.format(), stacklevel=2) if baseid is NOTSET: baseid = None # The node where this fixture was defined, if available. @@ -1197,7 +1197,7 @@ def scope(self) -> ScopeName: @property def has_location(self) -> bool: - warnings.warn(FIXTUREDEF_HAS_LOCATION_DEPRECATED, stacklevel=2) + warnings.warn(FIXTUREDEF_HAS_LOCATION_DEPRECATED.format(), stacklevel=2) return self._has_location def addfinalizer(self, finalizer: Callable[[], object]) -> None: @@ -1620,7 +1620,7 @@ def yield_fixture( .. deprecated:: 3.0 Use :py:func:`pytest.fixture` directly instead. """ - warnings.warn(YIELD_FIXTURE, stacklevel=2) + warnings.warn(YIELD_FIXTURE.format(), stacklevel=2) return fixture( fixture_function, *args, @@ -2091,7 +2091,7 @@ def _register_fixture( """ # Emit deprecation warning if nodeid string. if nodeid is not NOTSET or node is NOTSET: - warnings.warn(FIXTURE_NODEID_DEPRECATED, stacklevel=2) + warnings.warn(FIXTURE_NODEID_DEPRECATED.format(), stacklevel=2) fixture_def = FixtureDef( config=self.config, baseid=nodeid, @@ -2280,7 +2280,7 @@ def parsefactories( raise TypeError("parsefactories() requires holder or node_or_obj") elif nodeid is not NOTSET: # Legacy: parsefactories(obj, nodeid) - string-based scoping only. - warnings.warn(PARSEFACTORIES_NODEID_DEPRECATED, stacklevel=2) + warnings.warn(PARSEFACTORIES_NODEID_DEPRECATED.format(), stacklevel=2) holderobj = node_or_obj effective_nodeid = nodeid else: diff --git a/src/_pytest/monkeypatch.py b/src/_pytest/monkeypatch.py index 453c6728ee2..6002fa27a3b 100644 --- a/src/_pytest/monkeypatch.py +++ b/src/_pytest/monkeypatch.py @@ -385,7 +385,7 @@ def syspath_prepend(self, path) -> None: ns_pkg_path = path_obj / ns_pkg.replace(".", os.sep) if ns_pkg_path.is_dir(): warnings.warn( - MONKEYPATCH_LEGACY_NAMESPACE_PACKAGES, stacklevel=2 + MONKEYPATCH_LEGACY_NAMESPACE_PACKAGES.format(), stacklevel=2 ) break diff --git a/src/_pytest/pastebin.py b/src/_pytest/pastebin.py index e6a1430220a..5e1c06ca9e5 100644 --- a/src/_pytest/pastebin.py +++ b/src/_pytest/pastebin.py @@ -35,7 +35,7 @@ def pytest_addoption(parser: Parser) -> None: @pytest.hookimpl(trylast=True) def pytest_configure(config: Config) -> None: if config.option.pastebin: - config.issue_config_time_warning(PASTEBIN, 2) + config.issue_config_time_warning(PASTEBIN.format(), 2) if config.option.pastebin == "all": tr = config.pluginmanager.getplugin("terminalreporter") diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 792d0d5b4f3..d392afc7ef0 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1852,6 +1852,6 @@ def runtest(self) -> None: def __getattr__(name: str) -> object: if name == "CallSpec2": - warnings.warn(CALLSPEC2_RENAMED, stacklevel=2) + warnings.warn(CALLSPEC2_RENAMED.format(), stacklevel=2) return CallSpec raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/testing/deprecated_test.py b/testing/deprecated_test.py index ee91880d23a..af7120a010a 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -365,3 +365,48 @@ def test_callspec2_renamed() -> None: with pytest.warns(pytest.PytestRemovedIn10Warning, match="CallSpec2"): assert python_mod.CallSpec2 is CallSpec + + +def test_deprecation_constants_are_not_shared_instances() -> None: + """All deprecation constants must be UnformattedWarning, not shared Warning instances (#14912). + + Reusing a module-level Warning instance across warn() calls makes CPython + append to the instance's existing __traceback__ on every raise under + -W error, corrupting failure reports with stale frames from previous + raises. UnformattedWarning.format() returns a fresh instance per call. + """ + for name in dir(deprecated): + if name.isupper(): + value = getattr(deprecated, name) + assert not isinstance(value, Warning), ( + f"{name} is a shared Warning instance; " + "use UnformattedWarning and call .format() at the warn site" + ) + + +def test_deprecation_warning_traceback_does_not_accumulate_under_error() -> None: + """Repeated -W error raises of the same deprecation must not accumulate + __traceback__ frames (#14912).""" + import warnings + + def count_frames(exc: BaseException) -> int: + n = 0 + tb = exc.__traceback__ + while tb is not None: + n += 1 + tb = tb.tb_next + return n + + counts = [] + with warnings.catch_warnings(): + warnings.simplefilter("error") + for _ in range(3): + with pytest.raises(PytestRemovedIn10Warning) as excinfo: + + @pytest.yield_fixture # type: ignore[deprecated] + def fix(): + pass + + counts.append(count_frames(excinfo.value)) + + assert counts[0] == counts[1] == counts[2], counts