Skip to content

Commit e398c93

Browse files
authored
Merge pull request #8055 from bluetech/unraisable
Add unraisableexception and threadexception plugins
2 parents 760a73c + d50df85 commit e398c93

12 files changed

+524
-1
lines changed

changelog/5299.feature.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pytest now warns about unraisable exceptions and unhandled thread exceptions that occur in tests on Python>=3.8.
2+
See :ref:`unraisable` for more information.

doc/en/reference.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,6 +1084,12 @@ Custom warnings generated in some situations such as improper usage or deprecate
10841084
.. autoclass:: pytest.PytestUnknownMarkWarning
10851085
:show-inheritance:
10861086

1087+
.. autoclass:: pytest.PytestUnraisableExceptionWarning
1088+
:show-inheritance:
1089+
1090+
.. autoclass:: pytest.PytestUnhandledThreadExceptionWarning
1091+
:show-inheritance:
1092+
10871093

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

doc/en/usage.rst

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,38 @@ seconds to finish (not available on Windows).
470470
the command-line using ``-o faulthandler_timeout=X``.
471471

472472

473+
.. _unraisable:
474+
475+
Warning about unraisable exceptions and unhandled thread exceptions
476+
-------------------------------------------------------------------
477+
478+
.. versionadded:: 6.2
479+
480+
.. note::
481+
482+
These features only work on Python>=3.8.
483+
484+
Unhandled exceptions are exceptions that are raised in a situation in which
485+
they cannot propagate to a caller. The most common case is an exception raised
486+
in a :meth:`__del__ <object.__del__>` implementation.
487+
488+
Unhandled thread exceptions are exceptions raised in a :class:`~threading.Thread`
489+
but not handled, causing the thread to terminate uncleanly.
490+
491+
Both types of exceptions are normally considered bugs, but may go unnoticed
492+
because they don't cause the program itself to crash. Pytest detects these
493+
conditions and issues a warning that is visible in the test run summary.
494+
495+
The plugins are automatically enabled for pytest runs, unless the
496+
``-p no:unraisableexception`` (for unraisable exceptions) and
497+
``-p no:threadexception`` (for thread exceptions) options are given on the
498+
command-line.
499+
500+
The warnings may be silenced selectivly using the :ref:`pytest.mark.filterwarnings ref`
501+
mark. The warning categories are :class:`pytest.PytestUnraisableExceptionWarning` and
502+
:class:`pytest.PytestUnhandledThreadExceptionWarning`.
503+
504+
473505
Creating JUnitXML format files
474506
----------------------------------------------------
475507

src/_pytest/config/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ def directory_arg(path: str, optname: str) -> str:
251251
"warnings",
252252
"logging",
253253
"reports",
254+
*(["unraisableexception", "threadexception"] if sys.version_info >= (3, 8) else []),
254255
"faulthandler",
255256
)
256257

src/_pytest/pytester.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1350,7 +1350,7 @@ def run(
13501350
stderr=f2,
13511351
close_fds=(sys.platform != "win32"),
13521352
)
1353-
if isinstance(stdin, bytes):
1353+
if popen.stdin is not None:
13541354
popen.stdin.close()
13551355

13561356
def handle_timeout() -> None:

