Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions Lib/test/support/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3034,3 +3034,39 @@ def collision_stats(nbins, nballs):
collisions = k - occupied
var = dn*(dn-1)*((dn-2)/dn)**k + meanempty * (1 - meanempty)
return float(collisions), float(var.sqrt())


class catch_unraisable_exception:
"""
Context manager catching unraisable exception using sys.unraisablehook.

Usage:

try:
with support.catch_unraisable_exception() as cm:
...

# check the expected unraisable exception
...
finally:

@graingert graingert May 22, 2019 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this try/with/finally is somewhat tricky, how about:

try:
    with support.throw_unraisable_exceptions():
        ...
except Exception as e:
    ... # the exception is now here

eg:

@contextlib.contextmanager
def throw_unraisable_exceptions():
    unraisable = None
    old_hook = sys.unraisablehook

    def hook(exc):
        nonlocal unraisable
        unraisable = exc

    sys.unraisablehook = hook
    try:
        yield
        if unraisable is not None:
            raise unraisable
    finally:
        unraisable = None
        sys.unraisablehook = old_hook

@graingert graingert May 22, 2019 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or recommend usage like this:

    with support.catch_unraisable_exceptions() as cm:
        ...
        cm.unraisable  # only available here
    assert cm.unraisable is None  # now it's gone

by updating __exit__:

   def __exit__(self, *exc_info):
        self.unraisable = None  # clear unraisable here
        sys.unraisablehook = self._old_hook

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea :-) I implemented your __exit__ idea to avoid the need for try/finally.

throw_unraisable_exceptions() might be useful, but modified tests needs to access the 'obj' attribute of the unraisable hook. Later, they might also want to get access to the 'err_msg' attribute: #13488

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's still a sharp edge here:

how about making cm.unraisable throw if accessed outside the context instead of being None, eg:

def __exit__(self, *exc_info):
    del self.unraisable
    sys.unraisablehook = self._old_hook

@graingert graingert May 22, 2019 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and if you want to access additional unraisablehook info using throw_unraisable_exceptions:

class UnraisableException(Exception):
    def __init__(self, unraisable):
        self.unraisable = unraisable
        super().__init__(self, unraisable)


@contextlib.contextmanager
def throw_unraisable_exceptions():
    unraisable = None
    old_hook = sys.unraisablehook

    def hook(unraisable_):
        nonlocal unraisable
        unraisable = unraisable_

    sys.unraisablehook = hook
    try:
        yield
        if unraisable is not None:
            raise UnraisableException(unraisable) from unraisable.exc_value
    finally:
        sys.unraisablehook = old_hook

then you can use it with:

try:
    with throw_unraisable_exceptions():
        ...
except UnraisableException as e:
    print(repr(e.__cause__))
    err_msg = e.unraisable.err_msg

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wrote PR #13554 to implement your "del self.unraisable" idea.

# Explicitly break reference cycle
cm = None

The finally block is required: cm.unraisable contains a traceback object
which indirectly references the 'cm' variable, it's a reference cycle.
"""

def __init__(self):
self.unraisable = None
self._old_hook = None

def _hook(self, unraisable):
self.unraisable = unraisable

def __enter__(self):
self._old_hook = sys.unraisablehook
sys.unraisablehook = self._hook
return self

def __exit__(self, *exc_info):
sys.unraisablehook = self._old_hook
24 changes: 18 additions & 6 deletions Lib/test/test_coroutines.py
Original file line number Diff line number Diff line change
Expand Up @@ -2342,12 +2342,24 @@ async def corofn():
orig_wuc = warnings._warn_unawaited_coroutine
try:
warnings._warn_unawaited_coroutine = lambda coro: 1/0
with support.captured_stderr() as stream:
corofn()
support.gc_collect()
self.assertIn("Exception ignored in", stream.getvalue())
self.assertIn("ZeroDivisionError", stream.getvalue())
self.assertIn("was never awaited", stream.getvalue())
try:
# Note: catch_unraisable_exception() resurect the coroutine
with support.catch_unraisable_exception() as cm, \
support.captured_stderr() as stream:
# only store repr() to avoid keeping the coroutine alive
coro = corofn()
coro_repr = repr(coro)

# clear reference to the coroutine without awaiting for it
del coro
support.gc_collect()

self.assertEqual(repr(cm.unraisable.object), coro_repr)
self.assertEqual(cm.unraisable.exc_type, ZeroDivisionError)
self.assertIn("was never awaited", stream.getvalue())

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a little bit strange to have to catch half of the error message from stderr, and the other half from sys.unraisablehook. My PR #13488 will allow to inject the error message in the unraisable exception to catch both at the same time.

