Skip to content
Draft
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 AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ Patrick Hayes
Patrick Lannigan
Paul Müller
Paul Reece
Paul Zuradzki
Pauli Virtanen
Pavel Karateev
Pavel Zhukov
Expand Down
1 change: 1 addition & 0 deletions changelog/14263.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
An unraisable exception from a finalizer is now re-raised as the original warning class when an active error filter would have promoted it, instead of being wrapped in :class:`pytest.PytestUnraisableExceptionWarning`. This lets a user's ``filterwarnings = error::ResourceWarning`` (or any warning-class error filter) fail tests on resource leaks without a separate filter on the wrapping class.
50 changes: 38 additions & 12 deletions src/_pytest/unraisableexception.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ def gc_collect_harder(iterations: int) -> None:
gc.collect()


def _warning_class_has_error_filter(category: type[Warning]) -> bool:
"""Return True if an active ``error`` filter matches ``category`` by class.

Approximate match: ``message``/``module``/``lineno`` filter fields are ignored.
"""
for action, _msg, filt_category, _mod, _lineno in warnings.filters:
if action == "error" and issubclass(category, filt_category):
return True
return False


class UnraisableMeta(NamedTuple):
msg: str
cause_msg: str
Expand All @@ -46,7 +57,7 @@ class UnraisableMeta(NamedTuple):

def collect_unraisable(config: Config) -> None:
pop_unraisable = config.stash[unraisable_exceptions].pop
errors: list[pytest.PytestUnraisableExceptionWarning | RuntimeError] = []
errors: list[Warning | RuntimeError] = []
meta = None
hook_error = None
try:
Expand All @@ -62,6 +73,17 @@ def collect_unraisable(config: Config) -> None:
errors.append(hook_error)
continue

if isinstance(meta.exc_value, Warning) and _warning_class_has_error_filter(
type(meta.exc_value)
):
# Honor the user's error filter on the inner warning class
# rather than wrapping in PytestUnraisableExceptionWarning. See #14263.
if sys.version_info >= (3, 11):
if meta.cause_msg not in getattr(meta.exc_value, "__notes__", []):
meta.exc_value.add_note(meta.cause_msg)
errors.append(meta.exc_value)
continue

msg = meta.msg
try:
warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))
Expand All @@ -86,17 +108,8 @@ def collect_unraisable(config: Config) -> None:
def cleanup(
*, config: Config, prev_hook: Callable[[sys.UnraisableHookArgs], object]
) -> None:
# A single collection doesn't necessarily collect everything.
# Constant determined experimentally by the Trio project.
gc_collect_iterations = config.stash.get(gc_collect_iterations_key, 5)
try:
try:
gc_collect_harder(gc_collect_iterations)
collect_unraisable(config)
finally:
sys.unraisablehook = prev_hook
finally:
del config.stash[unraisable_exceptions]
sys.unraisablehook = prev_hook
del config.stash[unraisable_exceptions]


