Skip to content

gh-127221: Add colour to unittest output #127223

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

Merged
merged 7 commits into from
Dec 5, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions Doc/library/doctest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ examples of doctests in the standard Python test suite and libraries.
Especially useful examples can be found in the standard test file
:file:`Lib/test/test_doctest/test_doctest.py`.

.. versionadded:: 3.13
Output is colorized by default and can be
:ref:`controlled using environment variables <using-on-controlling-color>`.


.. _doctest-simple-testmod:

Expand Down
4 changes: 4 additions & 0 deletions Doc/library/traceback.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ The module's API can be divided into two parts:
necessary for later formatting without holding references to actual exception
and traceback objects.

.. versionadded:: 3.13
Output is colorized by default and can be
:ref:`controlled using environment variables <using-on-controlling-color>`.


Module-Level Functions
----------------------
Expand Down
4 changes: 3 additions & 1 deletion Doc/library/unittest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ test runner
a textual interface, or return a special value to indicate the results of
executing the tests.


.. seealso::

Module :mod:`doctest`
Expand Down Expand Up @@ -198,6 +197,9 @@ For a list of all the command-line options::
In earlier versions it was only possible to run individual test methods and
not modules or classes.

.. versionadded:: 3.14
Output is colorized by default and can be
:ref:`controlled using environment variables <using-on-controlling-color>`.

Command-line options
~~~~~~~~~~~~~~~~~~~~
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
Loading
Loading