-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
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
Add unraisableexception and threadexception plugins #8055
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
pytest now warns about unraisable exceptions and unhandled thread exceptions that occur in tests on Python>=3.8. | ||
See :ref:`unraisable` for more information. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
import threading | ||
import traceback | ||
import warnings | ||
from types import TracebackType | ||
from typing import Any | ||
from typing import Callable | ||
from typing import Generator | ||
from typing import Optional | ||
from typing import Type | ||
|
||
import pytest | ||
|
||
|
||
# Copied from cpython/Lib/test/support/threading_helper.py, with modifications. | ||
class catch_threading_exception: | ||
"""Context manager catching threading.Thread exception using | ||
threading.excepthook. | ||
|
||
Storing exc_value using a custom hook can create a reference cycle. The | ||
reference cycle is broken explicitly when the context manager exits. | ||
|
||
Storing thread using a custom hook can resurrect it if it is set to an | ||
object which is being finalized. Exiting the context manager clears the | ||
stored object. | ||
|
||
Usage: | ||
with threading_helper.catch_threading_exception() as cm: | ||
# code spawning a thread which raises an exception | ||
... | ||
# check the thread exception: use cm.args | ||
... | ||
# cm.args attribute no longer exists at this point | ||
# (to break a reference cycle) | ||
""" | ||
|
||
def __init__(self) -> None: | ||
# See https://github.com/python/typeshed/issues/4767 regarding the underscore. | ||
self.args: Optional["threading._ExceptHookArgs"] = None | ||
self._old_hook: Optional[Callable[["threading._ExceptHookArgs"], Any]] = None | ||
|
||
def _hook(self, args: "threading._ExceptHookArgs") -> None: | ||
self.args = args | ||
|
||
def __enter__(self) -> "catch_threading_exception": | ||
self._old_hook = threading.excepthook | ||
threading.excepthook = self._hook | ||
return self | ||
|
||
def __exit__( | ||
self, | ||
exc_type: Optional[Type[BaseException]], | ||
exc_val: Optional[BaseException], | ||
exc_tb: Optional[TracebackType], | ||
) -> None: | ||
assert self._old_hook is not None | ||
threading.excepthook = self._old_hook | ||
self._old_hook = None | ||
del self.args | ||
|
||
|
||
def thread_exception_runtest_hook() -> Generator[None, None, None]: | ||
with catch_threading_exception() as cm: | ||
yield | ||
if cm.args: | ||
if cm.args.thread is not None: | ||
thread_name = cm.args.thread.name | ||
else: | ||
thread_name = "<unknown>" | ||
msg = f"Exception in thread {thread_name}\n\n" | ||
msg += "".join( | ||
traceback.format_exception( | ||
cm.args.exc_type, cm.args.exc_value, cm.args.exc_traceback, | ||
) | ||
) | ||
warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg)) | ||
|
||
|
||
@pytest.hookimpl(hookwrapper=True, trylast=True) | ||
def pytest_runtest_setup() -> Generator[None, None, None]: | ||
yield from thread_exception_runtest_hook() | ||
|
||
|
||
@pytest.hookimpl(hookwrapper=True, tryfirst=True) | ||
def pytest_runtest_call() -> Generator[None, None, None]: | ||
yield from thread_exception_runtest_hook() | ||
|
||
|
||
@pytest.hookimpl(hookwrapper=True, tryfirst=True) | ||
def pytest_runtest_teardown() -> Generator[None, None, None]: | ||
yield from thread_exception_runtest_hook() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
import sys | ||
import traceback | ||
import warnings | ||
from types import TracebackType | ||
from typing import Any | ||
from typing import Callable | ||
from typing import Generator | ||
from typing import Optional | ||
from typing import Type | ||
|
||
import pytest | ||
|
||
|
||
# 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: Optional["sys.UnraisableHookArgs"] = None | ||
self._old_hook: Optional[Callable[["sys.UnraisableHookArgs"], Any]] = 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) -> "catch_unraisable_exception": | ||
self._old_hook = sys.unraisablehook | ||
sys.unraisablehook = self._hook | ||
return self | ||
|
||
def __exit__( | ||
self, | ||
exc_type: Optional[Type[BaseException]], | ||
exc_val: Optional[BaseException], | ||
exc_tb: Optional[TracebackType], | ||
) -> 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, None, None]: | ||
with catch_unraisable_exception() as cm: | ||
yield | ||
if cm.unraisable: | ||
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. would it be sensible to move this into the context manager, then the yield from generator hack wouldn't have to be used 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. I prefer to keep the context manager itself standalone from pytest stuff just in case it's useful on its own. |
||
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)) | ||
|
||
|
||
@pytest.hookimpl(hookwrapper=True, tryfirst=True) | ||
def pytest_runtest_setup() -> Generator[None, None, None]: | ||
yield from unraisable_exception_runtest_hook() | ||
|
||
|
||
@pytest.hookimpl(hookwrapper=True, tryfirst=True) | ||
def pytest_runtest_call() -> Generator[None, None, None]: | ||
yield from unraisable_exception_runtest_hook() | ||
|
||
|
||
@pytest.hookimpl(hookwrapper=True, tryfirst=True) | ||
def pytest_runtest_teardown() -> Generator[None, None, None]: | ||
yield from unraisable_exception_runtest_hook() |
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.
Great docs!