diff options
author | Ran Benita <ran@unusedvar.com> | 2020-12-05 21:52:17 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-12-05 21:52:17 +0200 |
commit | e398c93884f228c9808adf9d0be2f876719e703d (patch) | |
tree | 31425dd4f72b2b88f5a7c558f1fe3a7fdfff7b69 /src | |
parent | 760a73c08ce51d6556086988aee749978334ef91 (diff) | |
parent | d50df85e26b28e28ab5fb9a50df791068a051192 (diff) | |
download | pytest-e398c93884f228c9808adf9d0be2f876719e703d.tar.gz |
Merge pull request #8055 from bluetech/unraisable
Add unraisableexception and threadexception plugins
Diffstat (limited to 'src')
-rw-r--r-- | src/_pytest/config/__init__.py | 1 | ||||
-rw-r--r-- | src/_pytest/pytester.py | 2 | ||||
-rw-r--r-- | src/_pytest/threadexception.py | 90 | ||||
-rw-r--r-- | src/_pytest/unraisableexception.py | 93 | ||||
-rw-r--r-- | src/_pytest/warning_types.py | 22 | ||||
-rw-r--r-- | src/pytest/__init__.py | 4 |
6 files changed, 211 insertions, 1 deletions
diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 74168efd5..bd9e2883f 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -251,6 +251,7 @@ default_plugins = essential_plugins + ( "warnings", "logging", "reports", + *(["unraisableexception", "threadexception"] if sys.version_info >= (3, 8) else []), "faulthandler", ) diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 510d3aa60..6833eb021 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -1350,7 +1350,7 @@ class Pytester: stderr=f2, close_fds=(sys.platform != "win32"), ) - if isinstance(stdin, bytes): + if popen.stdin is not None: popen.stdin.close() def handle_timeout() -> None: diff --git a/src/_pytest/threadexception.py b/src/_pytest/threadexception.py new file mode 100644 index 000000000..1c1f62fdb --- /dev/null +++ b/src/_pytest/threadexception.py @@ -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() diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py new file mode 100644 index 000000000..fcb5d8237 --- /dev/null +++ b/src/_pytest/unraisableexception.py @@ -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: + 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() diff --git a/src/_pytest/warning_types.py b/src/_pytest/warning_types.py index 2fd4d4f6e..2eadd9fe4 100644 --- a/src/_pytest/warning_types.py +++ b/src/_pytest/warning_types.py @@ -90,6 +90,28 @@ class PytestUnknownMarkWarning(PytestWarning): __module__ = "pytest" +@final +class PytestUnraisableExceptionWarning(PytestWarning): + """An unraisable exception was reported. + + Unraisable exceptions are exceptions raised in :meth:`__del__ <object.__del__>` + implementations and similar situations when the exception cannot be raised + as normal. + """ + + __module__ = "pytest" + + +@final +class PytestUnhandledThreadExceptionWarning(PytestWarning): + """An unhandled exception occurred in a :class:`~threading.Thread`. + + Such exceptions don't propagate normally. + """ + + __module__ = "pytest" + + _W = TypeVar("_W", bound=PytestWarning) diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index 8af095ea8..70177f950 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -53,7 +53,9 @@ from _pytest.warning_types import PytestConfigWarning from _pytest.warning_types import PytestDeprecationWarning from _pytest.warning_types import PytestExperimentalApiWarning from _pytest.warning_types import PytestUnhandledCoroutineWarning +from _pytest.warning_types import PytestUnhandledThreadExceptionWarning from _pytest.warning_types import PytestUnknownMarkWarning +from _pytest.warning_types import PytestUnraisableExceptionWarning from _pytest.warning_types import PytestWarning set_trace = __pytestPDB.set_trace @@ -99,7 +101,9 @@ __all__ = [ "PytestExperimentalApiWarning", "Pytester", "PytestUnhandledCoroutineWarning", + "PytestUnhandledThreadExceptionWarning", "PytestUnknownMarkWarning", + "PytestUnraisableExceptionWarning", "PytestWarning", "raises", "register_assert_rewrite", |