Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions Lib/_colorize.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,19 @@ class ANSIColors:
ColorCodes = set()
NoColors = ANSIColors()


class CursesColors:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would rather have this one after the for-loop as it's not tied to ANSI colors.

"""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)
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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.

Expand All @@ -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
Expand All @@ -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(),
)


Expand Down Expand Up @@ -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(
*,
Expand Down
89 changes: 33 additions & 56 deletions Lib/profiling/sampling/live_collector/collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
49 changes: 28 additions & 21 deletions Lib/profiling/sampling/live_collector/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Loading