https://github.com/python/cpython/commit/e5ad7b7694c47555e3eac3fcb227a4b1b7b781c4 commit: e5ad7b7694c47555e3eac3fcb227a4b1b7b781c4 branch: main author: Pablo Galindo Salgado <[email protected]> committer: pablogsal <[email protected]> date: 2026-01-01T21:10:52Z summary:
gh-138122: Integrate live profiler TUI with _colorize theming system (#142360) Co-authored-by: Hugo van Kemenade <[email protected]> files: A Misc/NEWS.d/next/Library/2025-12-06-19-49-20.gh-issue-138122.m3EF9E.rst M Lib/_colorize.py M Lib/profiling/sampling/live_collector/collector.py M Lib/profiling/sampling/live_collector/constants.py M Lib/profiling/sampling/live_collector/display.py M Lib/profiling/sampling/live_collector/widgets.py diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 0b7047620b4556..5c4903f14aa86b 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -9,7 +9,7 @@ # types if False: - from typing import IO, Self, ClassVar + from typing import IO, Literal, Self, ClassVar _theme: Theme @@ -74,6 +74,19 @@ class ANSIColors: setattr(NoColors, attr, "") +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 + + # # Experimental theming support (see gh-133346) # @@ -187,6 +200,114 @@ class Difflib(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 (for sorted column highlight) + sorted_header_fg: int = CursesColors.BLACK + sorted_header_bg: int = CursesColors.CYAN + + # Normal header colors (non-sorted columns) - use reverse video style + normal_header_fg: int = CursesColors.BLACK + normal_header_bg: int = CursesColors.WHITE + + # 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: Literal["dark", "light"] = "dark" + + +LiveProfilerLight = LiveProfiler( + # Header colors + title_fg=CursesColors.BLUE, # Blue is more readable than cyan on light bg + + # Status display colors - darker colors for light backgrounds + pid_fg=CursesColors.BLUE, + uptime_fg=CursesColors.BLACK, + time_fg=CursesColors.BLACK, + interval_fg=CursesColors.BLUE, + + # Thread view colors + thread_all_fg=CursesColors.BLACK, + thread_single_fg=CursesColors.BLUE, + + # Stats colors + waiting_gil_fg=CursesColors.RED, + gc_fg=CursesColors.BLUE, + + # Function display colors + func_total_fg=CursesColors.BLUE, + func_exec_fg=CursesColors.BLACK, + func_stack_fg=CursesColors.BLACK, + func_shown_fg=CursesColors.BLUE, + + # Table header colors (for sorted column highlight) + sorted_header_fg=CursesColors.WHITE, + sorted_header_bg=CursesColors.BLUE, + + # Normal header colors (non-sorted columns) + normal_header_fg=CursesColors.WHITE, + normal_header_bg=CursesColors.BLACK, + + # Data row colors - use dark colors readable on white + samples_fg=CursesColors.BLACK, + file_fg=CursesColors.BLACK, + func_fg=CursesColors.BLUE, # Blue is more readable than magenta on light bg + + # Medal colors for top functions + medal_silver_fg=CursesColors.BLUE, + + # Background style + background_style="light", +) + + @dataclass(frozen=True, kw_only=True) class Syntax(ThemeSection): prompt: str = ANSIColors.BOLD_MAGENTA @@ -232,6 +353,7 @@ 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) @@ -241,6 +363,7 @@ 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, @@ -253,6 +376,7 @@ 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, @@ -269,6 +393,7 @@ 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(), @@ -338,6 +463,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 b31ab060a6b934..c91ed9e0ea9367 100644 --- a/Lib/profiling/sampling/live_collector/collector.py +++ b/Lib/profiling/sampling/live_collector/collector.py @@ -33,6 +33,9 @@ FINISHED_BANNER_EXTRA_LINES, DEFAULT_SORT_BY, DEFAULT_DISPLAY_LIMIT, + COLOR_PAIR_SAMPLES, + COLOR_PAIR_FILE, + COLOR_PAIR_FUNC, COLOR_PAIR_HEADER_BG, COLOR_PAIR_CYAN, COLOR_PAIR_YELLOW, @@ -552,79 +555,61 @@ 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 - ) - self.display.init_color_pair( - COLOR_PAIR_GREEN, COLOR_GREEN, COLOR_BLACK - ) - self.display.init_color_pair( - COLOR_PAIR_MAGENTA, COLOR_MAGENTA, COLOR_BLACK - ) + theme = _colorize.get_theme(force_color=True).live_profiler + default_bg = -1 + + self.display.init_color_pair(COLOR_PAIR_SAMPLES, theme.samples_fg, default_bg) + self.display.init_color_pair(COLOR_PAIR_FILE, theme.file_fg, default_bg) + self.display.init_color_pair(COLOR_PAIR_FUNC, theme.func_fg, default_bg) + + # Normal header background color pair self.display.init_color_pair( - COLOR_PAIR_RED, COLOR_RED, COLOR_BLACK + COLOR_PAIR_HEADER_BG, + theme.normal_header_fg, + theme.normal_header_bg, ) + + self.display.init_color_pair(COLOR_PAIR_CYAN, theme.pid_fg, default_bg) + self.display.init_color_pair(COLOR_PAIR_YELLOW, theme.time_fg, default_bg) + self.display.init_color_pair(COLOR_PAIR_GREEN, theme.uptime_fg, default_bg) + self.display.init_color_pair(COLOR_PAIR_MAGENTA, theme.interval_fg, default_bg) + self.display.init_color_pair(COLOR_PAIR_RED, theme.off_gil_fg, default_bg) self.display.init_color_pair( - COLOR_PAIR_SORTED_HEADER, COLOR_BLACK, COLOR_YELLOW + COLOR_PAIR_SORTED_HEADER, + theme.sorted_header_fg, + theme.sorted_header_bg, ) + TREND_UP_PAIR = 11 + TREND_DOWN_PAIR = 12 + self.display.init_color_pair(TREND_UP_PAIR, theme.trend_up_fg, default_bg) + self.display.init_color_pair(TREND_DOWN_PAIR, 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, - "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, + "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": self.display.get_color_pair(COLOR_PAIR_HEADER_BG) | A_BOLD, + "color_samples": self.display.get_color_pair(COLOR_PAIR_SAMPLES), + "color_file": self.display.get_color_pair(COLOR_PAIR_FILE), + "color_func": self.display.get_color_pair(COLOR_PAIR_FUNC), + "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 + # Fallback for no-color mode return { "header": A_REVERSE | A_BOLD, "cyan": A_BOLD, @@ -637,7 +622,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/constants.py b/Lib/profiling/sampling/live_collector/constants.py index 4f4575f7b7aae2..bb45006553a67b 100644 --- a/Lib/profiling/sampling/live_collector/constants.py +++ b/Lib/profiling/sampling/live_collector/constants.py @@ -49,6 +49,9 @@ OPCODE_PANEL_HEIGHT = 12 # Height reserved for opcode statistics panel # Color pair IDs +COLOR_PAIR_SAMPLES = 1 +COLOR_PAIR_FILE = 2 +COLOR_PAIR_FUNC = 3 COLOR_PAIR_HEADER_BG = 4 COLOR_PAIR_CYAN = 5 COLOR_PAIR_YELLOW = 6 diff --git a/Lib/profiling/sampling/live_collector/display.py b/Lib/profiling/sampling/live_collector/display.py index d7f65ad73fdc6d..f5324421b10211 100644 --- a/Lib/profiling/sampling/live_collector/display.py +++ b/Lib/profiling/sampling/live_collector/display.py @@ -74,13 +74,17 @@ def get_dimensions(self): return self.stdscr.getmaxyx() def clear(self): - self.stdscr.clear() + # Use erase() instead of clear() to avoid flickering + # clear() forces a complete screen redraw, erase() just clears the buffer + self.stdscr.erase() def refresh(self): self.stdscr.refresh() def redraw(self): - self.stdscr.redrawwin() + # Use noutrefresh + doupdate for smoother updates + self.stdscr.noutrefresh() + curses.doupdate() def add_str(self, line, col, text, attr=0): try: diff --git a/Lib/profiling/sampling/live_collector/widgets.py b/Lib/profiling/sampling/live_collector/widgets.py index cf04f3aa3254ef..ac215dbfeb896e 100644 --- a/Lib/profiling/sampling/live_collector/widgets.py +++ b/Lib/profiling/sampling/live_collector/widgets.py @@ -641,8 +641,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 @@ -661,38 +659,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 @@ -702,13 +700,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, @@ -724,8 +731,7 @@ def draw_stats_rows(self, line, height, width, stats_list, column_flags): column_flags ) - # Get color attributes from the colors dict (already initialized) - color_samples = self.colors.get("color_samples", curses.A_NORMAL) + # Get color attributes color_file = self.colors.get("color_file", curses.A_NORMAL) color_func = self.colors.get("color_func", curses.A_NORMAL) @@ -761,12 +767,12 @@ def draw_stats_rows(self, line, height, width, stats_list, column_flags): # Check if this row is selected is_selected = show_opcodes and row_idx == selected_row - # Helper function to get trend color for a specific column + # Helper function to get trend color def get_trend_color(column_name): if is_selected: return A_REVERSE | A_BOLD trend = trends.get(column_name, "stable") - if trend_tracker is not None: + if trend_tracker is not None and trend_tracker.enabled: return trend_tracker.get_color(trend) return curses.A_NORMAL 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..f4e024828e2a7b --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-12-06-19-49-20.gh-issue-138122.m3EF9E.rst @@ -0,0 +1,5 @@ +The Tachyon profiler's live TUI now integrates with the experimental +:mod:`!_colorize` theming system. Users can customize colors via +:func:`!_colorize.set_theme` (experimental API, subject to change). +A :class:`!LiveProfilerLight` theme is provided for light terminal backgrounds. +Patch by Pablo Galindo. _______________________________________________ Python-checkins mailing list -- [email protected] To unsubscribe send an email to [email protected] https://mail.python.org/mailman3//lists/python-checkins.python.org Member address: [email protected]