finally:
# Explicitly break reference cycle
cm = None

del warnings._warn_unawaited_coroutine
with support.captured_stderr() as stream:
Expand Down
26 changes: 12 additions & 14 deletions Lib/test/test_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
check_warnings, cpython_only, gc_collect, run_unittest,
no_tracing, unlink, import_module, script_helper,
SuppressCrashReport)
from test import support


class NaiveException(Exception):
def __init__(self, x):
self.x = x
Expand Down Expand Up @@ -1190,20 +1193,15 @@ def __del__(self):
for test_class in (BrokenDel, BrokenExceptionDel):
with self.subTest(test_class):
obj = test_class()
with captured_stderr() as stderr:
del obj
report = stderr.getvalue()
self.assertIn("Exception ignored", report)
self.assertIn(test_class.__del__.__qualname__, report)
self.assertIn("test_exceptions.py", report)
self.assertIn("raise exc", report)
if test_class is BrokenExceptionDel:
self.assertIn("BrokenStrException", report)
self.assertIn("<exception str() failed>", report)
else:
self.assertIn("ValueError", report)
self.assertIn("del is broken", report)
self.assertTrue(report.endswith("\n"))
try:
with support.catch_unraisable_exception() as cm:
del obj

self.assertEqual(cm.unraisable.object, test_class.__del__)
self.assertIsNotNone(cm.unraisable.exc_traceback)
finally:
# Explicitly break reference cycle
cm = None

def test_unhandled(self):
# Check for sensible reporting of unhandled exceptions
Expand Down
32 changes: 16 additions & 16 deletions Lib/test/test_generators.py
Original file line number Diff line number Diff line change
Expand Up @@ -2156,25 +2156,25 @@ def printsolution(self, x):
printing warnings and to doublecheck that we actually tested what we wanted
to test.

>>> import sys, io
>>> old = sys.stderr
>>> from test import support
>>> class Leaker:
... def __del__(self):
... def invoke(message):
... raise RuntimeError(message)
... invoke("del failed")
...
>>> try:
... sys.stderr = io.StringIO()
... class Leaker:
... def __del__(self):
... def invoke(message):
... raise RuntimeError(message)
... invoke("test")
... with support.catch_unraisable_exception() as cm:
... l = Leaker()
... del l
...
... l = Leaker()
... del l
... err = sys.stderr.getvalue().strip()
... "Exception ignored in" in err
... "RuntimeError: test" in err
... "Traceback" in err
... "in invoke" in err
... Leaker.__del__ == cm.unraisable.object
... cm.unraisable.exc_type == RuntimeError
... str(cm.unraisable.exc_value) == "del failed"
... cm.unraisable.exc_traceback is not None
... finally:
... sys.stderr = old
... # Explicitly break reference cycle
... cm = None
True
True
True
Expand Down
41 changes: 41 additions & 0 deletions Lib/test/test_sys.py
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,47 @@ def test_original_unraisablehook(self):
self.assertIn('Traceback (most recent call last):\n', err)
self.assertIn('ValueError: 42\n', err)

def test_original_unraisablehook_err(self):
# bpo-22836: PyErr_WriteUnraisable() should give sensible reports
class BrokenDel:
def __del__(self):
exc = ValueError("del is broken")
# The following line is included in the traceback report:
raise exc

class BrokenStrException(Exception):
def __str__(self):
raise Exception("str() is broken")

class BrokenExceptionDel:
def __del__(self):
exc = BrokenStrException()
# The following line is included in the traceback report:
raise exc

for test_class in (BrokenDel, BrokenExceptionDel):
with self.subTest(test_class):
obj = test_class()
with test.support.captured_stderr() as stderr, \
test.support.swap_attr(sys, 'unraisablehook',
sys.__unraisablehook__):
# Trigger obj.__del__()
del obj

report = stderr.getvalue()
self.assertIn("Exception ignored", report)
self.assertIn(test_class.__del__.__qualname__, report)
self.assertIn("test_sys.py", report)
self.assertIn("raise exc", report)
if test_class is BrokenExceptionDel:
self.assertIn("BrokenStrException", report)
self.assertIn("<exception str() failed>", report)
else:
self.assertIn("ValueError", report)
self.assertIn("del is broken", report)
self.assertTrue(report.endswith("\n"))


def test_original_unraisablehook_wrong_type(self):
exc = ValueError(42)
with test.support.swap_attr(sys, 'unraisablehook',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add :func:`test.support.catch_unraisable_exception`: context manager
catching unraisable exception using :func:`sys.unraisablehook`.