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