Skip to content

Commit 4b32e7c

Browse files
graingertbluetechnicoddemus
authored
Enhancements to unraisable plugin (#12958)
A number of `unraisable` plugin enhancements: * Set the unraisablehook as early as possible and unset it as late as possible, to collect the most possible unraisable exceptions (please let me know if there's an earlier or later hook I can use). * Call the garbage collector just before unsetting the unraisablehook, to collect any straggling exceptions * Collect multiple unraisable exceptions per test phase. * Report the tracemalloc allocation traceback, if available. * Avoid using a generator based hook to allow handling StopIteration in test failures * Report the unraisable exception as the cause of the PytestUnraisableExceptionWarning exception if raised. * Compute the repr of the unraisable.object in the unraisablehook so you get the latest information if available, and should help with resurrection of the object. Co-authored-by: Ran Benita <[email protected]> Co-authored-by: Bruno Oliveira <[email protected]>
1 parent e087e3f commit 4b32e7c

File tree

5 files changed

+279
-108
lines changed

5 files changed

+279
-108
lines changed

changelog/12958.improvement.rst

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
A number of :ref:`unraisable <unraisable>` enhancements:
2+
3+
* Set the unraisable hook as early as possible and unset it as late as possible, to collect the most possible number of unraisable exceptions.
4+
* Call the garbage collector just before unsetting the unraisable hook, to collect any straggling exceptions.
5+
* Collect multiple unraisable exceptions per test phase.
6+
* Report the :mod:`tracemalloc` allocation traceback (if available).
7+
* Avoid using a generator based hook to allow handling :class:`StopIteration` in test failures.
8+
* Report the unraisable exception as the cause of the :class:`pytest.PytestUnraisableExceptionWarning` exception if raised.
9+
* Compute the ``repr`` of the unraisable object in the unraisable hook so you get the latest information if available, and should help with resurrection of the object.

src/_pytest/tracemalloc.py

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from __future__ import annotations
2+
3+
4+
def tracemalloc_message(source: object) -> str:
5+
if source is None:
6+
return ""
7+
8+
try:
9+
import tracemalloc
10+
except ImportError:
11+
return ""
12+
13+
tb = tracemalloc.get_object_traceback(source)
14+
if tb is not None:
15+
formatted_tb = "\n".join(tb.format())
16+
# Use a leading new line to better separate the (large) output
17+
# from the traceback to the previous warning text.
18+
return f"\nObject allocated at:\n{formatted_tb}"
19+
# No need for a leading new line.
20+
url = "https://docs.pytest.org/en/stable/how-to/capture-warnings.html#resource-warnings"
21+
return (
22+
"Enable tracemalloc to get traceback where the object was allocated.\n"
23+
f"See {url} for more info."
24+
)

src/_pytest/unraisableexception.py

+140-82
Original file line numberDiff line numberDiff line change
@@ -1,100 +1,158 @@
11
from __future__ import annotations
22

3+
import collections
34
from collections.abc import Callable
4-
from collections.abc import Generator
5+
import functools
6+
import gc
57
import sys
68
import traceback
7-
from types import TracebackType
8-
from typing import Any
9+
from typing import NamedTuple
910
from typing import TYPE_CHECKING
1011
import warnings
1112

13+
from _pytest.config import Config
14+
from _pytest.nodes import Item
15+
from _pytest.stash import StashKey
16+
from _pytest.tracemalloc import tracemalloc_message
1217
import pytest
1318

1419

1520
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
65-
66-
67-
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-
)
84-
)
85-
warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))
21+
pass
22+
23+
if sys.version_info < (3, 11):
24+
from exceptiongroup import ExceptionGroup
25+
26+
27+
def gc_collect_harder() -> None:
28+
# A single collection doesn't necessarily collect everything.
29+
# Constant determined experimentally by the Trio project.
30+
for _ in range(5):
31+
gc.collect()
8632

8733

88-
@pytest.hookimpl(wrapper=True, tryfirst=True)
89-
def pytest_runtest_setup() -> Generator[None]:
90-
yield from unraisable_exception_runtest_hook()
34+
class UnraisableMeta(NamedTuple):
35+
msg: str
36+
cause_msg: str
37+
exc_value: BaseException | None
9138

9239

93-
@pytest.hookimpl(wrapper=True, tryfirst=True)
94-
def pytest_runtest_call() -> Generator[None]:
95-
yield from unraisable_exception_runtest_hook()
40+
unraisable_exceptions: StashKey[collections.deque[UnraisableMeta | BaseException]] = (
41+
StashKey()
42+
)
9643

9744

