-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
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
graingert
wants to merge
32
commits into
pytest-dev:main
Choose a base branch
from
graingert:set-unraisable-hook-per-session
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+278
−111
Open
unraisablehook enhancements #12958
Changes from all commits
Commits
Show all changes
32 commits
Select commit
Hold shift + click to select a range
85594a7
unraisablehook enhancements
graingert b79f652
only raise errors if there are any
graingert 194f9ec
cause the traceback to only show up once
graingert c78f84b
include tracemalloc message in unraisablehook failures
graingert 7f30217
fix tests
graingert d2045ce
avoid keeping unraisable.object alive while waiting for collection
graingert 7e7c965
use the formatted traceback if there's no exc_value
graingert b0f803d
Merge branch 'main' into set-unraisable-hook-per-session
graingert d540ada
handle failures in unraisable plugin hook
graingert a285cd5
refactor tracemalloc message
graingert f853b2c
test multiple errors
graingert 34b3683
hide from the branch coverage check
graingert 04705b4
test failure in unraisable processing
graingert 4f8aeef
test that errors due to cyclic grabage are collected eventually
graingert 90aa800
handle errors on <3.10
graingert 055e673
use trylast=True instead of a wrapper
graingert 3d6a023
Update testing/test_unraisableexception.py
graingert 02ada4b
Update src/_pytest/unraisableexception.py
graingert 84e7311
add newsfragment
graingert 086c418
refactor gc_collect_harder
graingert 6a76066
cleanup args/kwargs
graingert 2cc6648
make unraisablehook last-resort exception handling as simple as possible
graingert 77c5126
use assert_outcomes
graingert 4741611
Update changelog/12958.improvement.rst
graingert a554f2a
Update src/_pytest/unraisableexception.py
graingert f33fb85
Update src/_pytest/unraisableexception.py
graingert af13058
Update src/_pytest/unraisableexception.py
graingert 69d0b22
remove private prefix
graingert b0978e0
put unraisable deque in config.stash
graingert 740bafb
cleanup stash when finished
graingert b6e50ff
Merge branch 'main' into set-unraisable-hook-per-session
graingert b436d1c
Merge branch 'main' into set-unraisable-hook-per-session
graingert File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 "" | ||
|
||
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." | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
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 | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Some separator is needed here, e.g.
Suggested change
although are you sure you want to include |
||||||||
|
||||||||
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 🙂
There was a problem hiding this comment.
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