Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

unraisablehook enhancements #12958

Open
wants to merge 32 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
85594a7
unraisablehook enhancements
graingert Nov 13, 2024
b79f652
only raise errors if there are any
graingert Nov 13, 2024
194f9ec
cause the traceback to only show up once
graingert Nov 13, 2024
c78f84b
include tracemalloc message in unraisablehook failures
graingert Nov 13, 2024
7f30217
fix tests
graingert Nov 13, 2024
d2045ce
avoid keeping unraisable.object alive while waiting for collection
graingert Nov 13, 2024
7e7c965
use the formatted traceback if there's no exc_value
graingert Nov 13, 2024
b0f803d
Merge branch 'main' into set-unraisable-hook-per-session
graingert Nov 19, 2024
d540ada
handle failures in unraisable plugin hook
graingert Nov 19, 2024
a285cd5
refactor tracemalloc message
graingert Nov 20, 2024
f853b2c
test multiple errors
graingert Nov 20, 2024
34b3683
hide from the branch coverage check
graingert Nov 20, 2024
04705b4
test failure in unraisable processing
graingert Nov 20, 2024
4f8aeef
test that errors due to cyclic grabage are collected eventually
graingert Nov 20, 2024
90aa800
handle errors on <3.10
graingert Nov 20, 2024
055e673
use trylast=True instead of a wrapper
graingert Nov 20, 2024
3d6a023
Update testing/test_unraisableexception.py
graingert Nov 20, 2024
02ada4b
Update src/_pytest/unraisableexception.py
graingert Nov 20, 2024
84e7311
add newsfragment
graingert Nov 21, 2024
086c418
refactor gc_collect_harder
graingert Nov 21, 2024
6a76066
cleanup args/kwargs
graingert Nov 21, 2024
2cc6648
make unraisablehook last-resort exception handling as simple as possible
graingert Nov 21, 2024
77c5126
use assert_outcomes
graingert Nov 21, 2024
4741611
Update changelog/12958.improvement.rst
graingert Nov 21, 2024
a554f2a
Update src/_pytest/unraisableexception.py
graingert Nov 21, 2024
f33fb85
Update src/_pytest/unraisableexception.py
graingert Nov 21, 2024
af13058
Update src/_pytest/unraisableexception.py
graingert Nov 21, 2024
69d0b22
remove private prefix
graingert Nov 22, 2024
b0978e0
put unraisable deque in config.stash
graingert Nov 22, 2024
740bafb
cleanup stash when finished
graingert Nov 22, 2024
b6e50ff
Merge branch 'main' into set-unraisable-hook-per-session
graingert Nov 25, 2024
b436d1c
Merge branch 'main' into set-unraisable-hook-per-session
graingert Nov 25, 2024
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
9 changes: 9 additions & 0 deletions changelog/12958.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
A number of :ref:`unraisable <unraisable>` enhancements:

* Set the unraisable hook as early as possible and unset it as late as possible, to collect the most possible number of unraisable exceptions.
* Call the garbage collector just before unsetting the unraisable hook, to collect any straggling exceptions.
* Collect multiple unraisable exceptions per test phase.
* Report the :mod:`tracemalloc` allocation traceback (if available).
* Avoid using a generator based hook to allow handling :class:`StopIteration` in test failures.
* Report the unraisable exception as the cause of the :class:`pytest.PytestUnraisableExceptionWarning` exception if raised.
* Compute the ``repr`` of the unraisable object in the unraisable hook so you get the latest information if available, and should help with resurrection of the object.
24 changes: 24 additions & 0 deletions src/_pytest/tracemalloc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from __future__ import annotations


def tracemalloc_message(source: object) -> str:
if source is None:
return ""

try:
import tracemalloc
except ImportError:
return ""

Check warning on line 11 in src/_pytest/tracemalloc.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/tracemalloc.py#L10-L11

Added lines #L10 - L11 were not covered by tests
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be covered on pypy

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leave that comment in the code? More likely for future maintainers to see it that way 🙂

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah what I mean to say is that the coverage check is failing for this branch, but it shouldn't and I don't know why


tb = tracemalloc.get_object_traceback(source)
if tb is not None:
formatted_tb = "\n".join(tb.format())
# Use a leading new line to better separate the (large) output
# from the traceback to the previous warning text.
return f"\nObject allocated at:\n{formatted_tb}"
# No need for a leading new line.
url = "https://docs.pytest.org/en/stable/how-to/capture-warnings.html#resource-warnings"
return (
"Enable tracemalloc to get traceback where the object was allocated.\n"
f"See {url} for more info."
)
221 changes: 139 additions & 82 deletions src/_pytest/unraisableexception.py
Original file line number Diff line number Diff line change
@@ -1,100 +1,157 @@
from __future__ import annotations

