Skip to content

Commit e4d300e

Browse files
authored
bpo-36829: Add test.support.catch_unraisable_exception() (GH-13490)
* Copy test_exceptions.test_unraisable() to test_sys.UnraisableHookTest(). * Use catch_unraisable_exception() in test_coroutines, test_exceptions, test_generators.
1 parent 904e34d commit e4d300e

File tree

6 files changed

+108
-43
lines changed

6 files changed

+108
-43
lines changed

Lib/test/support/__init__.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3034,3 +3034,36 @@ def collision_stats(nbins, nballs):
30343034
collisions = k - occupied
30353035
var = dn*(dn-1)*((dn-2)/dn)**k + meanempty * (1 - meanempty)
30363036
return float(collisions), float(var.sqrt())
3037+
3038+
3039+
class catch_unraisable_exception:
3040+
"""
3041+
Context manager catching unraisable exception using sys.unraisablehook.
3042+
3043+
Usage:
3044+
3045+
with support.catch_unraisable_exception() as cm:
3046+
...
3047+
3048+
# check the expected unraisable exception: use cm.unraisable
3049+
...
3050+
3051+
# cm.unraisable is None here (to break a reference cycle)
3052+
"""
3053+
3054+
def __init__(self):
3055+
self.unraisable = None
3056+
self._old_hook = None
3057+
3058+
def _hook(self, unraisable):
3059+
self.unraisable = unraisable
3060+
3061+
def __enter__(self):
3062+
self._old_hook = sys.unraisablehook
3063+
sys.unraisablehook = self._hook
3064+
return self
3065+
3066+
def __exit__(self, *exc_info):
3067+
# Clear the unraisable exception to explicitly break a reference cycle
3068+
self.unraisable = None
3069+
sys.unraisablehook = self._old_hook

Lib/test/test_coroutines.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2342,12 +2342,19 @@ async def corofn():
23422342
orig_wuc = warnings._warn_unawaited_coroutine
23432343
try:
23442344
warnings._warn_unawaited_coroutine = lambda coro: 1/0
2345-
with support.captured_stderr() as stream:
2346-
corofn()
2345+
with support.catch_unraisable_exception() as cm, \
2346+
support.captured_stderr() as stream:
2347+
# only store repr() to avoid keeping the coroutine alive
2348+
coro = corofn()
2349+
coro_repr = repr(coro)
2350+
2351+
# clear reference to the coroutine without awaiting for it
2352+
del coro
23472353
support.gc_collect()
2348-
self.assertIn("Exception ignored in", stream.getvalue())
2349-
self.assertIn("ZeroDivisionError", stream.getvalue())
2350-
self.assertIn("was never awaited", stream.getvalue())
2354+
2355+
self.assertEqual(repr(cm.unraisable.object), coro_repr)
2356+
self.assertEqual(cm.unraisable.exc_type, ZeroDivisionError)
2357+
self.assertIn("was never awaited", stream.getvalue())
23512358

23522359
del warnings._warn_unawaited_coroutine
23532360
with support.captured_stderr() as stream:

Lib/test/test_exceptions.py

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
check_warnings, cpython_only, gc_collect, run_unittest,
1313
no_tracing, unlink, import_module, script_helper,
1414
SuppressCrashReport)
15+
from test import support
16+
17+
1518
class NaiveException(Exception):
1619
def __init__(self, x):
1720
self.x = x
@@ -1181,29 +1184,12 @@ def __del__(self):
11811184
# The following line is included in the traceback report:
11821185
raise exc
11831186

1184-
class BrokenExceptionDel:
1185-
def __del__(self):
1186-
exc = BrokenStrException()
1187-
# The following line is included in the traceback report:
1188-
raise exc
1187+
obj = BrokenDel()
1188+
with support.catch_unraisable_exception() as cm:
1189+
del obj
11891190

