Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/14912.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()


Expand Down Expand Up @@ -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)
Expand Down
65 changes: 40 additions & 25 deletions src/_pytest/deprecated.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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".
Expand All @@ -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)
10 changes: 5 additions & 5 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/_pytest/monkeypatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/_pytest/pastebin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion src/_pytest/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
34 changes: 34 additions & 0 deletions testing/deprecated_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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