summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorRan Benita <ran@unusedvar.com>2020-12-05 21:52:17 +0200
committerGitHub <noreply@github.com>2020-12-05 21:52:17 +0200
commite398c93884f228c9808adf9d0be2f876719e703d (patch)
tree31425dd4f72b2b88f5a7c558f1fe3a7fdfff7b69 /src
parent760a73c08ce51d6556086988aee749978334ef91 (diff)
parentd50df85e26b28e28ab5fb9a50df791068a051192 (diff)
downloadpytest-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__.py1
-rw-r--r--src/_pytest/pytester.py2
-rw-r--r--src/_pytest/threadexception.py90
-rw-r--r--src/_pytest/unraisableexception.py93
-rw-r--r--src/_pytest/warning_types.py22
-rw-r--r--src/pytest/__init__.py4
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",