Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gh-127221: Add colour to unittest output #127223

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
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
7 changes: 7 additions & 0 deletions Doc/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@
.. |python_version_literal| replace:: ``Python {version}``
.. |python_x_dot_y_literal| replace:: ``python{version}``
.. |usr_local_bin_python_x_dot_y_literal| replace:: ``/usr/local/bin/python{version}``
.. Apparently this how you hack together a formatted link:
(https://www.docutils.org/docs/ref/rst/directives.html#replacement-text)
.. |FORCE_COLOR| replace:: ``FORCE_COLOR``
.. _FORCE_COLOR: https://force-color.org/
.. |NO_COLOR| replace:: ``NO_COLOR``
.. _NO_COLOR: https://no-color.org/
"""

# There are two options for replacing |today|. Either, you set today to some
Expand Down
8 changes: 0 additions & 8 deletions Doc/using/cmdline.rst
Original file line number Diff line number Diff line change
Expand Up @@ -663,14 +663,6 @@ output. To control the color output only in the Python interpreter, the
precedence over ``NO_COLOR``, which in turn takes precedence over
``FORCE_COLOR``.

.. Apparently this how you hack together a formatted link:
.. |FORCE_COLOR| replace:: ``FORCE_COLOR``
.. _FORCE_COLOR: https://force-color.org/

.. |NO_COLOR| replace:: ``NO_COLOR``
.. _NO_COLOR: https://no-color.org/

Options you shouldn't use
~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
9 changes: 0 additions & 9 deletions Doc/whatsnew/3.13.rst
Original file line number Diff line number Diff line change
Expand Up @@ -252,15 +252,6 @@ Improved error messages
the canonical |NO_COLOR|_ and |FORCE_COLOR|_ environment variables.
(Contributed by Pablo Galindo Salgado in :gh:`112730`.)

.. Apparently this how you hack together a formatted link:
(https://www.docutils.org/docs/ref/rst/directives.html#replacement-text)
.. |FORCE_COLOR| replace:: ``FORCE_COLOR``
.. _FORCE_COLOR: https://force-color.org/

.. |NO_COLOR| replace:: ``NO_COLOR``
.. _NO_COLOR: https://no-color.org/

* A common mistake is to write a script with the same name as a
standard library module. When this results in errors, we now
display a more helpful error message:
Expand Down
7 changes: 7 additions & 0 deletions Doc/whatsnew/3.14.rst
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,13 @@ unicodedata
unittest
--------

* :mod:`unittest` output is now colored by default.
This can be controlled via the :envvar:`PYTHON_COLORS` environment
variable as well as the canonical |NO_COLOR|_
and |FORCE_COLOR|_ environment variables.
See also :ref:`using-on-controlling-color`.
(Contributed by Hugo van Kemenade in :gh:`127221`.)

* unittest discovery supports :term:`namespace package` as start
directory again. It was removed in Python 3.11.
(Contributed by Jacob Walls in :gh:`80958`.)
Expand Down
2 changes: 2 additions & 0 deletions Lib/test/test_unittest/test_async_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import contextvars
import unittest
from test import support
from test.support import force_not_colorized

support.requires_working_socket(module=True)

Expand Down Expand Up @@ -252,6 +253,7 @@ async def on_cleanup(self):
test.doCleanups()
self.assertEqual(events, ['asyncSetUp', 'test', 'asyncTearDown', 'cleanup'])

@force_not_colorized
def test_exception_in_tear_clean_up(self):
class Test(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
Expand Down
6 changes: 6 additions & 0 deletions Lib/test/test_unittest/test_program.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from test import support
import unittest
import test.test_unittest
from test.support import force_not_colorized
from test.test_unittest.test_result import BufferedWriter


Expand Down Expand Up @@ -120,6 +121,7 @@ def run(self, test):
self.assertEqual(['test.test_unittest', 'test.test_unittest2'],
program.testNames)

@force_not_colorized
def test_NonExit(self):
stream = BufferedWriter()
program = unittest.main(exit=False,
Expand All @@ -135,6 +137,7 @@ def test_NonExit(self):
'expected failures=1, unexpected successes=1)\n')
self.assertTrue(out.endswith(expected))

@force_not_colorized
def test_Exit(self):
stream = BufferedWriter()
with self.assertRaises(SystemExit) as cm:
Expand All @@ -152,6 +155,7 @@ def test_Exit(self):
'expected failures=1, unexpected successes=1)\n')
self.assertTrue(out.endswith(expected))

@force_not_colorized
def test_ExitAsDefault(self):
stream = BufferedWriter()
with self.assertRaises(SystemExit):
Expand All @@ -167,6 +171,7 @@ def test_ExitAsDefault(self):
'expected failures=1, unexpected successes=1)\n')
self.assertTrue(out.endswith(expected))

@force_not_colorized
def test_ExitSkippedSuite(self):
stream = BufferedWriter()
with self.assertRaises(SystemExit) as cm:
Expand All @@ -179,6 +184,7 @@ def test_ExitSkippedSuite(self):
expected = '\n\nOK (skipped=1)\n'
self.assertTrue(out.endswith(expected))

@force_not_colorized
def test_ExitEmptySuite(self):
stream = BufferedWriter()
with self.assertRaises(SystemExit) as cm:
Expand Down
16 changes: 15 additions & 1 deletion Lib/test/test_unittest/test_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@
import traceback
import unittest
from unittest.util import strclass
from test.support import force_not_colorized
from test.test_unittest.support import BufferedWriter


class MockTraceback(object):
class TracebackException:
def __init__(self, *args, **kwargs):
self.capture_locals = kwargs.get('capture_locals', False)
def format(self):
def format(self, **kwargs):
result = ['A traceback']
if self.capture_locals:
result.append('locals')
Expand Down Expand Up @@ -205,6 +206,7 @@ def test_1(self):
self.assertIs(test_case, test)
self.assertIsInstance(formatted_exc, str)

@force_not_colorized
def test_addFailure_filter_traceback_frames(self):
class Foo(unittest.TestCase):
def test_1(self):
Expand All @@ -231,6 +233,7 @@ def get_exc_info():
self.assertEqual(len(dropped), 1)
self.assertIn("raise self.failureException(msg)", dropped[0])

@force_not_colorized
def test_addFailure_filter_traceback_frames_context(self):
class Foo(unittest.TestCase):
def test_1(self):
Expand Down Expand Up @@ -260,6 +263,7 @@ def get_exc_info():
self.assertEqual(len(dropped), 1)
self.assertIn("raise self.failureException(msg)", dropped[0])

@force_not_colorized
def test_addFailure_filter_traceback_frames_chained_exception_self_loop(self):
class Foo(unittest.TestCase):
def test_1(self):
Expand All @@ -285,6 +289,7 @@ def get_exc_info():
formatted_exc = result.failures[0][1]
self.assertEqual(formatted_exc.count("Exception: Loop\n"), 1)

@force_not_colorized
def test_addFailure_filter_traceback_frames_chained_exception_cycle(self):
class Foo(unittest.TestCase):
def test_1(self):
Expand Down Expand Up @@ -446,6 +451,7 @@ def testFailFast(self):
result.addUnexpectedSuccess(None)
self.assertTrue(result.shouldStop)

@force_not_colorized
def testFailFastSetByRunner(self):
stream = BufferedWriter()
runner = unittest.TextTestRunner(stream=stream, failfast=True)
Expand Down Expand Up @@ -619,6 +625,7 @@ def _run_test(self, test_name, verbosity, tearDownError=None):
test.run(result)
return stream.getvalue()

@force_not_colorized
def testDotsOutput(self):
self.assertEqual(self._run_test('testSuccess', 1), '.')
self.assertEqual(self._run_test('testSkip', 1), 's')
Expand All @@ -627,6 +634,7 @@ def testDotsOutput(self):
self.assertEqual(self._run_test('testExpectedFailure', 1), 'x')
self.assertEqual(self._run_test('testUnexpectedSuccess', 1), 'u')

@force_not_colorized
def testLongOutput(self):
classname = f'{__name__}.{self.Test.__qualname__}'
self.assertEqual(self._run_test('testSuccess', 2),
Expand All @@ -642,17 +650,21 @@ def testLongOutput(self):
self.assertEqual(self._run_test('testUnexpectedSuccess', 2),
f'testUnexpectedSuccess ({classname}.testUnexpectedSuccess) ... unexpected success\n')

@force_not_colorized
def testDotsOutputSubTestSuccess(self):
self.assertEqual(self._run_test('testSubTestSuccess', 1), '.')

@force_not_colorized
def testLongOutputSubTestSuccess(self):
classname = f'{__name__}.{self.Test.__qualname__}'
self.assertEqual(self._run_test('testSubTestSuccess', 2),
f'testSubTestSuccess ({classname}.testSubTestSuccess) ... ok\n')

@force_not_colorized
def testDotsOutputSubTestMixed(self):
self.assertEqual(self._run_test('testSubTestMixed', 1), 'sFE')

@force_not_colorized
def testLongOutputSubTestMixed(self):
classname = f'{__name__}.{self.Test.__qualname__}'
self.assertEqual(self._run_test('testSubTestMixed', 2),
Expand All @@ -661,6 +673,7 @@ def testLongOutputSubTestMixed(self):
f' testSubTestMixed ({classname}.testSubTestMixed) [fail] (c=3) ... FAIL\n'
f' testSubTestMixed ({classname}.testSubTestMixed) [error] (d=4) ... ERROR\n')

@force_not_colorized
def testDotsOutputTearDownFail(self):
out = self._run_test('testSuccess', 1, AssertionError('fail'))
self.assertEqual(out, 'F')
Expand All @@ -671,6 +684,7 @@ def testDotsOutputTearDownFail(self):
out = self._run_test('testSkip', 1, AssertionError('fail'))
self.assertEqual(out, 'sF')

@force_not_colorized
def testLongOutputTearDownFail(self):
classname = f'{__name__}.{self.Test.__qualname__}'
out = self._run_test('testSuccess', 2, AssertionError('fail'))
Expand Down
13 changes: 13 additions & 0 deletions Lib/test/test_unittest/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import pickle
import subprocess
from test import support
from test.support import force_not_colorized

import unittest
from unittest.case import _Outcome
Expand Down Expand Up @@ -106,6 +107,7 @@ def cleanup2(*args, **kwargs):
self.assertTrue(test.doCleanups())
self.assertEqual(cleanups, [(2, (), {}), (1, (1, 2, 3), dict(four='hello', five='goodbye'))])

@force_not_colorized
Copy link
Member

Choose a reason for hiding this comment

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

we have to apply this decorator to so many methods that I almost wonder if it's worth having a custom metaclass that automatically adds it to each method on the class... or we could not use the @force_not_colorized decorator, and instead duplicate the logic in setUp and tearDown methods on the TestCleanup class here.

Neither feels ideal; it might be that what you have now is in fact best!

def testCleanUpWithErrors(self):
class TestableTest(unittest.TestCase):
def testNothing(self):
Expand Down Expand Up @@ -416,6 +418,7 @@ def cleanup2():
self.assertIsInstance(e2[1], CustomError)
self.assertEqual(str(e2[1]), 'cleanup1')

@force_not_colorized
def test_with_errors_addCleanUp(self):
ordering = []
class TestableTest(unittest.TestCase):
Expand All @@ -439,6 +442,7 @@ def tearDownClass(cls):
['setUpClass', 'setUp', 'cleanup_exc',
'tearDownClass', 'cleanup_good'])

@force_not_colorized
def test_run_with_errors_addClassCleanUp(self):
ordering = []
class TestableTest(unittest.TestCase):
Expand All @@ -462,6 +466,7 @@ def tearDownClass(cls):
['setUpClass', 'setUp', 'test', 'cleanup_good',
'tearDownClass', 'cleanup_exc'])

@force_not_colorized
def test_with_errors_in_addClassCleanup_and_setUps(self):
ordering = []
class_blow_up = False
Expand Down Expand Up @@ -514,6 +519,7 @@ def tearDownClass(cls):
['setUpClass', 'setUp', 'tearDownClass',
'cleanup_exc'])

@force_not_colorized
def test_with_errors_in_tearDownClass(self):
ordering = []
class TestableTest(unittest.TestCase):
Expand Down Expand Up @@ -590,6 +596,7 @@ def test(self):
'inner setup', 'inner test', 'inner cleanup',
'end outer test', 'outer cleanup'])

@force_not_colorized
def test_run_empty_suite_error_message(self):
class EmptyTest(unittest.TestCase):
pass
Expand Down Expand Up @@ -663,6 +670,7 @@ class Module(object):
self.assertEqual(cleanups,
[((1, 2), {'function': 'hello'})])

@force_not_colorized
def test_run_module_cleanUp(self):
blowUp = True
ordering = []
Expand Down Expand Up @@ -802,6 +810,7 @@ def tearDownClass(cls):
'tearDownClass', 'cleanup_good'])
self.assertEqual(unittest.case._module_cleanups, [])

@force_not_colorized
def test_run_module_cleanUp_when_teardown_exception(self):
ordering = []
class Module(object):
Expand Down Expand Up @@ -963,6 +972,7 @@ def testNothing(self):
self.assertEqual(cleanups,
[((1, 2), {'function': 3, 'self': 4})])

@force_not_colorized
def test_with_errors_in_addClassCleanup(self):
ordering = []

Expand Down Expand Up @@ -996,6 +1006,7 @@ def tearDownClass(cls):
['setUpModule', 'setUpClass', 'test', 'tearDownClass',
'cleanup_exc', 'tearDownModule', 'cleanup_good'])

@force_not_colorized
def test_with_errors_in_addCleanup(self):
ordering = []
class Module(object):
Expand Down Expand Up @@ -1026,6 +1037,7 @@ def tearDown(self):
['setUpModule', 'setUp', 'test', 'tearDown',
'cleanup_exc', 'tearDownModule', 'cleanup_good'])

@force_not_colorized
def test_with_errors_in_addModuleCleanup_and_setUps(self):
ordering = []
module_blow_up = False
Expand Down Expand Up @@ -1318,6 +1330,7 @@ def MockResultClass(*args):
expectedresult = (runner.stream, DESCRIPTIONS, VERBOSITY)
self.assertEqual(runner._makeResult(), expectedresult)

@force_not_colorized
@support.requires_subprocess()
def test_warnings(self):
"""
Expand Down
3 changes: 3 additions & 0 deletions Lib/test/test_unittest/test_skipping.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import unittest

from test.support import force_not_colorized
from test.test_unittest.support import LoggingResult


Expand Down Expand Up @@ -293,6 +294,7 @@ def test_die(self):
self.assertFalse(result.unexpectedSuccesses)
self.assertTrue(result.wasSuccessful())

@force_not_colorized
def test_expected_failure_and_fail_in_cleanup(self):
class Foo(unittest.TestCase):
@unittest.expectedFailure
Expand Down Expand Up @@ -372,6 +374,7 @@ def test_die(self):
self.assertEqual(result.unexpectedSuccesses, [test])
self.assertFalse(result.wasSuccessful())

@force_not_colorized
def test_unexpected_success_and_fail_in_cleanup(self):
class Foo(unittest.TestCase):
@unittest.expectedFailure
Expand Down
4 changes: 3 additions & 1 deletion Lib/unittest/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,9 @@ def _exc_info_to_string(self, err, test):
tb_e = traceback.TracebackException(
exctype, value, tb,
capture_locals=self.tb_locals, compact=True)
msgLines = list(tb_e.format())
from _colorize import can_colorize

msgLines = list(tb_e.format(colorize=can_colorize()))

if self.buffer:
output = sys.stdout.getvalue()
Expand Down
Loading
Loading