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]

Reply via email to