From 8a41ca2dc07f63098c52f0cb843d90fb3a466b9a Mon Sep 17 00:00:00 2001 From: Devansh-awat <80251412+Devansh-awat@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:49:07 +0000 Subject: [PATCH] fix: stop reusing shared PytestWarning instances whose tracebacks grow under -W error The deprecation constants in src/_pytest/deprecated.py were module-level PytestWarning instances passed directly to warnings.warn(). When a warning is raised as an error, CPython appends to the exception's existing __traceback__ instead of replacing it, so each raise grew the traceback of these process-lifetime singletons (measured 1 -> 2 -> 3 -> 4 frames across four raises). With multiple tests tripping the same deprecation, later failure reports carried stale frames -- and stale frame locals -- from earlier raises. Fix by storing every constant as an UnformattedWarning and calling .format() at the warn sites; format() returns a fresh instance of the underlying category on each call, so no exception object is ever reused. The four constants that were already UnformattedWarning are unchanged. Warning text, category, and all public names are unchanged. Fixes #14912. --- changelog/14912.bugfix.rst | 1 + src/_pytest/config/__init__.py | 4 +-- src/_pytest/deprecated.py | 65 +++++++++++++++++++++------------- src/_pytest/fixtures.py | 10 +++--- src/_pytest/monkeypatch.py | 2 +- src/_pytest/pastebin.py | 2 +- src/_pytest/python.py | 2 +- testing/deprecated_test.py | 34 ++++++++++++++++++ 8 files changed, 85 insertions(+), 35 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..5ca820df37f --- /dev/null +++ b/changelog/14912.bugfix.rst @@ -0,0 +1 @@ +:class:`~pytest.PytestWarning` instances defined in ``_pytest.deprecated`` are no longer reused across ``warnings.warn`` calls, so warnings raised as errors (e.g. with ``-W error``) no longer accumulate stale traceback frames from previous raises. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index e0038e26ce6..7ed4da7931e 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -280,7 +280,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() @@ -1191,7 +1191,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) diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index 860ec406584..1ab70bdf5a2 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -4,9 +4,11 @@ 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 instances of +:class:`UnformattedWarning`, whose ``format`` method returns a fresh warning +instance each time. Do not warn with shared warning instances: if a warning is +raised as an error, CPython appends to the exception's existing ``__traceback__``, +so a process-lifetime instance would accumulate stale frames from previous raises. """ from __future__ import annotations @@ -29,9 +31,10 @@ # This could have been removed pytest 8, but it's harmless and common, so no rush to remove. -YIELD_FIXTURE = PytestDeprecationWarning( +YIELD_FIXTURE = UnformattedWarning( + PytestDeprecationWarning, "@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 +47,10 @@ ) # 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 +62,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 +78,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,10 +99,11 @@ "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", ) # You want to make some `__init__` or function "private". @@ -115,33 +125,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 05537ec01b2..c6b9721a046 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1133,7 +1133,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. @@ -1198,7 +1198,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: @@ -1621,7 +1621,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, @@ -2081,7 +2081,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, @@ -2278,7 +2278,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 d6db72455a8..8be5a614cc1 100644 --- a/src/_pytest/monkeypatch.py +++ b/src/_pytest/monkeypatch.py @@ -359,7 +359,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 be0ea5b4d05..34f1a277eab 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1837,6 +1837,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 0dc66a09e31..9c9ff0fe7aa 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -364,3 +364,37 @@ def test_callspec2_renamed() -> None: with pytest.warns(pytest.PytestRemovedIn10Warning, match="CallSpec2"): assert python_mod.CallSpec2 is CallSpec + + +def test_deprecation_warning_instance_not_shared_across_raises() -> None: + """Each raise must use a fresh warning instance (#14912). + + When a warning is raised as an error, CPython appends to the exception's + existing ``__traceback__`` instead of replacing it, so reusing a single + module-level instance accumulates stale frames from previous raises. + """ + import warnings + + class PrivateClass: + def __init__(self) -> None: + deprecated.check_ispytest(False) + + def count_frames(tb: object) -> int: + n = 0 + while tb is not None: + n += 1 + tb = tb.tb_next + return n + + def raise_and_count() -> int: + with warnings.catch_warnings(): + warnings.simplefilter("error", PytestDeprecationWarning) + with pytest.raises(PytestDeprecationWarning) as excinfo: + PrivateClass() + return count_frames(excinfo.value.__traceback__) + + first = raise_and_count() + second = raise_and_count() + + # The traceback of a shared instance would grow with every raise. + assert second <= first