def unraisable_hook(
Expand Down Expand Up @@ -148,6 +161,19 @@ def pytest_configure(config: Config) -> None:
sys.unraisablehook = functools.partial(unraisable_hook, append=deque.append)


def pytest_unconfigure(config: Config) -> None:
# Run GC and drain the unraisable queue here rather than from the
# ``config.add_cleanup`` callback. ``pytest_unconfigure`` fires before
# ``_cleanup_stack.close()``, so warning filters managed via the cleanup
# stack (e.g. the ``warnings`` plugin's ``catch_warnings`` context) are
# still active. This decouples the GC step from plugin registration order.
# A single collection doesn't necessarily collect everything; the
# iteration count was determined experimentally by the Trio project.
gc_collect_iterations = config.stash.get(gc_collect_iterations_key, 5)
gc_collect_harder(gc_collect_iterations)
collect_unraisable(config)


@pytest.hookimpl(trylast=True)
def pytest_runtest_setup(item: Item) -> None:
collect_unraisable(item.config)
Expand Down
172 changes: 172 additions & 0 deletions testing/test_unraisableexception.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,10 @@ def test_scheduler_must_be_created_within_running_loop() -> None:

# TODO: Should be a test failure or error. Currently the exception
# propagates all the way to the top resulting in exit code 1.
# Note: since #14263, the propagated class is the bare RuntimeWarning
# rather than the wrapping PytestUnraisableExceptionWarning, because
# -Werror activates an error filter that matches RuntimeWarning's
# category and the new unwrap path in collect_unraisable fires.
assert result.ret == 1

result.assert_outcomes(passed=1)
Expand Down Expand Up @@ -359,6 +363,174 @@ def test_it():
result.stderr.fnmatch_lines("ValueError: del is broken")


def test_refcycle_resource_warning_filter(pytester: Pytester) -> None:
# Regression test for https://github.com/pytest-dev/pytest/issues/14263.
# A reference cycle holds a file alive past test return; only the cyclic
# GC at session end frees it. The file finalizer emits ResourceWarning.
# With ``filterwarnings = error::ResourceWarning`` the user has expressed
# intent that resource leaks should fail tests. Before the fix, the
# ResourceWarning was captured by sys.unraisablehook (the timing piece
# was already correct since #13057), but ``collect_unraisable`` wrapped
# it in a ``PytestUnraisableExceptionWarning``. Since the user had no
# error filter on the wrapping class, the failure was silently logged
# as a warning and the test passed.
pytester.makeini(
"""
[pytest]
filterwarnings =
error::ResourceWarning
"""
)
pytester.makepyfile(
test_it="""
# Disable gc so the cycle survives until session-end gc_collect_harder.
import gc; gc.disable()

def test_it():
f = open(__file__)
cycle = [f]
cycle.append(cycle)
"""
)

result = pytester.runpytest_subprocess()

# TODO: should be a test failure or error. Currently the exception
# propagates all the way to the top resulting in exit code 1.
assert result.ret == 1
result.assert_outcomes(passed=1)
# The unwrap path: stderr shows the ResourceWarning directly, NOT wrapped
# in PytestUnraisableExceptionWarning. The negative assertion is what
# makes this a contract test for #14263 rather than an exit-code check.
result.stderr.fnmatch_lines("*ResourceWarning: unclosed file*")
result.stderr.no_fnmatch_line("*PytestUnraisableExceptionWarning*")


def test_refcycle_userwarning_filter(pytester: Pytester) -> None:
# Companion to test_refcycle_resource_warning_filter. Covers the unwrap
# path for a non-built-in Warning subclass (UserWarning here) and a
# finalizer that calls ``warnings.warn(...)`` directly rather than
# leaking a resource. Confirms the fix is not specific to ResourceWarning.
pytester.makeini(
"""
[pytest]
filterwarnings =
error::UserWarning
"""
)
pytester.makepyfile(
test_it="""
import gc; gc.disable()
import warnings

class Leaky:
def __init__(self):
self.self = self # cycle so __del__ defers to session-end gc

def __del__(self):
warnings.warn("leak detected", UserWarning)

def test_it():
Leaky()
"""
)

result = pytester.runpytest_subprocess()

# TODO: should be a test failure or error. Currently the exception
# propagates all the way to the top resulting in exit code 1.
assert result.ret == 1
result.assert_outcomes(passed=1)
result.stderr.fnmatch_lines("*UserWarning: leak detected*")
result.stderr.no_fnmatch_line("*PytestUnraisableExceptionWarning*")


def test_unraisable_warning_without_filter_still_wraps(pytester: Pytester) -> None:
# Regression guard for the scope of the #14263 fix. A Warning raised
# from ``__del__`` *without* a matching error filter must still be
# wrapped in PytestUnraisableExceptionWarning rather than propagated
# directly. Otherwise the fix would change behavior for users who
# haven't set any filter (suites that previously logged would start
# failing unconditionally).
pytester.makepyfile(
test_it="""
class RaisingDel:
def __del__(self):
raise UserWarning("oops")

def test_it():
obj = RaisingDel()
del obj
"""
)

# Subprocess so we don't inherit the outer pytest's filterwarnings
# (which is ``error`` in pyproject.toml; that would falsely trigger
# the unwrap path). ``-Wdefault::pytest.PytestUnraisableExceptionWarning``
# makes the wrapping warning visible on stderr regardless of whether
# ``__del__`` fires inside the test or during later GC (PyPy).
result = pytester.runpytest_subprocess(
"-Wdefault::pytest.PytestUnraisableExceptionWarning"
)

assert result.ret == 0
result.assert_outcomes(passed=1)
# Wrap path fired: the unraisable hook emitted
# PytestUnraisableExceptionWarning rather than propagating UserWarning.
# The warning lands in the warnings-summary section of stdout on
# CPython (where ``__del__`` fires inside the test) and on stderr on
# PyPy (where it fires during later GC). Check both rather than the
# outcomes counter, which is timing-dependent.
combined = "\n".join(result.outlines + result.errlines)
assert "PytestUnraisableExceptionWarning" in combined


@pytest.mark.skipif(sys.version_info < (3, 11), reason="add_note requires Python 3.11+")
def test_unraisable_warning_filter_add_note_dedups(pytester: Pytester) -> None:
# Covers the duplicate-note guard in the unwrap path. When the same
# Warning instance reaches sys.unraisablehook twice (which happens
# for singleton/cached warnings), the cause note must be appended
# once, not duplicated.
pytester.makeini(
"""
[pytest]
filterwarnings =
error::UserWarning
"""
)
pytester.makepyfile(
test_it="""
import sys
import types

cached = UserWarning("cached")

def test_emit_same_instance_twice():
for _ in range(2):
sys.unraisablehook(
types.SimpleNamespace(
exc_type=UserWarning,
exc_value=cached,
exc_traceback=None,
err_msg=None,
object=None,
)
)
"""
)
result = pytester.runpytest_subprocess()
# The unwrap path raises the UserWarning, so the test fails.
assert result.ret == 1
# Two errors land in the ExceptionGroup (one per hook call). With the
# dedup guard, ``cached.__notes__`` holds the cause note once, so the
# formatted group prints it twice (once per entry). Without the
# guard it would print four times.
note_count = sum(
1 for ln in result.outlines + result.errlines if "Exception ignored in" in ln
)
assert note_count == 2, f"expected 2 cause-note lines, saw {note_count}"


@pytest.mark.filterwarnings("error::pytest.PytestUnraisableExceptionWarning")
def test_possibly_none_excinfo(pytester: Pytester) -> None:
pytester.makepyfile(
Expand Down
Loading