From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:18:53 +0000 (+0300) Subject: gh-146609: Add colour to `timeit` CLI output (#146610) X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=8687b9d731b1e402214cace2994963fb88f5a41f;p=thirdparty%2FPython%2Fcpython.git gh-146609: Add colour to `timeit` CLI output (#146610) Co-authored-by: Stan Ulbrych --- diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 71870a38a6a8..6543da4487e4 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -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 `. + (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/Lib/_colorize.py b/Lib/_colorize.py index bd2070ea97d3..478f81894911 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -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(), ) diff --git a/Lib/test/test_timeit.py b/Lib/test/test_timeit.py index 8837e88ba638..4971ebe0b78e 100644 --- a/Lib/test/test_timeit.py +++ b/Lib/test/test_timeit.py @@ -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() diff --git a/Lib/timeit.py b/Lib/timeit.py index f900da6ffe7d..130777768f79 100644 --- a/Lib/timeit.py +++ b/Lib/timeit.py @@ -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 index 000000000000..854fcc32ab76 --- /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.