1190-
for test_class in (BrokenDel, BrokenExceptionDel):
1191-
with self.subTest(test_class):
1192-
obj = test_class()
1193-
with captured_stderr() as stderr:
1194-
del obj
1195-
report = stderr.getvalue()
1196-
self.assertIn("Exception ignored", report)
1197-
self.assertIn(test_class.__del__.__qualname__, report)
1198-
self.assertIn("test_exceptions.py", report)
1199-
self.assertIn("raise exc", report)
1200-
if test_class is BrokenExceptionDel:
1201-
self.assertIn("BrokenStrException", report)
1202-
self.assertIn("<exception str() failed>", report)
1203-
else:
1204-
self.assertIn("ValueError", report)
1205-
self.assertIn("del is broken", report)
1206-
self.assertTrue(report.endswith("\n"))
1191+
self.assertEqual(cm.unraisable.object, BrokenDel.__del__)
1192+
self.assertIsNotNone(cm.unraisable.exc_traceback)
12071193

12081194
def test_unhandled(self):
12091195
# Check for sensible reporting of unhandled exceptions

Lib/test/test_generators.py

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2156,25 +2156,21 @@ def printsolution(self, x):
21562156
printing warnings and to doublecheck that we actually tested what we wanted
21572157
to test.
21582158
2159-
>>> import sys, io
2160-
>>> old = sys.stderr
2161-
>>> try:
2162-
... sys.stderr = io.StringIO()
2163-
... class Leaker:
2164-
... def __del__(self):
2165-
... def invoke(message):
2166-
... raise RuntimeError(message)
2167-
... invoke("test")
2159+
>>> from test import support
2160+
>>> class Leaker:
2161+
... def __del__(self):
2162+
... def invoke(message):
2163+
... raise RuntimeError(message)
2164+
... invoke("del failed")
21682165
...
2166+
>>> with support.catch_unraisable_exception() as cm:
21692167
... l = Leaker()
21702168
... del l
2171-
... err = sys.stderr.getvalue().strip()
2172-
... "Exception ignored in" in err
2173-
... "RuntimeError: test" in err
2174-
... "Traceback" in err
2175-
... "in invoke" in err
2176-
... finally:
2177-
... sys.stderr = old
2169+
...
2170+
... cm.unraisable.object == Leaker.__del__
2171+
... cm.unraisable.exc_type == RuntimeError
2172+
... str(cm.unraisable.exc_value) == "del failed"
2173+
... cm.unraisable.exc_traceback is not None
21782174
True
21792175
True
21802176
True

Lib/test/test_sys.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -909,6 +909,47 @@ def test_original_unraisablehook(self):
909909
self.assertIn('Traceback (most recent call last):\n', err)
910910
self.assertIn('ValueError: 42\n', err)
911911

912+
def test_original_unraisablehook_err(self):
913+
# bpo-22836: PyErr_WriteUnraisable() should give sensible reports
914+
class BrokenDel:
915+
def __del__(self):
916+
exc = ValueError("del is broken")
917+
# The following line is included in the traceback report:
918+
raise exc
919+
920+
class BrokenStrException(Exception):
921+
def __str__(self):
922+
raise Exception("str() is broken")
923+
924+
class BrokenExceptionDel:
925+
def __del__(self):
926+
exc = BrokenStrException()
927+
# The following line is included in the traceback report:
928+
raise exc
929+
930+
for test_class in (BrokenDel, BrokenExceptionDel):
931+
with self.subTest(test_class):
932+
obj = test_class()
933+
with test.support.captured_stderr() as stderr, \
934+
test.support.swap_attr(sys, 'unraisablehook',
935+
sys.__unraisablehook__):
936+
# Trigger obj.__del__()
937+
del obj
938+
939+
report = stderr.getvalue()
940+
self.assertIn("Exception ignored", report)
941+
self.assertIn(test_class.__del__.__qualname__, report)
942+
self.assertIn("test_sys.py", report)
943+
self.assertIn("raise exc", report)
944+
if test_class is BrokenExceptionDel:
945+
self.assertIn("BrokenStrException", report)
946+
self.assertIn("<exception str() failed>", report)
947+
else:
948+
self.assertIn("ValueError", report)
949+
self.assertIn("del is broken", report)
950+
self.assertTrue(report.endswith("\n"))
951+
952+
912953
def test_original_unraisablehook_wrong_type(self):
913954
exc = ValueError(42)
914955
with test.support.swap_attr(sys, 'unraisablehook',
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add :func:`test.support.catch_unraisable_exception`: context manager
2+
catching unraisable exception using :func:`sys.unraisablehook`.

0 commit comments

Comments
 (0)