From b024e50773ac30ea457f856f5db8879f2f431341 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Sat, 6 Dec 2025 19:49:11 +0000 Subject: [PATCH 1/6] gh-138122: Integrate live profiler TUI with _colorize theming system The Tachyon profiler's curses-based TUI now uses the centralized theming infrastructure in _colorize.py, enabling users to customize colors via the standard Python theming API. This adds a LiveProfiler theme section with two pre-configured themes: the default dark theme optimized for dark terminal backgrounds, and LiveProfilerLight for white/light backgrounds. Users can switch themes by calling _colorize.set_theme() in their PYTHONSTARTUP or sitecustomize.py. The table header rendering was also improved to draw a continuous background, eliminating visual gaps between columns when using reverse video styling. --- Lib/_colorize.py | 133 ++++++++++++++++++ .../sampling/live_collector/collector.py | 89 +++++------- .../sampling/live_collector/widgets.py | 49 ++++--- ...-12-06-19-49-20.gh-issue-138122.m3EF9E.rst | 4 + 4 files changed, 198 insertions(+), 77 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-12-06-19-49-20.gh-issue-138122.m3EF9E.rst diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 29d7cc67b6e39d..5df87010c05c49 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -68,6 +68,19 @@ class ANSIColors: ColorCodes = set() NoColors = ANSIColors() + +class CursesColors: + """Curses color constants for terminal UI theming.""" + BLACK = 0 + RED = 1 + GREEN = 2 + YELLOW = 3 + BLUE = 4 + MAGENTA = 5 + CYAN = 6 + WHITE = 7 + DEFAULT = -1 + for attr, code in ANSIColors.__dict__.items(): if not attr.startswith("__"): ColorCodes.add(code) @@ -223,6 +236,119 @@ class Unittest(ThemeSection): reset: str = ANSIColors.RESET +@dataclass(frozen=True, kw_only=True) +class LiveProfiler(ThemeSection): + """Theme section for the live profiling TUI (Tachyon profiler). + + Colors use CursesColors constants (BLACK, RED, GREEN, YELLOW, + BLUE, MAGENTA, CYAN, WHITE, DEFAULT). + """ + # Header colors + title_fg: int = CursesColors.CYAN + title_bg: int = CursesColors.DEFAULT + + # Status display colors + pid_fg: int = CursesColors.CYAN + uptime_fg: int = CursesColors.GREEN + time_fg: int = CursesColors.YELLOW + interval_fg: int = CursesColors.MAGENTA + + # Thread view colors + thread_all_fg: int = CursesColors.GREEN + thread_single_fg: int = CursesColors.MAGENTA + + # Progress bar colors + bar_good_fg: int = CursesColors.GREEN + bar_bad_fg: int = CursesColors.RED + + # Stats colors + on_gil_fg: int = CursesColors.GREEN + off_gil_fg: int = CursesColors.RED + waiting_gil_fg: int = CursesColors.YELLOW + gc_fg: int = CursesColors.MAGENTA + + # Function display colors + func_total_fg: int = CursesColors.CYAN + func_exec_fg: int = CursesColors.GREEN + func_stack_fg: int = CursesColors.YELLOW + func_shown_fg: int = CursesColors.MAGENTA + + # Table header colors + sorted_header_fg: int = CursesColors.BLACK + sorted_header_bg: int = CursesColors.YELLOW + + # Data row colors + samples_fg: int = CursesColors.CYAN + file_fg: int = CursesColors.GREEN + func_fg: int = CursesColors.YELLOW + + # Trend indicator colors + trend_up_fg: int = CursesColors.GREEN + trend_down_fg: int = CursesColors.RED + + # Medal colors for top functions + medal_gold_fg: int = CursesColors.RED + medal_silver_fg: int = CursesColors.YELLOW + medal_bronze_fg: int = CursesColors.GREEN + + # Background style: 'dark' or 'light' + background_style: str = "dark" + + +LiveProfilerLight = LiveProfiler( + # Header colors + title_fg=CursesColors.BLUE, + title_bg=CursesColors.DEFAULT, + + # Status display colors + pid_fg=CursesColors.BLUE, + uptime_fg=CursesColors.GREEN, + time_fg=CursesColors.YELLOW, + interval_fg=CursesColors.MAGENTA, + + # Thread view colors + thread_all_fg=CursesColors.GREEN, + thread_single_fg=CursesColors.MAGENTA, + + # Progress bar colors + bar_good_fg=CursesColors.GREEN, + bar_bad_fg=CursesColors.RED, + + # Stats colors + on_gil_fg=CursesColors.GREEN, + off_gil_fg=CursesColors.RED, + waiting_gil_fg=CursesColors.YELLOW, + gc_fg=CursesColors.MAGENTA, + + # Function display colors + func_total_fg=CursesColors.BLUE, + func_exec_fg=CursesColors.GREEN, + func_stack_fg=CursesColors.YELLOW, + func_shown_fg=CursesColors.MAGENTA, + + # Table header colors + sorted_header_fg=CursesColors.WHITE, + sorted_header_bg=CursesColors.BLUE, + + # Data row colors + samples_fg=CursesColors.BLUE, + file_fg=CursesColors.GREEN, + func_fg=CursesColors.MAGENTA, + + # Trend indicator colors + trend_up_fg=CursesColors.GREEN, + trend_down_fg=CursesColors.RED, + + # Medal colors for top functions + medal_gold_fg=CursesColors.RED, + medal_silver_fg=CursesColors.BLUE, + medal_bronze_fg=CursesColors.GREEN, + + # Background style + background_style="light", +) + + @dataclass(frozen=True, kw_only=True) class Theme: """A suite of themes for all sections of Python. @@ -235,6 +361,7 @@ class Theme: syntax: Syntax = field(default_factory=Syntax) traceback: Traceback = field(default_factory=Traceback) unittest: Unittest = field(default_factory=Unittest) + live_profiler: LiveProfiler = field(default_factory=LiveProfiler) def copy_with( self, @@ -244,6 +371,7 @@ def copy_with( syntax: Syntax | None = None, traceback: Traceback | None = None, unittest: Unittest | None = None, + live_profiler: LiveProfiler | None = None, ) -> Self: """Return a new Theme based on this instance with some sections replaced. @@ -256,6 +384,7 @@ def copy_with( syntax=syntax or self.syntax, traceback=traceback or self.traceback, unittest=unittest or self.unittest, + live_profiler=live_profiler or self.live_profiler, ) @classmethod @@ -272,6 +401,7 @@ def no_colors(cls) -> Self: syntax=Syntax.no_colors(), traceback=Traceback.no_colors(), unittest=Unittest.no_colors(), + live_profiler=LiveProfiler.no_colors(), ) @@ -338,6 +468,9 @@ def _safe_getenv(k: str, fallback: str | None = None) -> str | None: default_theme = Theme() theme_no_color = default_theme.no_colors() +# Convenience theme with light profiler colors (for white/light terminal backgrounds) +light_profiler_theme = default_theme.copy_with(live_profiler=LiveProfilerLight) + def get_theme( *, diff --git a/Lib/profiling/sampling/live_collector/collector.py b/Lib/profiling/sampling/live_collector/collector.py index 7adbf1bbe7f625..4b9e5fd94cb5b8 100644 --- a/Lib/profiling/sampling/live_collector/collector.py +++ b/Lib/profiling/sampling/live_collector/collector.py @@ -525,79 +525,57 @@ def _cycle_sort(self, reverse=False): def _setup_colors(self): """Set up color pairs and return color attributes.""" - A_BOLD = self.display.get_attr("A_BOLD") A_REVERSE = self.display.get_attr("A_REVERSE") A_UNDERLINE = self.display.get_attr("A_UNDERLINE") A_NORMAL = self.display.get_attr("A_NORMAL") - # Check both curses color support and _colorize.can_colorize() if self.display.has_colors() and self._can_colorize: with contextlib.suppress(Exception): - # Color constants (using curses values for compatibility) - COLOR_CYAN = 6 - COLOR_GREEN = 2 - COLOR_YELLOW = 3 - COLOR_BLACK = 0 - COLOR_MAGENTA = 5 - COLOR_RED = 1 - - # Initialize all color pairs used throughout the UI - self.display.init_color_pair( - 1, COLOR_CYAN, -1 - ) # Data colors for stats rows - self.display.init_color_pair(2, COLOR_GREEN, -1) - self.display.init_color_pair(3, COLOR_YELLOW, -1) - self.display.init_color_pair( - COLOR_PAIR_HEADER_BG, COLOR_BLACK, COLOR_GREEN - ) - self.display.init_color_pair( - COLOR_PAIR_CYAN, COLOR_CYAN, COLOR_BLACK - ) - self.display.init_color_pair( - COLOR_PAIR_YELLOW, COLOR_YELLOW, COLOR_BLACK - ) + theme = _colorize.get_theme(force_color=True) + profiler_theme = theme.live_profiler + default_bg = -1 + + self.display.init_color_pair(1, profiler_theme.samples_fg, default_bg) + self.display.init_color_pair(2, profiler_theme.file_fg, default_bg) + self.display.init_color_pair(3, profiler_theme.func_fg, default_bg) + + header_bg = 2 if profiler_theme.background_style == "dark" else 4 + self.display.init_color_pair(COLOR_PAIR_HEADER_BG, 0, header_bg) + + self.display.init_color_pair(COLOR_PAIR_CYAN, profiler_theme.pid_fg, default_bg) + self.display.init_color_pair(COLOR_PAIR_YELLOW, profiler_theme.time_fg, default_bg) + self.display.init_color_pair(COLOR_PAIR_GREEN, profiler_theme.uptime_fg, default_bg) + self.display.init_color_pair(COLOR_PAIR_MAGENTA, profiler_theme.interval_fg, default_bg) + self.display.init_color_pair(COLOR_PAIR_RED, profiler_theme.off_gil_fg, default_bg) self.display.init_color_pair( - COLOR_PAIR_GREEN, COLOR_GREEN, COLOR_BLACK - ) - self.display.init_color_pair( - COLOR_PAIR_MAGENTA, COLOR_MAGENTA, COLOR_BLACK - ) - self.display.init_color_pair( - COLOR_PAIR_RED, COLOR_RED, COLOR_BLACK - ) - self.display.init_color_pair( - COLOR_PAIR_SORTED_HEADER, COLOR_BLACK, COLOR_YELLOW + COLOR_PAIR_SORTED_HEADER, + profiler_theme.sorted_header_fg, + profiler_theme.sorted_header_bg, ) + TREND_UP_PAIR = 11 + TREND_DOWN_PAIR = 12 + self.display.init_color_pair(TREND_UP_PAIR, profiler_theme.trend_up_fg, default_bg) + self.display.init_color_pair(TREND_DOWN_PAIR, profiler_theme.trend_down_fg, default_bg) + return { - "header": self.display.get_color_pair(COLOR_PAIR_HEADER_BG) - | A_BOLD, - "cyan": self.display.get_color_pair(COLOR_PAIR_CYAN) - | A_BOLD, - "yellow": self.display.get_color_pair(COLOR_PAIR_YELLOW) - | A_BOLD, - "green": self.display.get_color_pair(COLOR_PAIR_GREEN) - | A_BOLD, - "magenta": self.display.get_color_pair(COLOR_PAIR_MAGENTA) - | A_BOLD, - "red": self.display.get_color_pair(COLOR_PAIR_RED) - | A_BOLD, - "sorted_header": self.display.get_color_pair( - COLOR_PAIR_SORTED_HEADER - ) - | A_BOLD, + "header": self.display.get_color_pair(COLOR_PAIR_HEADER_BG) | A_BOLD, + "cyan": self.display.get_color_pair(COLOR_PAIR_CYAN) | A_BOLD, + "yellow": self.display.get_color_pair(COLOR_PAIR_YELLOW) | A_BOLD, + "green": self.display.get_color_pair(COLOR_PAIR_GREEN) | A_BOLD, + "magenta": self.display.get_color_pair(COLOR_PAIR_MAGENTA) | A_BOLD, + "red": self.display.get_color_pair(COLOR_PAIR_RED) | A_BOLD, + "sorted_header": self.display.get_color_pair(COLOR_PAIR_SORTED_HEADER) | A_BOLD, "normal_header": A_REVERSE | A_BOLD, "color_samples": self.display.get_color_pair(1), "color_file": self.display.get_color_pair(2), "color_func": self.display.get_color_pair(3), - # Trend colors (stock-like indicators) - "trend_up": self.display.get_color_pair(COLOR_PAIR_GREEN) | A_BOLD, - "trend_down": self.display.get_color_pair(COLOR_PAIR_RED) | A_BOLD, + "trend_up": self.display.get_color_pair(TREND_UP_PAIR) | A_BOLD, + "trend_down": self.display.get_color_pair(TREND_DOWN_PAIR) | A_BOLD, "trend_stable": A_NORMAL, } - # Fallback to non-color attributes return { "header": A_REVERSE | A_BOLD, "cyan": A_BOLD, @@ -610,7 +588,6 @@ def _setup_colors(self): "color_samples": A_NORMAL, "color_file": A_NORMAL, "color_func": A_NORMAL, - # Trend colors (fallback to bold/normal for monochrome) "trend_up": A_BOLD, "trend_down": A_BOLD, "trend_stable": A_NORMAL, diff --git a/Lib/profiling/sampling/live_collector/widgets.py b/Lib/profiling/sampling/live_collector/widgets.py index 2af8caa2c2f6d9..1c122b7eab5cd4 100644 --- a/Lib/profiling/sampling/live_collector/widgets.py +++ b/Lib/profiling/sampling/live_collector/widgets.py @@ -639,8 +639,6 @@ def render(self, line, width, **kwargs): def draw_column_headers(self, line, width): """Draw column headers with sort indicators.""" - col = 0 - # Determine which columns to show based on width show_sample_pct = width >= WIDTH_THRESHOLD_SAMPLE_PCT show_tottime = width >= WIDTH_THRESHOLD_TOTTIME @@ -659,38 +657,38 @@ def draw_column_headers(self, line, width): "cumtime": 4, }.get(self.collector.sort_by, -1) + # Build the full header line first, then draw it + # This avoids gaps between columns when using reverse video + header_parts = [] + col = 0 + # Column 0: nsamples - attr = sorted_header if sort_col == 0 else normal_header - text = f"{'▼nsamples' if sort_col == 0 else 'nsamples':>13}" - self.add_str(line, col, text, attr) + text = f"{'▼nsamples' if sort_col == 0 else 'nsamples':>13} " + header_parts.append((col, text, sorted_header if sort_col == 0 else normal_header)) col += 15 # Column 1: sample % if show_sample_pct: - attr = sorted_header if sort_col == 1 else normal_header - text = f"{'▼%' if sort_col == 1 else '%':>5}" - self.add_str(line, col, text, attr) + text = f"{'▼%' if sort_col == 1 else '%':>5} " + header_parts.append((col, text, sorted_header if sort_col == 1 else normal_header)) col += 7 # Column 2: tottime if show_tottime: - attr = sorted_header if sort_col == 2 else normal_header - text = f"{'▼tottime' if sort_col == 2 else 'tottime':>10}" - self.add_str(line, col, text, attr) + text = f"{'▼tottime' if sort_col == 2 else 'tottime':>10} " + header_parts.append((col, text, sorted_header if sort_col == 2 else normal_header)) col += 12 # Column 3: cumul % if show_cumul_pct: - attr = sorted_header if sort_col == 3 else normal_header - text = f"{'▼%' if sort_col == 3 else '%':>5}" - self.add_str(line, col, text, attr) + text = f"{'▼%' if sort_col == 3 else '%':>5} " + header_parts.append((col, text, sorted_header if sort_col == 3 else normal_header)) col += 7 # Column 4: cumtime if show_cumtime: - attr = sorted_header if sort_col == 4 else normal_header - text = f"{'▼cumtime' if sort_col == 4 else 'cumtime':>10}" - self.add_str(line, col, text, attr) + text = f"{'▼cumtime' if sort_col == 4 else 'cumtime':>10} " + header_parts.append((col, text, sorted_header if sort_col == 4 else normal_header)) col += 12 # Remaining headers @@ -700,13 +698,22 @@ def draw_column_headers(self, line, width): MAX_FUNC_NAME_WIDTH, max(MIN_FUNC_NAME_WIDTH, remaining_space // 2), ) - self.add_str( - line, col, f"{'function':<{func_width}}", normal_header - ) + text = f"{'function':<{func_width}} " + header_parts.append((col, text, normal_header)) col += func_width + 2 if col < width - 10: - self.add_str(line, col, "file:line", normal_header) + file_text = "file:line" + padding = width - col - len(file_text) + text = file_text + " " * max(0, padding) + header_parts.append((col, text, normal_header)) + + # Draw full-width background first + self.add_str(line, 0, " " * (width - 1), normal_header) + + # Draw each header part on top + for col_pos, text, attr in header_parts: + self.add_str(line, col_pos, text.rstrip(), attr) return ( line + 1, diff --git a/Misc/NEWS.d/next/Library/2025-12-06-19-49-20.gh-issue-138122.m3EF9E.rst b/Misc/NEWS.d/next/Library/2025-12-06-19-49-20.gh-issue-138122.m3EF9E.rst new file mode 100644 index 00000000000000..a32ae6f228e12f --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-12-06-19-49-20.gh-issue-138122.m3EF9E.rst @@ -0,0 +1,4 @@ +The Tachyon profiler's live TUI now integrates with the :mod:`!_colorize` +theming system, allowing users to customize colors via +:func:`!_colorize.set_theme`. A :class:`!LiveProfilerLight` theme is provided +for light terminal backgrounds. Patch by Pablo Galindo. From b1f1a937c48a2cb88b0d84e5e69653313cc308c8 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Tue, 9 Dec 2025 14:55:10 +0000 Subject: [PATCH 2/6] Update 2025-12-06-19-49-20.gh-issue-138122.m3EF9E.rst Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- .../Library/2025-12-06-19-49-20.gh-issue-138122.m3EF9E.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Misc/NEWS.d/next/Library/2025-12-06-19-49-20.gh-issue-138122.m3EF9E.rst b/Misc/NEWS.d/next/Library/2025-12-06-19-49-20.gh-issue-138122.m3EF9E.rst index a32ae6f228e12f..bd8a7a5ccdafaa 100644 --- a/Misc/NEWS.d/next/Library/2025-12-06-19-49-20.gh-issue-138122.m3EF9E.rst +++ b/Misc/NEWS.d/next/Library/2025-12-06-19-49-20.gh-issue-138122.m3EF9E.rst @@ -1,4 +1,4 @@ -The Tachyon profiler's live TUI now integrates with the :mod:`!_colorize` -theming system, allowing users to customize colors via +The Tachyon profiler's live TUI now integrates with the experimental +:mod:`!_colorize` theming system, allowing users to customize colors via :func:`!_colorize.set_theme`. A :class:`!LiveProfilerLight` theme is provided for light terminal backgrounds. Patch by Pablo Galindo. From 5ccaa25c8cb6861d5ae03a50a8caf2575503314e Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Tue, 9 Dec 2025 14:55:34 +0000 Subject: [PATCH 3/6] Update _colorize.py Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- Lib/_colorize.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 5df87010c05c49..97bcd637d976fe 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -358,10 +358,10 @@ class Theme: """ argparse: Argparse = field(default_factory=Argparse) difflib: Difflib = field(default_factory=Difflib) + live_profiler: LiveProfiler = field(default_factory=LiveProfiler) syntax: Syntax = field(default_factory=Syntax) traceback: Traceback = field(default_factory=Traceback) unittest: Unittest = field(default_factory=Unittest) - live_profiler: LiveProfiler = field(default_factory=LiveProfiler) def copy_with( self, From 2e42a81810a39712b6e91ef3bb8895fa302216a1 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Tue, 9 Dec 2025 14:55:40 +0000 Subject: [PATCH 4/6] Update _colorize.py Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- Lib/_colorize.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 97bcd637d976fe..0704aef3537e9f 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -368,10 +368,10 @@ def copy_with( *, argparse: Argparse | None = None, difflib: Difflib | None = None, + live_profiler: LiveProfiler | None = None, syntax: Syntax | None = None, traceback: Traceback | None = None, unittest: Unittest | None = None, - live_profiler: LiveProfiler | None = None, ) -> Self: """Return a new Theme based on this instance with some sections replaced. From 5bd5b75b051349789a76d266f22b915405a3f078 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Tue, 9 Dec 2025 14:55:46 +0000 Subject: [PATCH 5/6] Update _colorize.py Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- Lib/_colorize.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 0704aef3537e9f..3c7e51cd50b9ac 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -381,10 +381,10 @@ def copy_with( return type(self)( argparse=argparse or self.argparse, difflib=difflib or self.difflib, + live_profiler=live_profiler or self.live_profiler, syntax=syntax or self.syntax, traceback=traceback or self.traceback, unittest=unittest or self.unittest, - live_profiler=live_profiler or self.live_profiler, ) @classmethod From 214a33945eda2f2070f64bdeafb9a4dfe45dde05 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Tue, 9 Dec 2025 14:55:57 +0000 Subject: [PATCH 6/6] Update _colorize.py Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- Lib/_colorize.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 3c7e51cd50b9ac..a22c42171a990f 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -398,10 +398,10 @@ def no_colors(cls) -> Self: return cls( argparse=Argparse.no_colors(), difflib=Difflib.no_colors(), + live_profiler=LiveProfiler.no_colors(), syntax=Syntax.no_colors(), traceback=Traceback.no_colors(), unittest=Unittest.no_colors(), - live_profiler=LiveProfiler.no_colors(), )