Skip to content
Merged
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
7 changes: 7 additions & 0 deletions Doc/library/profiling.sampling.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1490,6 +1490,13 @@ Output options
named ``<format>_<PID>.<ext>`` (for example, ``flamegraph_12345.html``).
:option:`--heatmap` creates a directory named ``heatmap_<PID>``.

.. option:: --browser

Automatically open HTML output (:option:`--flamegraph` and
:option:`--heatmap`) in your default web browser after generation.
When profiling with :option:`--subprocesses`, only the main process
opens the browser; subprocess outputs are never auto-opened.


pstats display options
----------------------
Expand Down
130 changes: 129 additions & 1 deletion Lib/_colorize.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

# types
if False:
from typing import IO, Self, ClassVar
from typing import IO, Literal, Self, ClassVar
_theme: Theme


Expand Down Expand Up @@ -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)
#
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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(),
Expand Down Expand Up @@ -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(
*,
Expand Down
41 changes: 41 additions & 0 deletions Lib/profiling/sampling/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import subprocess
import sys
import time
import webbrowser
from contextlib import nullcontext

from .errors import SamplingUnknownProcessError, SamplingModuleNotFoundError, SamplingScriptNotFoundError
Expand Down Expand Up @@ -487,6 +488,12 @@ def _add_format_options(parser, include_compression=True, include_binary=True):
help="Output path (default: stdout for pstats, auto-generated for others). "
"For heatmap: directory name (default: heatmap_PID)",
)
output_group.add_argument(
"--browser",
action="store_true",
help="Automatically open HTML output (flamegraph, heatmap) in browser. "
"When using --subprocesses, only the main process opens the browser",
)


def _add_pstats_options(parser):
Expand Down Expand Up @@ -586,6 +593,32 @@ def _generate_output_filename(format_type, pid):
return f"{format_type}_{pid}.{extension}"


def _open_in_browser(path):
"""Open a file or directory in the default web browser.

Args:
path: File path or directory path to open

For directories (heatmap), opens the index.html file inside.
"""
abs_path = os.path.abspath(path)

# For heatmap directories, open the index.html file
if os.path.isdir(abs_path):
index_path = os.path.join(abs_path, 'index.html')
if os.path.exists(index_path):
abs_path = index_path
else:
print(f"Warning: Could not find index.html in {path}", file=sys.stderr)
return

file_url = f"file://{abs_path}"
try:
webbrowser.open(file_url)
except Exception as e:
print(f"Warning: Could not open browser: {e}", file=sys.stderr)


def _handle_output(collector, args, pid, mode):
"""Handle output for the collector based on format and arguments.

Expand Down Expand Up @@ -625,6 +658,10 @@ def _handle_output(collector, args, pid, mode):
filename = args.outfile or _generate_output_filename(args.format, pid)
collector.export(filename)

# Auto-open browser for HTML output if --browser flag is set
if args.format in ('flamegraph', 'heatmap') and getattr(args, 'browser', False):
_open_in_browser(filename)


def _validate_args(args, parser):
"""Validate format-specific options and live mode requirements.
Expand Down Expand Up @@ -1161,6 +1198,10 @@ def progress_callback(current, total):
filename = args.outfile or _generate_output_filename(args.format, os.getpid())
collector.export(filename)

# Auto-open browser for HTML output if --browser flag is set
if args.format in ('flamegraph', 'heatmap') and getattr(args, 'browser', False):
_open_in_browser(filename)

print(f"Replayed {count} samples")


Expand Down
37 changes: 34 additions & 3 deletions Lib/profiling/sampling/heatmap_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,10 @@ def __init__(self, *args, **kwargs):
self.callers_graph = collections.defaultdict(set)
self.function_definitions = {}

# Map each sampled line to its function for proper caller lookup
# (filename, lineno) -> funcname
self.line_to_function = {}

# Edge counting for call path analysis
self.edge_samples = collections.Counter()

Expand Down Expand Up @@ -596,6 +600,10 @@ def _record_line_sample(self, filename, lineno, funcname, is_leaf=False,
if funcname and (filename, funcname) not in self.function_definitions:
self.function_definitions[(filename, funcname)] = lineno

# Map this line to its function for caller/callee navigation
if funcname:
self.line_to_function[(filename, lineno)] = funcname

def _record_bytecode_sample(self, filename, lineno, opcode,
end_lineno=None, col_offset=None, end_col_offset=None,
weight=1):
Expand Down Expand Up @@ -1150,13 +1158,36 @@ def _format_specialization_color(self, spec_pct: int) -> str:
return f"rgba({r}, {g}, {b}, {alpha})"

def _build_navigation_buttons(self, filename: str, line_num: int) -> str:
"""Build navigation buttons for callers/callees."""
"""Build navigation buttons for callers/callees.

- Callers: All lines in a function show who calls this function
- Callees: Only actual call site lines show what they call
"""
line_key = (filename, line_num)
caller_list = self._deduplicate_by_function(self.callers_graph.get(line_key, set()))

funcname = self.line_to_function.get(line_key)

# Get callers: look up by function definition line, not current line
# This ensures all lines in a function show who calls this function
if funcname:
func_def_line = self.function_definitions.get((filename, funcname), line_num)
func_def_key = (filename, func_def_line)
caller_list = self._deduplicate_by_function(self.callers_graph.get(func_def_key, set()))
else:
caller_list = self._deduplicate_by_function(self.callers_graph.get(line_key, set()))

# Get callees: only show for actual call site lines (not every line in function)
callee_list = self._deduplicate_by_function(self.call_graph.get(line_key, set()))

# Get edge counts for each caller/callee
callers_with_counts = self._get_edge_counts(line_key, caller_list, is_caller=True)
# For callers, use the function definition key for edge lookup
if funcname:
func_def_line = self.function_definitions.get((filename, funcname), line_num)
caller_edge_key = (filename, func_def_line)
else:
caller_edge_key = line_key
callers_with_counts = self._get_edge_counts(caller_edge_key, caller_list, is_caller=True)
# For callees, use the actual line key since that's where the call happens
callees_with_counts = self._get_edge_counts(line_key, callee_list, is_caller=False)

# Build navigation buttons with counts
Expand Down
Loading
Loading