gh-134170: Add colorization to unraisable exceptions (#134183)

Default implementation of sys.unraisablehook() now uses traceback._print_exception_bltin() to print exceptions with colorized text.

Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com>
Co-authored-by: Victor Stinner <vstinner@python.org>
This commit is contained in:
Peter Bierma 2025-08-04 10:35:00 -04:00 committed by GitHub
parent 8943bb722f
commit e8251dc0ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 55 additions and 6 deletions

View file

@ -2152,11 +2152,16 @@ always available. Unless explicitly noted otherwise, all variables are read-only
The default hook formats :attr:`!err_msg` and :attr:`!object` as: The default hook formats :attr:`!err_msg` and :attr:`!object` as:
``f'{err_msg}: {object!r}'``; use "Exception ignored in" error message ``f'{err_msg}: {object!r}'``; use "Exception ignored in" error message
if :attr:`!err_msg` is ``None``. if :attr:`!err_msg` is ``None``. Similar to the :mod:`traceback` module,
this adds color to exceptions by default. This can be disabled using
:ref:`environment variables <using-on-controlling-color>`.
:func:`sys.unraisablehook` can be overridden to control how unraisable :func:`sys.unraisablehook` can be overridden to control how unraisable
exceptions are handled. exceptions are handled.
.. versionchanged:: next
Exceptions are now printed with colorful text.
.. seealso:: .. seealso::
:func:`excepthook` which handles uncaught exceptions. :func:`excepthook` which handles uncaught exceptions.

View file

@ -200,6 +200,10 @@ Other language changes
* Several error messages incorrectly using the term "argument" have been corrected. * Several error messages incorrectly using the term "argument" have been corrected.
(Contributed by Stan Ulbrych in :gh:`133382`.) (Contributed by Stan Ulbrych in :gh:`133382`.)
* Unraisable exceptions are now highlighted with color by default. This can be
controlled by :ref:`environment variables <using-on-controlling-color>`.
(Contributed by Peter Bierma in :gh:`134170`.)
New modules New modules
=========== ===========

View file

@ -6,7 +6,7 @@
import textwrap import textwrap
from test import support from test import support
from test.support import import_helper from test.support import import_helper, force_not_colorized
from test.support.os_helper import TESTFN, TESTFN_UNDECODABLE from test.support.os_helper import TESTFN, TESTFN_UNDECODABLE
from test.support.script_helper import assert_python_failure, assert_python_ok from test.support.script_helper import assert_python_failure, assert_python_ok
from test.support.testcase import ExceptionIsLikeMixin from test.support.testcase import ExceptionIsLikeMixin
@ -337,6 +337,10 @@ def test_err_writeunraisable(self):
self.assertIsNone(cm.unraisable.err_msg) self.assertIsNone(cm.unraisable.err_msg)
self.assertIsNone(cm.unraisable.object) self.assertIsNone(cm.unraisable.object)
@force_not_colorized
def test_err_writeunraisable_lines(self):
writeunraisable = _testcapi.err_writeunraisable
with (support.swap_attr(sys, 'unraisablehook', None), with (support.swap_attr(sys, 'unraisablehook', None),
support.captured_stderr() as stderr): support.captured_stderr() as stderr):
writeunraisable(CustomError('oops!'), hex) writeunraisable(CustomError('oops!'), hex)
@ -387,6 +391,10 @@ def test_err_formatunraisable(self):
self.assertIsNone(cm.unraisable.err_msg) self.assertIsNone(cm.unraisable.err_msg)
self.assertIsNone(cm.unraisable.object) self.assertIsNone(cm.unraisable.object)
@force_not_colorized
def test_err_formatunraisable_lines(self):
formatunraisable = _testcapi.err_formatunraisable
with (support.swap_attr(sys, 'unraisablehook', None), with (support.swap_attr(sys, 'unraisablehook', None),
support.captured_stderr() as stderr): support.captured_stderr() as stderr):
formatunraisable(CustomError('oops!'), b'Error in %R', []) formatunraisable(CustomError('oops!'), b'Error in %R', [])

View file

@ -489,6 +489,7 @@ def test_unmached_quote(self):
self.assertRegex(err.decode('ascii', 'ignore'), 'SyntaxError') self.assertRegex(err.decode('ascii', 'ignore'), 'SyntaxError')
self.assertEqual(b'', out) self.assertEqual(b'', out)
@force_not_colorized
def test_stdout_flush_at_shutdown(self): def test_stdout_flush_at_shutdown(self):
# Issue #5319: if stdout.flush() fails at shutdown, an error should # Issue #5319: if stdout.flush() fails at shutdown, an error should
# be printed out. # be printed out.

View file

@ -49,6 +49,7 @@ def test_interpreter_shutdown(self):
self.assertFalse(err) self.assertFalse(err)
self.assertEqual(out.strip(), b"apple") self.assertEqual(out.strip(), b"apple")
@support.force_not_colorized
def test_submit_after_interpreter_shutdown(self): def test_submit_after_interpreter_shutdown(self):
# Test the atexit hook for shutdown of worker threads and processes # Test the atexit hook for shutdown of worker threads and processes
rc, out, err = assert_python_ok('-c', """if 1: rc, out, err = assert_python_ok('-c', """if 1:

View file

@ -14,7 +14,7 @@
import unittest import unittest
from test import support from test import support
from test.support import ( from test.support import (
is_apple, is_apple_mobile, os_helper, threading_helper force_not_colorized, is_apple, is_apple_mobile, os_helper, threading_helper
) )
from test.support.script_helper import assert_python_ok, spawn_python from test.support.script_helper import assert_python_ok, spawn_python
try: try:
@ -353,6 +353,7 @@ def check_signum(signals):
@unittest.skipIf(_testcapi is None, 'need _testcapi') @unittest.skipIf(_testcapi is None, 'need _testcapi')
@unittest.skipUnless(hasattr(os, "pipe"), "requires os.pipe()") @unittest.skipUnless(hasattr(os, "pipe"), "requires os.pipe()")
@force_not_colorized
def test_wakeup_write_error(self): def test_wakeup_write_error(self):
# Issue #16105: write() errors in the C signal handler should not # Issue #16105: write() errors in the C signal handler should not
# pass silently. # pass silently.

View file

@ -1340,6 +1340,7 @@ def test_disable_gil_abi(self):
@test.support.cpython_only @test.support.cpython_only
@force_not_colorized
class UnraisableHookTest(unittest.TestCase): class UnraisableHookTest(unittest.TestCase):
def test_original_unraisablehook(self): def test_original_unraisablehook(self):
_testcapi = import_helper.import_module('_testcapi') _testcapi = import_helper.import_module('_testcapi')

View file

@ -2494,6 +2494,7 @@ def test_atexit_called_once(self):
self.assertFalse(err) self.assertFalse(err)
@force_not_colorized
def test_atexit_after_shutdown(self): def test_atexit_after_shutdown(self):
# The only way to do this is by registering an atexit within # The only way to do this is by registering an atexit within
# an atexit, which is intended to raise an exception. # an atexit, which is intended to raise an exception.

View file

@ -137,8 +137,9 @@ def print_exception(exc, /, value=_sentinel, tb=_sentinel, limit=None, \
BUILTIN_EXCEPTION_LIMIT = object() BUILTIN_EXCEPTION_LIMIT = object()
def _print_exception_bltin(exc, /): def _print_exception_bltin(exc, file=None, /):
file = sys.stderr if sys.stderr is not None else sys.__stderr__ if file is None:
file = sys.stderr if sys.stderr is not None else sys.__stderr__
colorize = _colorize.can_colorize(file=file) colorize = _colorize.can_colorize(file=file)
return print_exception(exc, limit=BUILTIN_EXCEPTION_LIMIT, file=file, colorize=colorize) return print_exception(exc, limit=BUILTIN_EXCEPTION_LIMIT, file=file, colorize=colorize)

View file

@ -0,0 +1 @@
Add colorization to :func:`sys.unraisablehook` by default.

View file

@ -1444,12 +1444,16 @@ make_unraisable_hook_args(PyThreadState *tstate, PyObject *exc_type,
It can be called to log the exception of a custom sys.unraisablehook. It can be called to log the exception of a custom sys.unraisablehook.
Do nothing if sys.stderr attribute doesn't exist or is set to None. */ This assumes 'file' is neither NULL nor None.
*/
static int static int
write_unraisable_exc_file(PyThreadState *tstate, PyObject *exc_type, write_unraisable_exc_file(PyThreadState *tstate, PyObject *exc_type,
PyObject *exc_value, PyObject *exc_tb, PyObject *exc_value, PyObject *exc_tb,
PyObject *err_msg, PyObject *obj, PyObject *file) PyObject *err_msg, PyObject *obj, PyObject *file)
{ {
assert(file != NULL);
assert(!Py_IsNone(file));
if (obj != NULL && obj != Py_None) { if (obj != NULL && obj != Py_None) {
if (err_msg != NULL && err_msg != Py_None) { if (err_msg != NULL && err_msg != Py_None) {
if (PyFile_WriteObject(err_msg, file, Py_PRINT_RAW) < 0) { if (PyFile_WriteObject(err_msg, file, Py_PRINT_RAW) < 0) {
@ -1484,6 +1488,27 @@ write_unraisable_exc_file(PyThreadState *tstate, PyObject *exc_type,
} }
} }
// Try printing the exception using the stdlib module.
// If this fails, then we have to use the C implementation.
PyObject *print_exception_fn = PyImport_ImportModuleAttrString("traceback",
"_print_exception_bltin");
if (print_exception_fn != NULL && PyCallable_Check(print_exception_fn)) {
PyObject *args[2] = {exc_value, file};
PyObject *result = PyObject_Vectorcall(print_exception_fn, args, 2, NULL);
int ok = (result != NULL);
Py_DECREF(print_exception_fn);
Py_XDECREF(result);
if (ok) {
// Nothing else to do
return 0;
}
}
else {
Py_XDECREF(print_exception_fn);
}
// traceback module failed, fall back to pure C
_PyErr_Clear(tstate);
if (exc_tb != NULL && exc_tb != Py_None) { if (exc_tb != NULL && exc_tb != Py_None) {
if (PyTraceBack_Print(exc_tb, file) < 0) { if (PyTraceBack_Print(exc_tb, file) < 0) {
/* continue even if writing the traceback failed */ /* continue even if writing the traceback failed */