From 2d4380c2de71b5e5cf30ce1bfbfbb35a2af2ee7a Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:29:47 +0200 Subject: [PATCH 1/6] Add colour to timeit CLI output --- Lib/_colorize.py | 18 +++++++++++++++++ Lib/test/test_timeit.py | 39 +++++++++++++++++++++++++++++++---- Lib/timeit.py | 45 +++++++++++++++++++++++++++++------------ 3 files changed, 85 insertions(+), 17 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index fd0ae9d6145961..95e3f431671237 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -323,6 +323,20 @@ 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 + arrow: str = ANSIColors.GREY + warning: str = ANSIColors.YELLOW + warning_worst: str = ANSIColors.MAGENTA + warning_worst_timing: str = ANSIColors.BOLD_MAGENTA + warning_best: str = ANSIColors.GREEN + warning_best_timing: str = ANSIColors.BOLD_GREEN + reset: str = ANSIColors.RESET + + @dataclass(frozen=True, kw_only=True) class Traceback(ThemeSection): type: str = ANSIColors.BOLD_MAGENTA @@ -356,6 +370,7 @@ class Theme: difflib: Difflib = field(default_factory=Difflib) 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) @@ -366,6 +381,7 @@ def copy_with( difflib: Difflib | None = None, live_profiler: LiveProfiler | None = None, syntax: Syntax | None = None, + timeit: Timeit | None = None, traceback: Traceback | None = None, unittest: Unittest | None = None, ) -> Self: @@ -379,6 +395,7 @@ def copy_with( difflib=difflib or self.difflib, 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, ) @@ -396,6 +413,7 @@ def no_colors(cls) -> Self: difflib=Difflib.no_colors(), live_profiler=LiveProfiler.no_colors(), syntax=Syntax.no_colors(), + timeit=Timeit.no_colors(), traceback=Traceback.no_colors(), unittest=Unittest.no_colors(), ) diff --git a/Lib/test/test_timeit.py b/Lib/test/test_timeit.py index f8bc306b455a5d..6e136c348528ed 100644 --- a/Lib/test/test_timeit.py +++ b/Lib/test/test_timeit.py @@ -5,9 +5,14 @@ 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 @@ def wrap_timer(self, timer): self.saved_timer = timer return self +@force_not_colorized_test_class class TestTimeit(unittest.TestCase): def tearDown(self): @@ -352,13 +358,11 @@ def test_main_with_time_unit(self): 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']) @@ -398,5 +402,32 @@ def callback(a, b): self.assertEqual(s.getvalue(), expected) -if __name__ == '__main__': +class TestTimeitColor(TestTimeit): + + @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.arrow}-> {t.timing}1 secs{t.reset}\n\n" + "raw times: " + f"{t.timing}1 sec, 1 sec, 1 sec, 1 sec, 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() diff --git a/Lib/timeit.py b/Lib/timeit.py index 80791acdeca23f..429b238dbf6a71 100644 --- a/Lib/timeit.py +++ b/Lib/timeit.py @@ -268,6 +268,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:pvh", @@ -328,10 +330,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.arrow}-> " + f"{theme.timing}{time_taken:.{precision}g} secs{reset}" + ) + try: number, _ = t.autorange(callback) except: @@ -362,24 +367,38 @@ def format_time(dt): return "%.*g %s" % (precision, dt / scale, unit) if verbose: - print("raw times: %s" % ", ".join(map(format_time, raw_timings))) + raw = ", ".join(map(format_time, raw_timings)) + print(f"raw times: {theme.timing}{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))) + 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}" + ) best = min(timings) worst = max(timings) 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 (" + f"{theme.warning_worst_timing}{format_time(worst)}{reset}" + f"{theme.warning_worst})" + f"{theme.warning} was more than " + f"{theme.warning_worst}four times slower" + f"{theme.warning} than the " + f"{theme.warning_best}best time (" + f"{theme.warning_best_timing}{format_time(best)}{reset}" + f"{theme.warning_best}){theme.warning}.{reset}", + UserWarning, "", 0, + ) return None From 0f800b3459966ae41f0e78e9bfc333a0908f3091 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:33:12 +0200 Subject: [PATCH 2/6] Only calculate 'best = min(timings)' once --- Lib/timeit.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/timeit.py b/Lib/timeit.py index 429b238dbf6a71..c24607f8261e5b 100644 --- a/Lib/timeit.py +++ b/Lib/timeit.py @@ -373,6 +373,7 @@ def format_time(dt): timings = [dt / number for dt in raw_timings] best = min(timings) + worst = max(timings) s = "" if number == 1 else "s" print( f"{number} loop{s}, best of {repeat}: " @@ -380,8 +381,6 @@ def format_time(dt): f"{theme.per_loop}per loop{reset}" ) - best = min(timings) - worst = max(timings) if worst >= best * 4: import warnings From 28e8af9437ff217c1dc042e3c76374b39e414fbb Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:31:32 +0300 Subject: [PATCH 3/6] What's New and blurb --- Doc/whatsnew/3.15.rst | 4 ++++ .../Library/2026-03-29-21-31-14.gh-issue-146609.BnshCt.rst | 1 + 2 files changed, 5 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-03-29-21-31-14.gh-issue-146609.BnshCt.rst diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 7d13eccb22311f..576cb2f307735c 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -1070,6 +1070,10 @@ tarfile timeit ------ +* The output of the :mod:`timeit` command-line interface is colored by default. + This can be controlled with + :ref:`environment variables `. + (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 `. 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 index 00000000000000..854fcc32ab76e1 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-03-29-21-31-14.gh-issue-146609.BnshCt.rst @@ -0,0 +1 @@ +Add colour to :mod:`timeit` CLI output. Patch by Hugo van Kemenade. From f6ef7018d12d95a68f6e830228748c9224f72738 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:55:06 +0300 Subject: [PATCH 4/6] Avoid re-running duplicate tests --- Lib/test/test_timeit.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_timeit.py b/Lib/test/test_timeit.py index 6e136c348528ed..e2af02524ff5ee 100644 --- a/Lib/test/test_timeit.py +++ b/Lib/test/test_timeit.py @@ -402,7 +402,10 @@ def callback(a, b): self.assertEqual(s.getvalue(), expected) -class TestTimeitColor(TestTimeit): +class TestTimeitColor(unittest.TestCase): + + fake_stmt = TestTimeit.fake_stmt + run_main = TestTimeit.run_main @force_colorized def test_main_colorized(self): From ff4c493e849b9753c55aa6dbc02035145f107f98 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:06:57 +0300 Subject: [PATCH 5/6] Simplify warning colours --- Lib/_colorize.py | 2 -- Lib/timeit.py | 13 ++++--------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 95e3f431671237..a5112ac000108d 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -331,9 +331,7 @@ class Timeit(ThemeSection): arrow: str = ANSIColors.GREY warning: str = ANSIColors.YELLOW warning_worst: str = ANSIColors.MAGENTA - warning_worst_timing: str = ANSIColors.BOLD_MAGENTA warning_best: str = ANSIColors.GREEN - warning_best_timing: str = ANSIColors.BOLD_GREEN reset: str = ANSIColors.RESET diff --git a/Lib/timeit.py b/Lib/timeit.py index c24607f8261e5b..573d2beed3b2ce 100644 --- a/Lib/timeit.py +++ b/Lib/timeit.py @@ -387,15 +387,10 @@ def format_time(dt): print(file=sys.stderr) warnings.warn_explicit( f"{theme.warning}The test results are likely unreliable. " - f"The {theme.warning_worst}worst time (" - f"{theme.warning_worst_timing}{format_time(worst)}{reset}" - f"{theme.warning_worst})" - f"{theme.warning} was more than " - f"{theme.warning_worst}four times slower" - f"{theme.warning} than the " - f"{theme.warning_best}best time (" - f"{theme.warning_best_timing}{format_time(best)}{reset}" - f"{theme.warning_best}){theme.warning}.{reset}", + 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 From 7e75e56b4935b5100eeab9065d4dc0b7cc09d4bb Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:12:14 +0300 Subject: [PATCH 6/6] Adjust spacing Co-authored-by: Stan Ulbrych --- Lib/timeit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/timeit.py b/Lib/timeit.py index 573d2beed3b2ce..5fbad1675fd8a6 100644 --- a/Lib/timeit.py +++ b/Lib/timeit.py @@ -377,7 +377,7 @@ def format_time(dt): s = "" if number == 1 else "s" print( f"{number} loop{s}, best of {repeat}: " - f"{theme.best}{format_time(best)} {reset}" + f"{theme.best}{format_time(best)}{reset} " f"{theme.per_loop}per loop{reset}" )