]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-146609: Add colour to `timeit` CLI output (#146610)
authorHugo van Kemenade <1324225+hugovk@users.noreply.github.com>
Wed, 8 Apr 2026 13:18:53 +0000 (16:18 +0300)
committerGitHub <noreply@github.com>
Wed, 8 Apr 2026 13:18:53 +0000 (16:18 +0300)
Co-authored-by: Stan Ulbrych <stan@python.org>
Doc/whatsnew/3.15.rst
Lib/_colorize.py
Lib/test/test_timeit.py
Lib/timeit.py
Misc/NEWS.d/next/Library/2026-03-29-21-31-14.gh-issue-146609.BnshCt.rst [new file with mode: 0644]

index 71870a38a6a8d647cd61110832149cd86af195cd..6543da4487e42f33f7436d0a7bb51724725dce44 100644 (file)
@@ -1108,6 +1108,10 @@ tarfile
 timeit
 ------
 
+* The output of the :mod:`timeit` command-line interface is colored by default.
+  This can be controlled with
+  :ref:`environment variables <using-on-controlling-color>`.
+  (Contributed by Hugo van Kemenade in :gh:`146609`.)
 * The command-line interface now colorizes error tracebacks
   by default. This can be controlled with
   :ref:`environment variables <using-on-controlling-color>`.
index bd2070ea97d3707c9f6163612de9adeabda45ca3..478f81894911e73ad8524c4dfe73753c30134cd8 100644 (file)
@@ -362,6 +362,18 @@ class Syntax(ThemeSection):
     reset: str = ANSIColors.RESET
 
 
+@dataclass(frozen=True, kw_only=True)
+class Timeit(ThemeSection):
+    timing: str = ANSIColors.CYAN
+    best: str = ANSIColors.BOLD_GREEN
+    per_loop: str = ANSIColors.GREEN
+    punctuation: str = ANSIColors.GREY
+    warning: str = ANSIColors.YELLOW
+    warning_worst: str = ANSIColors.MAGENTA
+    warning_best: str = ANSIColors.GREEN
+    reset: str = ANSIColors.RESET
+
+
 @dataclass(frozen=True, kw_only=True)
 class Traceback(ThemeSection):
     type: str = ANSIColors.BOLD_MAGENTA
@@ -397,6 +409,7 @@ class Theme:
     http_server: HttpServer = field(default_factory=HttpServer)
     live_profiler: LiveProfiler = field(default_factory=LiveProfiler)
     syntax: Syntax = field(default_factory=Syntax)
+    timeit: Timeit = field(default_factory=Timeit)
     traceback: Traceback = field(default_factory=Traceback)
     unittest: Unittest = field(default_factory=Unittest)
 
@@ -409,6 +422,7 @@ class Theme:
         http_server: HttpServer | None = None,
         live_profiler: LiveProfiler | None = None,
         syntax: Syntax | None = None,
+        timeit: Timeit | None = None,
         traceback: Traceback | None = None,
         unittest: Unittest | None = None,
     ) -> Self:
@@ -424,6 +438,7 @@ class Theme:
             http_server=http_server or self.http_server,
             live_profiler=live_profiler or self.live_profiler,
             syntax=syntax or self.syntax,
+            timeit=timeit or self.timeit,
             traceback=traceback or self.traceback,
             unittest=unittest or self.unittest,
         )
@@ -443,6 +458,7 @@ class Theme:
             http_server=HttpServer.no_colors(),
             live_profiler=LiveProfiler.no_colors(),
             syntax=Syntax.no_colors(),
+            timeit=Timeit.no_colors(),
             traceback=Traceback.no_colors(),
             unittest=Unittest.no_colors(),
         )
index 8837e88ba638cfdbcdbf75e2ff935da22aec7476..4971ebe0b78ef8eaa1af7525cf13fa7ef49d3e7b 100644 (file)
@@ -5,9 +5,14 @@ import io
 from textwrap import dedent
 
 from test.support import (
-    captured_stdout, captured_stderr, force_not_colorized,
+    captured_stderr,
+    captured_stdout,
+    force_colorized,
+    force_not_colorized_test_class,
 )
 
+from _colorize import get_theme
+
 # timeit's default number of iterations.
 DEFAULT_NUMBER = 1000000
 
@@ -42,6 +47,7 @@ class FakeTimer:
         self.saved_timer = timer
         return self
 
+@force_not_colorized_test_class
 class TestTimeit(unittest.TestCase):
 
     def tearDown(self):
@@ -352,13 +358,11 @@ class TestTimeit(unittest.TestCase):
         self.assertEqual(error_stringio.getvalue(),
                     "Unrecognized unit. Please select nsec, usec, msec, or sec.\n")
 
-    @force_not_colorized
     def test_main_exception(self):
         with captured_stderr() as error_stringio:
             s = self.run_main(switches=['1/0'])
         self.assert_exc_string(error_stringio.getvalue(), 'ZeroDivisionError')
 
-    @force_not_colorized
     def test_main_exception_fixed_reps(self):
         with captured_stderr() as error_stringio:
             s = self.run_main(switches=['-n1', '1/0'])
