Skip to content

Commit 85594a7

Browse files
committed
unraisablehook enhancements
1 parent 76b8870 commit 85594a7

File tree

1 file changed

+70
-67
lines changed

1 file changed

+70
-67
lines changed

src/_pytest/unraisableexception.py

Lines changed: 70 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,91 @@
11
from __future__ import annotations
22

3+
import collections
4+
import functools
5+
import gc
36
import sys
47
import traceback
5-
from types import TracebackType
6-
from typing import Any
78
from typing import Callable
89
from typing import Generator
910
from typing import TYPE_CHECKING
1011
import warnings
1112

13+
from _pytest.config import Config
1214
import pytest
1315

1416

1517
if TYPE_CHECKING:
16-
from typing_extensions import Self
17-
18-
19-
# Copied from cpython/Lib/test/support/__init__.py, with modifications.
20-
class catch_unraisable_exception:
21-
"""Context manager catching unraisable exception using sys.unraisablehook.
22-
23-
Storing the exception value (cm.unraisable.exc_value) creates a reference
24-
cycle. The reference cycle is broken explicitly when the context manager
25-
exits.
26-
27-
Storing the object (cm.unraisable.object) can resurrect it if it is set to
28-
an object which is being finalized. Exiting the context manager clears the
29-
stored object.
30-
31-
Usage:
32-
with catch_unraisable_exception() as cm:
33-
# code creating an "unraisable exception"
34-
...
35-
# check the unraisable exception: use cm.unraisable
36-
...
37-
# cm.unraisable attribute no longer exists at this point
38-
# (to break a reference cycle)
39-
"""
40-
41-
def __init__(self) -> None:
42-
self.unraisable: sys.UnraisableHookArgs | None = None
43-
self._old_hook: Callable[[sys.UnraisableHookArgs], Any] | None = None
44-
45-
def _hook(self, unraisable: sys.UnraisableHookArgs) -> None:
46-
# Storing unraisable.object can resurrect an object which is being
47-
# finalized. Storing unraisable.exc_value creates a reference cycle.
48-
self.unraisable = unraisable
49-
50-
def __enter__(self) -> Self:
51-
self._old_hook = sys.unraisablehook
52-
sys.unraisablehook = self._hook
53-
return self
54-
55-
def __exit__(
56-
self,
57-
exc_type: type[BaseException] | None,
58-
exc_val: BaseException | None,
59-
exc_tb: TracebackType | None,
60-
) -> None:
61-
assert self._old_hook is not None
62-
sys.unraisablehook = self._old_hook
63-
self._old_hook = None
64-
del self.unraisable
18+
pass
19+
20+
if sys.version_info < (3, 11):
21+
from exceptiongroup import ExceptionGroup
6522

6623

6724
def unraisable_exception_runtest_hook() -> Generator[None]:
68-
with catch_unraisable_exception() as cm:
69-
try:
70-
yield
71-
finally:
72-
if cm.unraisable:
73-
if cm.unraisable.err_msg is not None:
74-
err_msg = cm.unraisable.err_msg
75-
else:
76-
err_msg = "Exception ignored in"
77-
msg = f"{err_msg}: {cm.unraisable.object!r}\n\n"
78-
msg += "".join(
79-
traceback.format_exception(
80-
cm.unraisable.exc_type,
81-
cm.unraisable.exc_value,
82-
cm.unraisable.exc_traceback,
83-
)
25+
try:
26+
yield
27+
finally:
28+
collect_unraisable()
29+
30+
31+
_unraisable_exceptions: collections.deque[tuple[str, sys.UnraisableHookArgs]] = (
32+
collections.deque()
33+
)
34+
35+
36+
def collect_unraisable() -> None:
37+
errors = []
38+
unraisable = None
39+
try:
40+
while True:
41+
try:
42+
object_repr, unraisable = _unraisable_exceptions.pop()
43+
except IndexError:
44+
break
45+
46+
if unraisable.err_msg is not None:
47+
err_msg = unraisable.err_msg
48+
else:
49+
err_msg = "Exception ignored in"
50+
msg = f"{err_msg}: {object_repr}\n\n"
51+
msg += "".join(
52+
traceback.format_exception(
53+
unraisable.exc_type,
54+
unraisable.exc_value,
55+
unraisable.exc_traceback,
8456
)
57+
)
58+
try:
8559
warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))
60+
except pytest.PytestUnraisableExceptionWarning as e:
61+
e.__cause__ = unraisable.exc_value
62+
errors.append(e)
63+
64+
if len(errors) == 1:
65+
raise errors[0]
66+
else:
67+
raise ExceptionGroup("multiple unraisable exception warnings", errors)
68+
finally:
69+
del errors, unraisable
70+
71+
72+
def _cleanup(prev_hook: Callable[[sys.UnraisableHookArgs], object]) -> None:
73+
try:
74+
for i in range(5):
75+
gc.collect()
76+
collect_unraisable()
77+
finally:
78+
sys.unraisablehook = prev_hook
79+
80+
81+
def unraisable_hook(unraisable: sys.UnraisableHookArgs) -> None:
82+
_unraisable_exceptions.append((repr(unraisable.object), unraisable))
83+
84+
85+
def pytest_configure(config: Config) -> None:
86+
prev_hook = sys.unraisablehook
87+
config.add_cleanup(functools.partial(_cleanup, prev_hook))
88+
sys.unraisablehook = unraisable_hook
8689

8790

8891
@pytest.hookimpl(wrapper=True, tryfirst=True)

0 commit comments

Comments
 (0)