import collections
import functools
import gc
import sys
import traceback
from types import TracebackType
from typing import Any
from typing import Callable
from typing import Generator
from typing import NamedTuple
from typing import TYPE_CHECKING
import warnings

from _pytest.config import Config
from _pytest.nodes import Item
from _pytest.stash import StashKey
from _pytest.tracemalloc import tracemalloc_message
import pytest


if TYPE_CHECKING:
from typing_extensions import Self


# Copied from cpython/Lib/test/support/__init__.py, with modifications.
class catch_unraisable_exception:
"""Context manager catching unraisable exception using sys.unraisablehook.

Storing the exception value (cm.unraisable.exc_value) creates a reference
cycle. The reference cycle is broken explicitly when the context manager
exits.

Storing the object (cm.unraisable.object) can resurrect it if it is set to
an object which is being finalized. Exiting the context manager clears the
stored object.

Usage:
with catch_unraisable_exception() as cm:
# code creating an "unraisable exception"
...
# check the unraisable exception: use cm.unraisable
...
# cm.unraisable attribute no longer exists at this point
# (to break a reference cycle)
"""

def __init__(self) -> None:
self.unraisable: sys.UnraisableHookArgs | None = None
self._old_hook: Callable[[sys.UnraisableHookArgs], Any] | None = None

def _hook(self, unraisable: sys.UnraisableHookArgs) -> None:
# Storing unraisable.object can resurrect an object which is being
# finalized. Storing unraisable.exc_value creates a reference cycle.
self.unraisable = unraisable

def __enter__(self) -> Self:
self._old_hook = sys.unraisablehook
sys.unraisablehook = self._hook
return self

def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
assert self._old_hook is not None
sys.unraisablehook = self._old_hook
self._old_hook = None
del self.unraisable


def unraisable_exception_runtest_hook() -> Generator[None]:
with catch_unraisable_exception() as cm:
try:
yield
finally:
if cm.unraisable:
if cm.unraisable.err_msg is not None:
err_msg = cm.unraisable.err_msg
else:
err_msg = "Exception ignored in"
msg = f"{err_msg}: {cm.unraisable.object!r}\n\n"
msg += "".join(
traceback.format_exception(
cm.unraisable.exc_type,
cm.unraisable.exc_value,
cm.unraisable.exc_traceback,
)
)
warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))
pass

if sys.version_info < (3, 11):
from exceptiongroup import ExceptionGroup


def gc_collect_harder() -> None:
# Constant determined experimentally by the Trio project.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Constant determined experimentally by the Trio project.
# A single collection doesn't necessarily collect everything.
# Constant determined experimentally by the Trio project.

for _ in range(5):
gc.collect()


@pytest.hookimpl(wrapper=True, tryfirst=True)
def pytest_runtest_setup() -> Generator[None]:
yield from unraisable_exception_runtest_hook()
class UnraisableMeta(NamedTuple):
graingert marked this conversation as resolved.
Show resolved Hide resolved
msg: str
cause_msg: str
exc_value: BaseException | None


@pytest.hookimpl(wrapper=True, tryfirst=True)
def pytest_runtest_call() -> Generator[None]:
yield from unraisable_exception_runtest_hook()
unraisable_exceptions: StashKey[collections.deque[UnraisableMeta | BaseException]] = (
StashKey()
)


@pytest.hookimpl(wrapper=True, tryfirst=True)
def pytest_runtest_teardown() -> Generator[None]:
yield from unraisable_exception_runtest_hook()
def collect_unraisable(config: Config) -> None:
pop_unraisable = config.stash[unraisable_exceptions].pop
errors: list[pytest.PytestUnraisableExceptionWarning | RuntimeError] = []
meta = None
hook_error = None
try:
while True:
try:
meta = pop_unraisable()
except IndexError:
break

if isinstance(meta, BaseException):
hook_error = RuntimeError("Failed to process unraisable exception")
hook_error.__cause__ = meta
errors.append(hook_error)
continue

