Skip to content

Commit 975081b

Browse files
hugovkAlexWaygood
andauthored
gh-117225: Add color to doctest output (#117583)
Co-authored-by: Alex Waygood <[email protected]>
1 parent f6e5cc6 commit 975081b

File tree

5 files changed

+92
-15
lines changed

5 files changed

+92
-15
lines changed

Lib/doctest.py

+38-11
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ def _test():
104104
import unittest
105105
from io import StringIO, IncrementalNewlineDecoder
106106
from collections import namedtuple
107+
from traceback import _ANSIColors, _can_colorize
107108

108109

109110
class TestResults(namedtuple('TestResults', 'failed attempted')):
@@ -1179,6 +1180,9 @@ class DocTestRunner:
11791180
The `run` method is used to process a single DocTest case. It
11801181
returns a TestResults instance.
11811182
1183+
>>> save_colorize = traceback._COLORIZE
1184+
>>> traceback._COLORIZE = False
1185+
11821186
>>> tests = DocTestFinder().find(_TestClass)
11831187
>>> runner = DocTestRunner(verbose=False)
11841188
>>> tests.sort(key = lambda test: test.name)
@@ -1229,6 +1233,8 @@ class DocTestRunner:
12291233
can be also customized by subclassing DocTestRunner, and
12301234
overriding the methods `report_start`, `report_success`,
12311235
`report_unexpected_exception`, and `report_failure`.
1236+
1237+
>>> traceback._COLORIZE = save_colorize
12321238
"""
12331239
# This divider string is used to separate failure messages, and to
12341240
# separate sections of the summary.
@@ -1307,7 +1313,10 @@ def report_unexpected_exception(self, out, test, example, exc_info):
13071313
'Exception raised:\n' + _indent(_exception_traceback(exc_info)))
13081314

13091315
def _failure_header(self, test, example):
1310-
out = [self.DIVIDER]
1316+
red, reset = (
1317+
(_ANSIColors.RED, _ANSIColors.RESET) if _can_colorize() else ("", "")
1318+
)
1319+
out = [f"{red}{self.DIVIDER}{reset}"]
13111320
if test.filename:
13121321
if test.lineno is not None and example.lineno is not None:
13131322
lineno = test.lineno + example.lineno + 1
@@ -1592,6 +1601,21 @@ def summarize(self, verbose=None):
15921601
else:
15931602
failed.append((name, (failures, tries, skips)))
15941603

1604+
if _can_colorize():
1605+
bold_green = _ANSIColors.BOLD_GREEN
1606+
bold_red = _ANSIColors.BOLD_RED
1607+
green = _ANSIColors.GREEN
1608+
red = _ANSIColors.RED
1609+
reset = _ANSIColors.RESET
1610+
yellow = _ANSIColors.YELLOW
1611+
else:
1612+
bold_green = ""
1613+
bold_red = ""
1614+
green = ""
1615+
red = ""
1616+
reset = ""
1617+
yellow = ""
1618+
15951619
if verbose:
15961620
if notests:
15971621
print(f"{_n_items(notests)} had no tests:")
@@ -1600,13 +1624,13 @@ def summarize(self, verbose=None):
16001624
print(f" {name}")
16011625

16021626
if passed:
1603-
print(f"{_n_items(passed)} passed all tests:")
1627+
print(f"{green}{_n_items(passed)} passed all tests:{reset}")
16041628
for name, count in sorted(passed):
16051629
s = "" if count == 1 else "s"
1606-
print(f" {count:3d} test{s} in {name}")
1630+
print(f" {green}{count:3d} test{s} in {name}{reset}")
16071631

16081632
if failed:
1609-
print(self.DIVIDER)
1633+
print(f"{red}{self.DIVIDER}{reset}")
16101634
print(f"{_n_items(failed)} had failures:")
16111635
for name, (failures, tries, skips) in sorted(failed):
16121636
print(f" {failures:3d} of {tries:3d} in {name}")
@@ -1615,18 +1639,21 @@ def summarize(self, verbose=None):
16151639
s = "" if total_tries == 1 else "s"
16161640
print(f"{total_tries} test{s} in {_n_items(self._stats)}.")
16171641

1618-
and_f = f" and {total_failures} failed" if total_failures else ""
1619-
print(f"{total_tries - total_failures} passed{and_f}.")
1642+
and_f = (
1643+
f" and {red}{total_failures} failed{reset}"
1644+
if total_failures else ""
1645+
)
1646+
print(f"{green}{total_tries - total_failures} passed{reset}{and_f}.")
16201647

16211648
if total_failures:
16221649
s = "" if total_failures == 1 else "s"
1623-
msg = f"***Test Failed*** {total_failures} failure{s}"
1650+
msg = f"{bold_red}***Test Failed*** {total_failures} failure{s}{reset}"
16241651
if total_skips:
16251652
s = "" if total_skips == 1 else "s"
1626-
msg = f"{msg} and {total_skips} skipped test{s}"
1653+
msg = f"{msg} and {yellow}{total_skips} skipped test{s}{reset}"
16271654
print(f"{msg}.")
16281655
elif verbose:
1629-
print("Test passed.")
1656+
print(f"{bold_green}Test passed.{reset}")
16301657

16311658
return TestResults(total_failures, total_tries, skipped=total_skips)
16321659

@@ -1644,7 +1671,7 @@ def merge(self, other):
16441671
d[name] = (failures, tries, skips)
16451672

16461673

1647-
def _n_items(items: list) -> str:
1674+
def _n_items(items: list | dict) -> str:
16481675
"""
16491676
Helper to pluralise the number of items in a list.
16501677
"""
@@ -1655,7 +1682,7 @@ def _n_items(items: list) -> str:
16551682

16561683
class OutputChecker:
16571684
"""
1658-
A class used to check the whether the actual output from a doctest
1685+
A class used to check whether the actual output from a doctest
16591686
example matches the expected output. `OutputChecker` defines two
16601687
methods: `check_output`, which compares a given pair of outputs,
16611688
and returns true if they match; and `output_difference`, which

Lib/test/support/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"Error", "TestFailed", "TestDidNotRun", "ResourceDenied",
2727
# io
2828
"record_original_stdout", "get_original_stdout", "captured_stdout",
29-
"captured_stdin", "captured_stderr",
29+
"captured_stdin", "captured_stderr", "captured_output",
3030
# unittest
3131
"is_resource_enabled", "requires", "requires_freebsd_version",
3232
"requires_gil_enabled", "requires_linux_version", "requires_mac_ver",

Lib/test/test_doctest/test_doctest.py

+48-3
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import tempfile
1717
import types
1818
import contextlib
19+
import traceback
1920

2021

2122
def doctest_skip_if(condition):
@@ -470,7 +471,7 @@ def basics(): r"""
470471
>>> tests = finder.find(sample_func)
471472
472473
>>> print(tests) # doctest: +ELLIPSIS
473-
[<DocTest sample_func from test_doctest.py:37 (1 example)>]
474+
[<DocTest sample_func from test_doctest.py:38 (1 example)>]
474475
475476
The exact name depends on how test_doctest was invoked, so allow for
476477
leading path components.
@@ -892,6 +893,9 @@ def basics(): r"""
892893
DocTestRunner is used to run DocTest test cases, and to accumulate
893894
statistics. Here's a simple DocTest case we can use:
894895
896+
>>> save_colorize = traceback._COLORIZE
897+
>>> traceback._COLORIZE = False
898+
895899
>>> def f(x):
896900
... '''
897901
... >>> x = 12
@@ -946,6 +950,8 @@ def basics(): r"""
946950
6
947951
ok
948952
TestResults(failed=1, attempted=3)
953+
954+
>>> traceback._COLORIZE = save_colorize
949955
"""
950956
def verbose_flag(): r"""
951957
The `verbose` flag makes the test runner generate more detailed
@@ -1021,6 +1027,9 @@ def exceptions(): r"""
10211027
lines between the first line and the type/value may be omitted or
10221028
replaced with any other string:
10231029
1030+
>>> save_colorize = traceback._COLORIZE
1031+
>>> traceback._COLORIZE = False
1032+
10241033
>>> def f(x):
10251034
... '''
10261035
... >>> x = 12
@@ -1251,6 +1260,8 @@ def exceptions(): r"""
12511260
...
12521261
ZeroDivisionError: integer division or modulo by zero
12531262
TestResults(failed=1, attempted=1)
1263+
1264+
>>> traceback._COLORIZE = save_colorize
12541265
"""
12551266
def displayhook(): r"""
12561267
Test that changing sys.displayhook doesn't matter for doctest.
@@ -1292,6 +1303,9 @@ def optionflags(): r"""
12921303
The DONT_ACCEPT_TRUE_FOR_1 flag disables matches between True/False
12931304
and 1/0:
12941305
1306+
>>> save_colorize = traceback._COLORIZE
1307+
>>> traceback._COLORIZE = False
1308+
12951309
>>> def f(x):
12961310
... '>>> True\n1\n'
12971311
@@ -1711,6 +1725,7 @@ def optionflags(): r"""
17111725
17121726
Clean up.
17131727
>>> del doctest.OPTIONFLAGS_BY_NAME[unlikely]
1728+
>>> traceback._COLORIZE = save_colorize
17141729
17151730
"""
17161731

@@ -1721,6 +1736,9 @@ def option_directives(): r"""
17211736
single example. To turn an option on for an example, follow that
17221737
example with a comment of the form ``# doctest: +OPTION``:
17231738
1739+
>>> save_colorize = traceback._COLORIZE
1740+
>>> traceback._COLORIZE = False
1741+
17241742
>>> def f(x): r'''
17251743
... >>> print(list(range(10))) # should fail: no ellipsis
17261744
... [0, 1, ..., 9]
@@ -1928,6 +1946,8 @@ def option_directives(): r"""
19281946
>>> test = doctest.DocTestParser().get_doctest(s, {}, 's', 's.py', 0)
19291947
Traceback (most recent call last):
19301948
ValueError: line 0 of the doctest for s has an option directive on a line with no example: '# doctest: +ELLIPSIS'
1949+
1950+
>>> traceback._COLORIZE = save_colorize
19311951
"""
19321952

19331953
def test_testsource(): r"""
@@ -2011,6 +2031,9 @@ def test_pdb_set_trace():
20112031
with a version that restores stdout. This is necessary for you to
20122032
see debugger output.
20132033
2034+
>>> save_colorize = traceback._COLORIZE
2035+
>>> traceback._COLORIZE = False
2036+
20142037
>>> doc = '''
20152038
... >>> x = 42
20162039
... >>> raise Exception('clé')
@@ -2065,7 +2088,7 @@ def test_pdb_set_trace():
20652088
... finally:
20662089
... sys.stdin = real_stdin
20672090
--Return--
2068-
> <doctest test.test_doctest.test_doctest.test_pdb_set_trace[7]>(3)calls_set_trace()->None
2091+
> <doctest test.test_doctest.test_doctest.test_pdb_set_trace[9]>(3)calls_set_trace()->None
20692092
-> import pdb; pdb.set_trace()
20702093
(Pdb) print(y)
20712094
2
@@ -2133,6 +2156,8 @@ def test_pdb_set_trace():
21332156
Got:
21342157
9
21352158
TestResults(failed=1, attempted=3)
2159+
2160+
>>> traceback._COLORIZE = save_colorize
21362161
"""
21372162

21382163
def test_pdb_set_trace_nested():
@@ -2667,7 +2692,10 @@ def test_testfile(): r"""
26672692
called with the name of a file, which is taken to be relative to the
26682693
calling module. The return value is (#failures, #tests).
26692694
2670-
We don't want `-v` in sys.argv for these tests.
2695+
We don't want color or `-v` in sys.argv for these tests.
2696+
2697+
>>> save_colorize = traceback._COLORIZE
2698+
>>> traceback._COLORIZE = False
26712699
26722700
>>> save_argv = sys.argv
26732701
>>> if '-v' in sys.argv:
@@ -2835,6 +2863,7 @@ def test_testfile(): r"""
28352863
TestResults(failed=0, attempted=2)
28362864
>>> doctest.master = None # Reset master.
28372865
>>> sys.argv = save_argv
2866+
>>> traceback._COLORIZE = save_colorize
28382867
"""
28392868

28402869
class TestImporter(importlib.abc.MetaPathFinder, importlib.abc.ResourceLoader):
@@ -2972,6 +3001,9 @@ def test_testmod(): r"""
29723001
def test_unicode(): """
29733002
Check doctest with a non-ascii filename:
29743003
3004+
>>> save_colorize = traceback._COLORIZE
3005+
>>> traceback._COLORIZE = False
3006+
29753007
>>> doc = '''
29763008
... >>> raise Exception('clé')
29773009
... '''
@@ -2997,8 +3029,11 @@ def test_unicode(): """
29973029
raise Exception('clé')
29983030
Exception: clé
29993031
TestResults(failed=1, attempted=1)
3032+
3033+
>>> traceback._COLORIZE = save_colorize
30003034
"""
30013035

3036+
30023037
@doctest_skip_if(not support.has_subprocess_support)
30033038
def test_CLI(): r"""
30043039
The doctest module can be used to run doctests against an arbitrary file.
@@ -3290,6 +3325,9 @@ def test_run_doctestsuite_multiple_times():
32903325

32913326
def test_exception_with_note(note):
32923327
"""
3328+
>>> save_colorize = traceback._COLORIZE
3329+
>>> traceback._COLORIZE = False
3330+
32933331
>>> test_exception_with_note('Note')
32943332
Traceback (most recent call last):
32953333
...
@@ -3339,6 +3377,8 @@ def test_exception_with_note(note):
33393377
ValueError: message
33403378
note
33413379
TestResults(failed=1, attempted=...)
3380+
3381+
>>> traceback._COLORIZE = save_colorize
33423382
"""
33433383
exc = ValueError('Text')
33443384
exc.add_note(note)
@@ -3419,6 +3459,9 @@ def test_syntax_error_subclass_from_stdlib():
34193459

34203460
def test_syntax_error_with_incorrect_expected_note():
34213461
"""
3462+
>>> save_colorize = traceback._COLORIZE
3463+
>>> traceback._COLORIZE = False
3464+
34223465
>>> def f(x):
34233466
... r'''
34243467
... >>> exc = SyntaxError("error", ("x.py", 23, None, "bad syntax"))
@@ -3447,6 +3490,8 @@ def test_syntax_error_with_incorrect_expected_note():
34473490
note1
34483491
note2
34493492
TestResults(failed=1, attempted=...)
3493+
3494+
>>> traceback._COLORIZE = save_colorize
34503495
"""
34513496

34523497

Lib/traceback.py

+4
Original file line numberDiff line numberDiff line change
@@ -448,8 +448,12 @@ class _ANSIColors:
448448
BOLD_RED = '\x1b[1;31m'
449449
MAGENTA = '\x1b[35m'
450450
BOLD_MAGENTA = '\x1b[1;35m'
451+
GREEN = "\x1b[32m"
452+
BOLD_GREEN = "\x1b[1;32m"
451453
GREY = '\x1b[90m'
452454
RESET = '\x1b[0m'
455+
YELLOW = "\x1b[33m"
456+
453457

454458
class StackSummary(list):
455459
"""A list of FrameSummary objects, representing a stack of frames."""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add colour to doctest output. Patch by Hugo van Kemenade.

0 commit comments

Comments
 (0)