Skip to content

Commit

Permalink
Add unraisableexception and threadexception plugins
Browse files Browse the repository at this point in the history
  • Loading branch information
bluetech committed Nov 20, 2020
1 parent 148e3c5 commit bef21d7
Show file tree
Hide file tree
Showing 10 changed files with 520 additions and 0 deletions.
2 changes: 2 additions & 0 deletions changelog/5299.feature.rst
@@ -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.
6 changes: 6 additions & 0 deletions doc/en/reference.rst
Expand Up @@ -1090,6 +1090,12 @@ Custom warnings generated in some situations such as improper usage or deprecate
.. autoclass:: pytest.PytestUnknownMarkWarning
:show-inheritance:

.. autoclass:: pytest.PytestUnraisableExceptionWarning
:show-inheritance:

.. autoclass:: pytest.PytestUnhandledThreadExceptionWarning
:show-inheritance:


Consult the :ref:`internal-warnings` section in the documentation for more information.

Expand Down
32 changes: 32 additions & 0 deletions doc/en/usage.rst
Expand Up @@ -470,6 +470,38 @@ seconds to finish (not available on Windows).
the command-line using ``-o faulthandler_timeout=X``.


.. _unraisable:

Warning about unraisable exceptions and unhandled thread exceptions
-------------------------------------------------------------------

.. versionadded:: 6.2

.. note::

These features only work on Python>=3.8.

Unhandled exceptions are exceptions that are raised in a situation in which
they cannot propagate to a caller. The most common case is an exception raised
in a :meth:`__del__ <object.__del__>` implementation.

Unhandled thread exceptions are exceptions raised in a :class:`~threading.Thread`
but not handled, causing the thread to terminate uncleanly.

Both types of exceptions are normally considered bugs, but may go unnoticed
because they don't cause the program itself to crash. Pytest detects these
conditions and issues a warning that is visible in the test run summary.

The modules are automatically enabled for pytest runs, unless the
``-p no:unraisableexception`` (for unraisable exceptions) and
``-p no:threadexception`` (for thread exceptions) options are given on the
command-line.

The warnings may be silenced selectivly using the :ref:`pytest.mark.filterwarnings ref`
mark. The warning categories are :class:`pytest.PytestUnraisableExceptionWarning` and
:class:`pytest.PytestUnhandledThreadExceptionWarning`.


Creating JUnitXML format files
----------------------------------------------------

Expand Down
1 change: 1 addition & 0 deletions src/_pytest/config/__init__.py
Expand Up @@ -251,6 +251,7 @@ def directory_arg(path: str, optname: str) -> str:
"warnings",
"logging",
"reports",
*(["unraisableexception", "threadexception"] if sys.version_info >= (3, 8) else []),
"faulthandler",
)

Expand Down
90 changes: 90 additions & 0 deletions 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()
93 changes: 93 additions & 0 deletions 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()
22 changes: 22 additions & 0 deletions src/_pytest/warning_types.py
Expand Up @@ -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)


Expand Down
4 changes: 4 additions & 0 deletions src/pytest/__init__.py
Expand Up @@ -44,7 +44,9 @@
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
Expand Down Expand Up @@ -85,7 +87,9 @@
"PytestDeprecationWarning",
"PytestExperimentalApiWarning",
"PytestUnhandledCoroutineWarning",
"PytestUnhandledThreadExceptionWarning",
"PytestUnknownMarkWarning",
"PytestUnraisableExceptionWarning",
"PytestWarning",
"raises",
"register_assert_rewrite",
Expand Down

0 comments on commit bef21d7

Please sign in to comment.