src/_pytest/threadexception.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import threading
2+
import traceback
3+
import warnings
4+
from types import TracebackType
5+
from typing import Any
6+
from typing import Callable
7+
from typing import Generator
8+
from typing import Optional
9+
from typing import Type
10+
11+
import pytest
12+
13+
14+
# Copied from cpython/Lib/test/support/threading_helper.py, with modifications.
15+
class catch_threading_exception:
16+
"""Context manager catching threading.Thread exception using
17+
threading.excepthook.
18+
19+
Storing exc_value using a custom hook can create a reference cycle. The
20+
reference cycle is broken explicitly when the context manager exits.
21+
22+
Storing thread using a custom hook can resurrect it if it is set to an
23+
object which is being finalized. Exiting the context manager clears the
24+
stored object.
25+
26+
Usage:
27+
with threading_helper.catch_threading_exception() as cm:
28+
# code spawning a thread which raises an exception
29+
...
30+
# check the thread exception: use cm.args
31+
...
32+
# cm.args attribute no longer exists at this point
33+
# (to break a reference cycle)
34+
"""
35+
36+
def __init__(self) -> None:
37+
# See https://github.com/python/typeshed/issues/4767 regarding the underscore.
38+
self.args: Optional["threading._ExceptHookArgs"] = None
39+
self._old_hook: Optional[Callable[["threading._ExceptHookArgs"], Any]] = None
40+
41+
def _hook(self, args: "threading._ExceptHookArgs") -> None:
42+
self.args = args
43+
44+
def __enter__(self) -> "catch_threading_exception":
45+
self._old_hook = threading.excepthook
46+
threading.excepthook = self._hook
47+
return self
48+
49+
def __exit__(
50+
self,
51+
exc_type: Optional[Type[BaseException]],
52+
exc_val: Optional[BaseException],
53+
exc_tb: Optional[TracebackType],
54+
) -> None:
55+
assert self._old_hook is not None
56+
threading.excepthook = self._old_hook
57+
self._old_hook = None
58+
del self.args
59+
60+
61+
def thread_exception_runtest_hook() -> Generator[None, None, None]:
62+
with catch_threading_exception() as cm:
63+
yield
64+
if cm.args:
65+
if cm.args.thread is not None:
66+
thread_name = cm.args.thread.name
67+
else:
68+
thread_name = "<unknown>"
69+
msg = f"Exception in thread {thread_name}\n\n"
70+
msg += "".join(
71+
traceback.format_exception(
72+
cm.args.exc_type, cm.args.exc_value, cm.args.exc_traceback,
73+
)
74+
)
75+
warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg))
76+
77+
78+
@pytest.hookimpl(hookwrapper=True, trylast=True)
79+
def pytest_runtest_setup() -> Generator[None, None, None]:
80+
yield from thread_exception_runtest_hook()
81+
82+
83+
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
84+
def pytest_runtest_call() -> Generator[None, None, None]:
85+
yield from thread_exception_runtest_hook()
86+
87+
88+
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
89+
def pytest_runtest_teardown() -> Generator[None, None, None]:
90+
yield from thread_exception_runtest_hook()

