Skip to content
Closed
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
6 changes: 6 additions & 0 deletions changelog/14912.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()


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

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 @@ -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}")
45 changes: 45 additions & 0 deletions testing/deprecated_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading