mirror of
https://github.com/python/cpython.git
synced 2025-12-08 06:10:17 +00:00
Merge b024e50773 into 3db7bf2d18
This commit is contained in:
commit
473558efa6
4 changed files with 197 additions and 76 deletions
133
Lib/_colorize.py
133
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(
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -501,79 +501,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
|
||||
theme = _colorize.get_theme(force_color=True)
|
||||
profiler_theme = theme.live_profiler
|
||||
default_bg = -1
|
||||
|
||||
# Initialize all color pairs used throughout the UI
|
||||
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(
|
||||
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
|
||||
)
|
||||
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,
|
||||
|
|
@ -586,7 +564,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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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…
Add table
Add a link
Reference in a new issue