98-
@pytest.hookimpl(wrapper=True, tryfirst=True)
99-
def pytest_runtest_teardown() -> Generator[None]:
100-
yield from unraisable_exception_runtest_hook()
45+
def collect_unraisable(config: Config) -> None:
46+
pop_unraisable = config.stash[unraisable_exceptions].pop
47+
errors: list[pytest.PytestUnraisableExceptionWarning | RuntimeError] = []
48+
meta = None
49+
hook_error = None
50+
try:
51+
while True:
52+
try:
53+
meta = pop_unraisable()
54+
except IndexError:
55+
break
56+
57+
if isinstance(meta, BaseException):
58+
hook_error = RuntimeError("Failed to process unraisable exception")
59+
hook_error.__cause__ = meta
60+
errors.append(hook_error)
61+
continue
62+
63+
msg = meta.msg
64+
try:
65+
warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))
66+
except pytest.PytestUnraisableExceptionWarning as e:
67+
# This except happens when the warning is treated as an error (e.g. `-Werror`).
68+
if meta.exc_value is not None:
69+
# Exceptions have a better way to show the traceback, but
70+
# warnings do not, so hide the traceback from the msg and
71+
# set the cause so the traceback shows up in the right place.
72+
e.args = (meta.cause_msg,)
73+
e.__cause__ = meta.exc_value
74+
errors.append(e)
75+
76+
if len(errors) == 1:
77+
raise errors[0]
78+
if errors:
79+
raise ExceptionGroup("multiple unraisable exception warnings", errors)
80+
finally:
81+
del errors, meta, hook_error
82+
83+
84+
def cleanup(
85+
*, config: Config, prev_hook: Callable[[sys.UnraisableHookArgs], object]
86+
) -> None:
87+
try:
88+
try:
89+
gc_collect_harder()
90+
collect_unraisable(config)
91+
finally:
92+
sys.unraisablehook = prev_hook
93+
finally:
94+
del config.stash[unraisable_exceptions]
95+
96+
97+
def unraisable_hook(
98+
unraisable: sys.UnraisableHookArgs,
99+
/,
100+
*,
101+
append: Callable[[UnraisableMeta | BaseException], object],
102+
) -> None:
103+
try:
104+
err_msg = (
105+
"Exception ignored in" if unraisable.err_msg is None else unraisable.err_msg
106+
)
107+
summary = f"{err_msg}: {unraisable.object!r}"
108+
traceback_message = "\n\n" + "".join(
109+
traceback.format_exception(
110+
unraisable.exc_type,
111+
unraisable.exc_value,
112+
unraisable.exc_traceback,
113+
)
114+
)
115+
tracemalloc_tb = "\n" + tracemalloc_message(unraisable.object)
116+
msg = summary + traceback_message + tracemalloc_tb
117+
cause_msg = summary + tracemalloc_tb
118+
119+
append(
120+
UnraisableMeta(
121+
# we need to compute these strings here as they might change after
122+
# the unraisablehook finishes and before the unraisable object is
123+
# collected by a hook
124+
msg=msg,
125+
cause_msg=cause_msg,
126+
exc_value=unraisable.exc_value,
127+
)
128+
)
129+
except BaseException as e:
130+
append(e)
131+
# Raising this will cause the exception to be logged twice, once in our
132+
# collect_unraisable and once by the unraisablehook calling machinery
133+
# which is fine - this should never happen anyway and if it does
134+
# it should probably be reported as a pytest bug.
135+
raise
136+
137+
138+
def pytest_configure(config: Config) -> None:
139+
prev_hook = sys.unraisablehook
140+
deque: collections.deque[UnraisableMeta | BaseException] = collections.deque()
141+
config.stash[unraisable_exceptions] = deque
142+
config.add_cleanup(functools.partial(cleanup, config=config, prev_hook=prev_hook))
143+
sys.unraisablehook = functools.partial(unraisable_hook, append=deque.append)
144+
145+
146+
@pytest.hookimpl(trylast=True)
147+
def pytest_runtest_setup(item: Item) -> None:
148+
collect_unraisable(item.config)
149+
150+
151+
@pytest.hookimpl(trylast=True)
152+
def pytest_runtest_call(item: Item) -> None:
153+
collect_unraisable(item.config)
154+
155+
156+
@pytest.hookimpl(trylast=True)
157+
def pytest_runtest_teardown(item: Item) -> None:
158+
collect_unraisable(item.config)

src/_pytest/warnings.py

+4-22
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from _pytest.main import Session
1414
from _pytest.nodes import Item
1515
from _pytest.terminal import TerminalReporter
16+
from _pytest.tracemalloc import tracemalloc_message
1617
import pytest
1718

1819

@@ -76,32 +77,13 @@ def catch_warnings_for_item(
7677

7778
def warning_record_to_str(warning_message: warnings.WarningMessage) -> str:
7879
"""Convert a warnings.WarningMessage to a string."""
79-
warn_msg = warning_message.message
80-
msg = warnings.formatwarning(
81-
str(warn_msg),
80+
return warnings.formatwarning(
81+
str(warning_message.message),
8282
warning_message.category,
8383
warning_message.filename,
8484
warning_message.lineno,
8585
warning_message.line,
86-
)
87-
if warning_message.source is not None:
88-
try:
89-
import tracemalloc
90-
except ImportError:
91-
pass
92-
else:
93-
tb = tracemalloc.get_object_traceback(warning_message.source)
94-
if tb is not None:
95-
formatted_tb = "\n".join(tb.format())
96-
# Use a leading new line to better separate the (large) output
97-
# from the traceback to the previous warning text.
98-
msg += f"\nObject allocated at:\n{formatted_tb}"
99-
else:
100-
# No need for a leading new line.
101-
url = "https://docs.pytest.org/en/stable/how-to/capture-warnings.html#resource-warnings"
102-
msg += "Enable tracemalloc to get traceback where the object was allocated.\n"
103-
msg += f"See {url} for more info."
104-
return msg
86+
) + tracemalloc_message(warning_message.source)
10587

10688

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

0 commit comments

Comments
 (0)