@@ -403,5 +407,39 @@ class TestTimeit(unittest.TestCase):
         self.assertEqual(s.getvalue(), expected)
 
 
-if __name__ == '__main__':
+class TestTimeitColor(unittest.TestCase):
+
+    fake_stmt = TestTimeit.fake_stmt
+    run_main = TestTimeit.run_main
+
+    @force_colorized
+    def test_main_colorized(self):
+        t = get_theme(force_color=True).timeit
+        s = self.run_main(seconds_per_increment=5.5)
+        self.assertEqual(
+            s,
+            "1 loop, best of 5: "
+            f"{t.best}5.5 sec{t.reset} "
+            f"{t.per_loop}per loop{t.reset}\n",
+        )
+
+    @force_colorized
+    def test_main_verbose_colorized(self):
+        t = get_theme(force_color=True).timeit
+        s = self.run_main(switches=["-v"])
+        self.assertEqual(
+            s,
+            f"1 loop {t.punctuation}-> {t.timing}1 secs{t.reset}\n\n"
+            "raw times: "
+            f"{t.timing}1 sec{t.punctuation}, "
+            f"{t.timing}1 sec{t.punctuation}, "
+            f"{t.timing}1 sec{t.punctuation}, "
+            f"{t.timing}1 sec{t.punctuation}, "
+            f"{t.timing}1 sec{t.reset}\n\n"
+            f"1 loop, best of 5: {t.best}1 sec{t.reset} "
+            f"{t.per_loop}per loop{t.reset}\n",
+        )
+
+
+if __name__ == "__main__":
     unittest.main()
index f900da6ffe7d67a93d2919821864b05adfd1de6e..130777768f79fcc74e6f812aee52b8a0caf32667 100644 (file)
@@ -273,6 +273,8 @@ def main(args=None, *, _wrap_timer=None):
         args = sys.argv[1:]
     import _colorize
     colorize = _colorize.can_colorize()
+    theme = _colorize.get_theme(force_color=colorize).timeit
+    reset = theme.reset
 
     try:
         opts, args = getopt.getopt(args, "n:u:s:r:pt:vh",
@@ -337,10 +339,13 @@ def main(args=None, *, _wrap_timer=None):
         callback = None
         if verbose:
             def callback(number, time_taken):
-                msg = "{num} loop{s} -> {secs:.{prec}g} secs"
-                plural = (number != 1)
-                print(msg.format(num=number, s='s' if plural else '',
-                                 secs=time_taken, prec=precision))
+                s = "" if number == 1 else "s"
+                print(
+                    f"{number} loop{s} "
+                    f"{theme.punctuation}-> "
+                    f"{theme.timing}{time_taken:.{precision}g} secs{reset}"
+                )
+
         try:
             number, _ = t.autorange(callback, target_time)
         except:
@@ -371,24 +376,34 @@ def main(args=None, *, _wrap_timer=None):
         return "%.*g %s" % (precision, dt / scale, unit)
 
     if verbose:
-        print("raw times: %s" % ", ".join(map(format_time, raw_timings)))
+        raw = f"{theme.punctuation}, ".join(
+            f"{theme.timing}{t}" for t in map(format_time, raw_timings)
+        )
+        print(f"raw times: {raw}{reset}")
         print()
     timings = [dt / number for dt in raw_timings]
 
-    best = min(timings)
-    print("%d loop%s, best of %d: %s per loop"
-          % (number, 's' if number != 1 else '',
-             repeat, format_time(best)))
-
     best = min(timings)
     worst = max(timings)
+    s = "" if number == 1 else "s"
+    print(
+        f"{number} loop{s}, best of {repeat}: "
+        f"{theme.best}{format_time(best)}{reset} "
+        f"{theme.per_loop}per loop{reset}"
+    )
+
     if worst >= best * 4:
         import warnings
-        warnings.warn_explicit("The test results are likely unreliable. "
-                               "The worst time (%s) was more than four times "
-                               "slower than the best time (%s)."
-                               % (format_time(worst), format_time(best)),
-                               UserWarning, '', 0)
+
+        print(file=sys.stderr)
+        warnings.warn_explicit(
+            f"{theme.warning}The test results are likely unreliable. "
+            f"The {theme.warning_worst}worst time ({format_time(worst)})"
+            f"{theme.warning} was more than four times slower than the "
+            f"{theme.warning_best}best time ({format_time(best)})"
+            f"{theme.warning}.{reset}",
+            UserWarning, "", 0,
+        )
     return None
 
 
diff --git a/Misc/NEWS.d/next/Library/2026-03-29-21-31-14.gh-issue-146609.BnshCt.rst b/Misc/NEWS.d/next/Library/2026-03-29-21-31-14.gh-issue-146609.BnshCt.rst
new file mode 100644 (file)
index 0000000..854fcc3
--- /dev/null
@@ -0,0 +1 @@
+Add colour to :mod:`timeit` CLI output. Patch by Hugo van Kemenade.