]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-117225: Add color to doctest output (#117583)
authorHugo van Kemenade <1324225+hugovk@users.noreply.github.com>
Wed, 24 Apr 2024 11:27:40 +0000 (14:27 +0300)
committerGitHub <noreply@github.com>
Wed, 24 Apr 2024 11:27:40 +0000 (14:27 +0300)
Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
Lib/doctest.py
Lib/test/support/__init__.py
Lib/test/test_doctest/test_doctest.py
Lib/traceback.py
Misc/NEWS.d/next/Library/2024-04-06-18-41-36.gh-issue-117225.tJh1Hw.rst [new file with mode: 0644]

index 4e362cbb9c9d6b4adf526f87abcf44afb41ff46e..a3b42fdfb12254d33c7cefe058f21a2fd45153da 100644 (file)
@@ -104,6 +104,7 @@ import traceback
 import unittest
 from io import StringIO, IncrementalNewlineDecoder
 from collections import namedtuple
+from traceback import _ANSIColors, _can_colorize
 
 
 class TestResults(namedtuple('TestResults', 'failed attempted')):
@@ -1179,6 +1180,9 @@ class DocTestRunner:
     The `run` method is used to process a single DocTest case.  It
     returns a TestResults instance.
 
+        >>> save_colorize = traceback._COLORIZE
+        >>> traceback._COLORIZE = False
+
         >>> tests = DocTestFinder().find(_TestClass)
         >>> runner = DocTestRunner(verbose=False)
         >>> tests.sort(key = lambda test: test.name)
@@ -1229,6 +1233,8 @@ class DocTestRunner:
     can be also customized by subclassing DocTestRunner, and
     overriding the methods `report_start`, `report_success`,
     `report_unexpected_exception`, and `report_failure`.
+
+        >>> traceback._COLORIZE = save_colorize
     """
     # This divider string is used to separate failure messages, and to
     # separate sections of the summary.
@@ -1307,7 +1313,10 @@ class DocTestRunner:
             'Exception raised:\n' + _indent(_exception_traceback(exc_info)))
 
     def _failure_header(self, test, example):
-        out = [self.DIVIDER]
+        red, reset = (
+            (_ANSIColors.RED, _ANSIColors.RESET) if _can_colorize() else ("", "")
+        )
+        out = [f"{red}{self.DIVIDER}{reset}"]
         if test.filename:
             if test.lineno is not None and example.lineno is not None:
                 lineno = test.lineno + example.lineno + 1
@@ -1592,6 +1601,21 @@ class DocTestRunner:
             else:
                 failed.append((name, (failures, tries, skips)))
 
+        if _can_colorize():
+            bold_green = _ANSIColors.BOLD_GREEN
+            bold_red = _ANSIColors.BOLD_RED
+            green = _ANSIColors.GREEN
+            red = _ANSIColors.RED
+            reset = _ANSIColors.RESET
+            yellow = _ANSIColors.YELLOW
+        else:
+            bold_green = ""
+            bold_red = ""
+            green = ""
+            red = ""
+            reset = ""
+            yellow = ""
+
         if verbose:
             if notests:
                 print(f"{_n_items(notests)} had no tests:")
@@ -1600,13 +1624,13 @@ class DocTestRunner:
                     print(f"    {name}")
 
             if passed:
-                print(f"{_n_items(passed)} passed all tests:")
+                print(f"{green}{_n_items(passed)} passed all tests:{reset}")
                 for name, count in sorted(passed):
                     s = "" if count == 1 else "s"
-                    print(f" {count:3d} test{s} in {name}")
+                    print(f" {green}{count:3d} test{s} in {name}{reset}")
 
         if failed:
-            print(self.DIVIDER)
+            print(f"{red}{self.DIVIDER}{reset}")
             print(f"{_n_items(failed)} had failures:")
             for name, (failures, tries, skips) in sorted(failed):
                 print(f" {failures:3d} of {tries:3d} in {name}")
@@ -1615,18 +1639,21 @@ class DocTestRunner:
             s = "" if total_tries == 1 else "s"
             print(f"{total_tries} test{s} in {_n_items(self._stats)}.")
 
-            and_f = f" and {total_failures} failed" if total_failures else ""
-            print(f"{total_tries - total_failures} passed{and_f}.")
+            and_f = (
+                f" and {red}{total_failures} failed{reset}"
+                if total_failures else ""
+            )
+            print(f"{green}{total_tries - total_failures} passed{reset}{and_f}.")
 
         if total_failures:
             s = "" if total_failures == 1 else "s"
-            msg = f"***Test Failed*** {total_failures} failure{s}"
+            msg = f"{bold_red}***Test Failed*** {total_failures} failure{s}{reset}"
             if total_skips:
                 s = "" if total_skips == 1 else "s"
-                msg = f"{msg} and {total_skips} skipped test{s}"
+                msg = f"{msg} and {yellow}{total_skips} skipped test{s}{reset}"
             print(f"{msg}.")
         elif verbose:
-            print("Test passed.")
+            print(f"{bold_green}Test passed.{reset}")
 
         return TestResults(total_failures, total_tries, skipped=total_skips)
 
@@ -1644,7 +1671,7 @@ class DocTestRunner:
             d[name] = (failures, tries, skips)
 
 
-def _n_items(items: list) -> str:
+def _n_items(items: list | dict) -> str:
     """
     Helper to pluralise the number of items in a list.
     """
@@ -1655,7 +1682,7 @@ def _n_items(items: list) -> str:
 
 class OutputChecker:
     """
-    A class used to check the whether the actual output from a doctest
+    A class used to check whether the actual output from a doctest
     example matches the expected output.  `OutputChecker` defines two
     methods: `check_output`, which compares a given pair of outputs,
     and returns true if they match; and `output_difference`, which
index be3f93ab2e5fd1a4af02dabec9657b0bb1b3dda6..6eb0f84b02ea22a6020c8317b99a5a25b16e48b2 100644 (file)
@@ -26,7 +26,7 @@ __all__ = [
     "Error", "TestFailed", "TestDidNotRun", "ResourceDenied",
     # io
     "record_original_stdout", "get_original_stdout", "captured_stdout",
-    "captured_stdin", "captured_stderr",
+    "captured_stdin", "captured_stderr", "captured_output",
     # unittest
     "is_resource_enabled", "requires", "requires_freebsd_version",
     "requires_gil_enabled", "requires_linux_version", "requires_mac_ver",
index cba4b16d544a2041e944cbaa52c5368c27e9e87b..0f1e584e22a888fb799a085240e8dcadc63ceec1 100644 (file)
@@ -16,6 +16,7 @@ import unittest
 import tempfile
 import types
 import contextlib
+import traceback
 
 
 def doctest_skip_if(condition):
@@ -470,7 +471,7 @@ We'll simulate a __file__ attr that ends in pyc:
     >>> tests = finder.find(sample_func)
 
     >>> print(tests)  # doctest: +ELLIPSIS
-    [<DocTest sample_func from test_doctest.py:37 (1 example)>]
+    [<DocTest sample_func from test_doctest.py:38 (1 example)>]
 
 The exact name depends on how test_doctest was invoked, so allow for
 leading path components.
@@ -892,6 +893,9 @@ Unit tests for the `DocTestRunner` class.
 DocTestRunner is used to run DocTest test cases, and to accumulate
 statistics.  Here's a simple DocTest case we can use:
 
+    >>> save_colorize = traceback._COLORIZE
+    >>> traceback._COLORIZE = False
+
     >>> def f(x):
     ...     '''
     ...     >>> x = 12
@@ -946,6 +950,8 @@ the failure and proceeds to the next example:
         6
     ok
     TestResults(failed=1, attempted=3)
+
+    >>> traceback._COLORIZE = save_colorize
 """
     def verbose_flag(): r"""
 The `verbose` flag makes the test runner generate more detailed
@@ -1021,6 +1027,9 @@ An expected exception is specified with a traceback message.  The
 lines between the first line and the type/value may be omitted or
 replaced with any other string:
 
+    >>> save_colorize = traceback._COLORIZE
+    >>> traceback._COLORIZE = False
+
     >>> def f(x):
     ...     '''
     ...     >>> x = 12
@@ -1251,6 +1260,8 @@ unexpected exception:
         ...
         ZeroDivisionError: integer division or modulo by zero
     TestResults(failed=1, attempted=1)
+
+    >>> traceback._COLORIZE = save_colorize
 """
     def displayhook(): r"""
 Test that changing sys.displayhook doesn't matter for doctest.
@@ -1292,6 +1303,9 @@ together).
 The DONT_ACCEPT_TRUE_FOR_1 flag disables matches between True/False
 and 1/0:
 
+    >>> save_colorize = traceback._COLORIZE
+    >>> traceback._COLORIZE = False
+
     >>> def f(x):
     ...     '>>> True\n1\n'
 
@@ -1711,6 +1725,7 @@ more than one flag value.  Here we verify that's fixed:
 
 Clean up.
     >>> del doctest.OPTIONFLAGS_BY_NAME[unlikely]
+    >>> traceback._COLORIZE = save_colorize
 
     """
 
@@ -1721,6 +1736,9 @@ Option directives can be used to turn option flags on or off for a
 single example.  To turn an option on for an example, follow that
 example with a comment of the form ``# doctest: +OPTION``:
 
+    >>> save_colorize = traceback._COLORIZE
+    >>> traceback._COLORIZE = False
+
     >>> def f(x): r'''
     ...     >>> print(list(range(10)))      # should fail: no ellipsis
     ...     [0, 1, ..., 9]
@@ -1928,6 +1946,8 @@ source:
     >>> test = doctest.DocTestParser().get_doctest(s, {}, 's', 's.py', 0)
     Traceback (most recent call last):
     ValueError: line 0 of the doctest for s has an option directive on a line with no example: '# doctest: +ELLIPSIS'
+
+    >>> traceback._COLORIZE = save_colorize
 """
 
 def test_testsource(): r"""
@@ -2011,6 +2031,9 @@ if not hasattr(sys, 'gettrace') or not sys.gettrace():
         with a version that restores stdout.  This is necessary for you to
         see debugger output.
 
+          >>> save_colorize = traceback._COLORIZE
+          >>> traceback._COLORIZE = False
+
           >>> doc = '''
           ... >>> x = 42
           ... >>> raise Exception('clé')
@@ -2065,7 +2088,7 @@ if not hasattr(sys, 'gettrace') or not sys.gettrace():
           ... finally:
           ...     sys.stdin = real_stdin
           --Return--
-          > <doctest test.test_doctest.test_doctest.test_pdb_set_trace[7]>(3)calls_set_trace()->None
+          > <doctest test.test_doctest.test_doctest.test_pdb_set_trace[9]>(3)calls_set_trace()->None
           -> import pdb; pdb.set_trace()
           (Pdb) print(y)
           2
@@ -2133,6 +2156,8 @@ if not hasattr(sys, 'gettrace') or not sys.gettrace():
           Got:
               9
           TestResults(failed=1, attempted=3)
+
+          >>> traceback._COLORIZE = save_colorize
           """
 
     def test_pdb_set_trace_nested():
@@ -2667,7 +2692,10 @@ doctest examples in a given file.  In its simple invocation, it is
 called with the name of a file, which is taken to be relative to the
 calling module.  The return value is (#failures, #tests).
 
-We don't want `-v` in sys.argv for these tests.
+We don't want color or `-v` in sys.argv for these tests.
+
+    >>> save_colorize = traceback._COLORIZE
+    >>> traceback._COLORIZE = False
 
     >>> save_argv = sys.argv
     >>> if '-v' in sys.argv:
@@ -2835,6 +2863,7 @@ Test the verbose output:
     TestResults(failed=0, attempted=2)
     >>> doctest.master = None  # Reset master.
     >>> sys.argv = save_argv
+    >>> traceback._COLORIZE = save_colorize
 """
 
 class TestImporter(importlib.abc.MetaPathFinder, importlib.abc.ResourceLoader):
@@ -2972,6 +3001,9 @@ if supports_unicode:
     def test_unicode(): """
 Check doctest with a non-ascii filename:
 
+    >>> save_colorize = traceback._COLORIZE
+    >>> traceback._COLORIZE = False
+
     >>> doc = '''
     ... >>> raise Exception('clé')
     ... '''
@@ -2997,8 +3029,11 @@ Check doctest with a non-ascii filename:
             raise Exception('clé')
         Exception: clé
     TestResults(failed=1, attempted=1)
+
+    >>> traceback._COLORIZE = save_colorize
     """
 
+
 @doctest_skip_if(not support.has_subprocess_support)
 def test_CLI(): r"""
 The doctest module can be used to run doctests against an arbitrary file.
@@ -3290,6 +3325,9 @@ def test_run_doctestsuite_multiple_times():
 
 def test_exception_with_note(note):
     """
+    >>> save_colorize = traceback._COLORIZE
+    >>> traceback._COLORIZE = False
+
     >>> test_exception_with_note('Note')
     Traceback (most recent call last):
       ...
@@ -3339,6 +3377,8 @@ def test_exception_with_note(note):
         ValueError: message
         note
     TestResults(failed=1, attempted=...)
+
+    >>> traceback._COLORIZE = save_colorize
     """
     exc = ValueError('Text')
     exc.add_note(note)
@@ -3419,6 +3459,9 @@ def test_syntax_error_subclass_from_stdlib():
 
 def test_syntax_error_with_incorrect_expected_note():
     """
+    >>> save_colorize = traceback._COLORIZE
+    >>> traceback._COLORIZE = False
+
     >>> def f(x):
     ...     r'''
     ...     >>> exc = SyntaxError("error", ("x.py", 23, None, "bad syntax"))
@@ -3447,6 +3490,8 @@ def test_syntax_error_with_incorrect_expected_note():
         note1
         note2
     TestResults(failed=1, attempted=...)
+
+    >>> traceback._COLORIZE = save_colorize
     """
 
 
index d27c7a726d2bb6c0cb8f5f3dabf61f21bcb90579..054def57c214827561c324c05a093db46d0af3a6 100644 (file)
@@ -448,8 +448,12 @@ class _ANSIColors:
     BOLD_RED = '\x1b[1;31m'
     MAGENTA = '\x1b[35m'
     BOLD_MAGENTA = '\x1b[1;35m'
+    GREEN = "\x1b[32m"
+    BOLD_GREEN = "\x1b[1;32m"
     GREY = '\x1b[90m'
     RESET = '\x1b[0m'
+    YELLOW = "\x1b[33m"
+
 
 class StackSummary(list):
     """A list of FrameSummary objects, representing a stack of frames."""
diff --git a/Misc/NEWS.d/next/Library/2024-04-06-18-41-36.gh-issue-117225.tJh1Hw.rst b/Misc/NEWS.d/next/Library/2024-04-06-18-41-36.gh-issue-117225.tJh1Hw.rst
new file mode 100644 (file)
index 0000000..6a0da1c
--- /dev/null
@@ -0,0 +1 @@
+Add colour to doctest output. Patch by Hugo van Kemenade.