diff --git a/Doc/library/profiling.sampling.rst b/Doc/library/profiling.sampling.rst index 370bbcd3242526..8d64013bcba859 100644 --- a/Doc/library/profiling.sampling.rst +++ b/Doc/library/profiling.sampling.rst @@ -1491,6 +1491,20 @@ Output options named ``_.`` (for example, ``flamegraph_12345.html``). :option:`--heatmap` creates a directory named ``heatmap_``. + For HTML outputs (:option:`--flamegraph` and :option:`--heatmap`), the + generated file or directory is automatically opened in your default web + browser after profiling completes. Use :option:`--no-browser` to disable + this behavior. When using :option:`--subprocesses`, only the main process + output is opened automatically to avoid opening multiple browser tabs. + +.. option:: --no-browser + + Disable automatic browser opening for HTML output (:option:`--flamegraph` + and :option:`--heatmap`). By default, HTML visualizations are opened in + your default web browser after generation. When profiling with + :option:`--subprocesses`, only the main process opens the browser by + default; subprocess outputs are never auto-opened. + pstats display options ---------------------- diff --git a/Lib/profiling/sampling/cli.py b/Lib/profiling/sampling/cli.py index 10341c1570ceca..52dd2d725cddf5 100644 --- a/Lib/profiling/sampling/cli.py +++ b/Lib/profiling/sampling/cli.py @@ -10,6 +10,7 @@ import subprocess import sys import time +import webbrowser from contextlib import nullcontext from .errors import SamplingUnknownProcessError, SamplingModuleNotFoundError, SamplingScriptNotFoundError @@ -147,6 +148,9 @@ def _build_child_profiler_args(args): if args.format != "pstats": child_args.append(f"--{args.format}") + # Always add --no-browser for child profilers to avoid opening multiple browser tabs + child_args.append("--no-browser") + return child_args @@ -492,6 +496,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( + "--no-browser", + action="store_true", + help="Disable automatic browser opening for HTML output (flamegraph, heatmap). " + "When using --subprocesses, only the main process opens the browser by default", + ) def _add_pstats_options(parser): @@ -591,6 +601,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. @@ -630,6 +666,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 unless --no-browser flag is set + if args.format in ('flamegraph', 'heatmap') and not getattr(args, 'no_browser', False): + _open_in_browser(filename) + def _validate_args(args, parser): """Validate format-specific options and live mode requirements. @@ -1153,6 +1193,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 unless --no-browser flag is set + if args.format in ('flamegraph', 'heatmap') and not getattr(args, 'no_browser', False): + _open_in_browser(filename) + print(f"Replayed {count} samples") diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_children.py b/Lib/test/test_profiling/test_sampling_profiler/test_children.py index b7dc878a238f8d..dfeec4f9f6030f 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_children.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_children.py @@ -438,6 +438,11 @@ def assert_flag_value_pair(flag, value): child_args, f"Flag '--flamegraph' not found in args: {child_args}", ) + self.assertIn( + "--no-browser", + child_args, + f"Flag '--no-browser' not found in args: {child_args}", + ) def test_build_child_profiler_args_no_gc(self): """Test building CLI args with --no-gc.""" @@ -992,6 +997,7 @@ def test_subprocesses_flag_with_flamegraph_output(self): "-r", "100", "--flamegraph", + "--no-browser", "-o", output_file, script_file,