msg = meta.msg
try:
warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))
except pytest.PytestUnraisableExceptionWarning as e:
# This except happens when the warning is treated as an error (e.g. `-Werror`).
if meta.exc_value is not None:
# Exceptions have a better way to show the traceback, but
# warnings do not, so hide the traceback from the msg and
# set the cause so the traceback shows up in the right place.
e.args = (meta.cause_msg,)
e.__cause__ = meta.exc_value
errors.append(e)

if len(errors) == 1:
raise errors[0]
if errors:
raise ExceptionGroup("multiple unraisable exception warnings", errors)
finally:
del errors, meta, hook_error


def cleanup(
*, config: Config, prev_hook: Callable[[sys.UnraisableHookArgs], object]
) -> None:
try:
try:
gc_collect_harder()
collect_unraisable(config)
finally:
sys.unraisablehook = prev_hook
finally:
del config.stash[unraisable_exceptions]


def unraisable_hook(
unraisable: sys.UnraisableHookArgs,
/,
*,
append: Callable[[UnraisableMeta | BaseException], object],
) -> None:
try:
graingert marked this conversation as resolved.
Show resolved Hide resolved
err_msg = (
"Exception ignored in" if unraisable.err_msg is None else unraisable.err_msg
)
summary = f"{err_msg}: {unraisable.object!r}"
traceback_message = "\n\n" + "".join(
traceback.format_exception(
unraisable.exc_type,
unraisable.exc_value,
unraisable.exc_traceback,
)
)
tracemalloc_tb = tracemalloc_message(unraisable.object)
msg = summary + traceback_message + tracemalloc_tb
cause_msg = summary + tracemalloc_tb
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some separator is needed here, e.g.

Suggested change
cause_msg = summary + tracemalloc_tb
cause_msg = summary + "\n" + tracemalloc_tb

although are you sure you want to include tracemalloc_tb here? Maybe it's better to keep this a single line?


append(
UnraisableMeta(
# we need to compute these strings here as they might change after
# the unraisablehook finishes and before the unraisable object is
# collected by a hook
msg=msg,
cause_msg=cause_msg,
exc_value=unraisable.exc_value,
)
)
except BaseException as e:
append(e)
# Raising this will cause the exception to be logged twice, once in our
# collect_unraisable and once by the unraisablehook calling machinery
# which is fine - this should never happen anyway and if it does
# it should probably be reported as a pytest bug.
raise


def pytest_configure(config: Config) -> None:
prev_hook = sys.unraisablehook
deque: collections.deque[UnraisableMeta | BaseException] = collections.deque()
config.stash[unraisable_exceptions] = deque
config.add_cleanup(functools.partial(cleanup, config=config, prev_hook=prev_hook))
sys.unraisablehook = functools.partial(unraisable_hook, append=deque.append)


@pytest.hookimpl(trylast=True)
def pytest_runtest_setup(item: Item) -> None:
collect_unraisable(item.config)


@pytest.hookimpl(trylast=True)
def pytest_runtest_call(item: Item) -> None:
collect_unraisable(item.config)


@pytest.hookimpl(trylast=True)
def pytest_runtest_teardown(item: Item) -> None:
collect_unraisable(item.config)
26 changes: 4 additions & 22 deletions src/_pytest/warnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from _pytest.main import Session
from _pytest.nodes import Item
from _pytest.terminal import TerminalReporter
from _pytest.tracemalloc import tracemalloc_message
import pytest


Expand Down Expand Up @@ -76,32 +77,13 @@ def catch_warnings_for_item(

def warning_record_to_str(warning_message: warnings.WarningMessage) -> str:
"""Convert a warnings.WarningMessage to a string."""
warn_msg = warning_message.message
msg = warnings.formatwarning(
str(warn_msg),
return warnings.formatwarning(
str(warning_message.message),
warning_message.category,
warning_message.filename,
warning_message.lineno,
warning_message.line,
)
if warning_message.source is not None:
try:
import tracemalloc
except ImportError:
pass
else:
tb = tracemalloc.get_object_traceback(warning_message.source)
if tb is not None:
formatted_tb = "\n".join(tb.format())
# Use a leading new line to better separate the (large) output
# from the traceback to the previous warning text.
msg += f"\nObject allocated at:\n{formatted_tb}"
else:
# No need for a leading new line.
url = "https://docs.pytest.org/en/stable/how-to/capture-warnings.html#resource-warnings"
msg += "Enable tracemalloc to get traceback where the object was allocated.\n"
msg += f"See {url} for more info."
return msg
) + tracemalloc_message(warning_message.source)


@pytest.hookimpl(wrapper=True, tryfirst=True)
Expand Down
Loading
Loading