src/_pytest/unraisableexception.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import sys
2+
import traceback
3+
import warnings
4+
from types import TracebackType
5+
from typing import Any
6+
from typing import Callable
7+
from typing import Generator
8+
from typing import Optional
9+
from typing import Type
10+
11+
import pytest
12+
13+
14+
# Copied from cpython/Lib/test/support/__init__.py, with modifications.
15+
class catch_unraisable_exception:
16+
"""Context manager catching unraisable exception using sys.unraisablehook.
17+
18+
Storing the exception value (cm.unraisable.exc_value) creates a reference
19+
cycle. The reference cycle is broken explicitly when the context manager
20+
exits.
21+
22+
Storing the object (cm.unraisable.object) can resurrect it if it is set to
23+
an object which is being finalized. Exiting the context manager clears the
24+
stored object.
25+
26+
Usage:
27+
with catch_unraisable_exception() as cm:
28+
# code creating an "unraisable exception"
29+
...
30+
# check the unraisable exception: use cm.unraisable
31+
...
32+
# cm.unraisable attribute no longer exists at this point
33+
# (to break a reference cycle)
34+
"""
35+
36+
def __init__(self) -> None:
37+
self.unraisable: Optional["sys.UnraisableHookArgs"] = None
38+
self._old_hook: Optional[Callable[["sys.UnraisableHookArgs"], Any]] = None
39+
40+
def _hook(self, unraisable: "sys.UnraisableHookArgs") -> None:
41+
# Storing unraisable.object can resurrect an object which is being
42+
# finalized. Storing unraisable.exc_value creates a reference cycle.
43+
self.unraisable = unraisable
44+
45+
def __enter__(self) -> "catch_unraisable_exception":
46+
self._old_hook = sys.unraisablehook
47+
sys.unraisablehook = self._hook
48+
return self
49+
50+
def __exit__(
51+
self,
52+
exc_type: Optional[Type[BaseException]],
53+
exc_val: Optional[BaseException],
54+
exc_tb: Optional[TracebackType],
55+
) -> None:
56+
assert self._old_hook is not None
57+
sys.unraisablehook = self._old_hook
58+
self._old_hook = None
59+
del self.unraisable
60+
61+
62+
def unraisable_exception_runtest_hook() -> Generator[None, None, None]:
63+
with catch_unraisable_exception() as cm:
64+
yield
65+
if cm.unraisable:
66+
if cm.unraisable.err_msg is not None:
67+
err_msg = cm.unraisable.err_msg
68+
else:
69+
err_msg = "Exception ignored in"
70+
msg = f"{err_msg}: {cm.unraisable.object!r}\n\n"
71+
msg += "".join(
72+
traceback.format_exception(
73+
cm.unraisable.exc_type,
74+
cm.unraisable.exc_value,
75+
cm.unraisable.exc_traceback,
76+
)
77+
)
78+
warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))
79+
80+
81+
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
82+
def pytest_runtest_setup() -> Generator[None, None, None]:
83+
yield from unraisable_exception_runtest_hook()
84+
85+
86+
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
87+
def pytest_runtest_call() -> Generator[None, None, None]:
88+
yield from unraisable_exception_runtest_hook()
89+
90+
91+
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
92+
def pytest_runtest_teardown() -> Generator[None, None, None]:
93+
yield from unraisable_exception_runtest_hook()

src/_pytest/warning_types.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,28 @@ class PytestUnknownMarkWarning(PytestWarning):
9090
__module__ = "pytest"
9191

9292

93+
@final
94+
class PytestUnraisableExceptionWarning(PytestWarning):
95+
"""An unraisable exception was reported.
96+
97+
Unraisable exceptions are exceptions raised in :meth:`__del__ <object.__del__>`
98+
implementations and similar situations when the exception cannot be raised
99+
as normal.
100+
"""
101+
102+
__module__ = "pytest"
103+
104+
105+
@final
106+
class PytestUnhandledThreadExceptionWarning(PytestWarning):
107+
"""An unhandled exception occurred in a :class:`~threading.Thread`.
108+
109+
Such exceptions don't propagate normally.
110+
"""
111+
112+
__module__ = "pytest"
113+
114+
93115
_W = TypeVar("_W", bound=PytestWarning)
94116

95117

src/pytest/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@
5353
from _pytest.warning_types import PytestDeprecationWarning
5454
from _pytest.warning_types import PytestExperimentalApiWarning
5555
from _pytest.warning_types import PytestUnhandledCoroutineWarning
56+
from _pytest.warning_types import PytestUnhandledThreadExceptionWarning
5657
from _pytest.warning_types import PytestUnknownMarkWarning
58+
from _pytest.warning_types import PytestUnraisableExceptionWarning
5759
from _pytest.warning_types import PytestWarning
5860

5961
set_trace = __pytestPDB.set_trace
@@ -99,7 +101,9 @@
99101
"PytestExperimentalApiWarning",
100102
"Pytester",
101103
"PytestUnhandledCoroutineWarning",
104+
"PytestUnhandledThreadExceptionWarning",
102105
"PytestUnknownMarkWarning",
106+
"PytestUnraisableExceptionWarning",
103107
"PytestWarning",
104108
"raises",
105109
"register_assert_rewrite",

testing/acceptance_test.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1315,3 +1315,6 @@ def test_no_brokenpipeerror_message(pytester: Pytester) -> None:
13151315
ret = popen.wait()
13161316
assert popen.stderr.read() == b""
13171317
assert ret == 1
1318+
1319+
# Cleanup.
1320+
popen.stderr.close()

0 commit comments

Comments
 (0)