https://github.com/python/cpython/commit/860f8a5addcb556c251b8b9caa95d80518928067
commit: 860f8a5addcb556c251b8b9caa95d80518928067
branch: main
author: ivonastojanovic <[email protected]>
committer: pablogsal <[email protected]>
date: 2026-06-27T19:09:49+02:00
summary:
gh-145306: Fix browser open after empty export (#150017)
files:
M Lib/profiling/sampling/binary_collector.py
M Lib/profiling/sampling/cli.py
M Lib/profiling/sampling/collector.py
M Lib/profiling/sampling/gecko_collector.py
M Lib/profiling/sampling/heatmap_collector.py
M Lib/profiling/sampling/jsonl_collector.py
M Lib/profiling/sampling/pstats_collector.py
M Lib/profiling/sampling/stack_collector.py
M Lib/test/test_profiling/test_sampling_profiler/test_cli.py
M Lib/test/test_profiling/test_sampling_profiler/test_collectors.py
diff --git a/Lib/profiling/sampling/binary_collector.py
b/Lib/profiling/sampling/binary_collector.py
index 64afe632fae175..afbbc829269067 100644
--- a/Lib/profiling/sampling/binary_collector.py
+++ b/Lib/profiling/sampling/binary_collector.py
@@ -94,6 +94,7 @@ def export(self, filename=None):
filename: Ignored (binary files are written incrementally)
"""
self._writer.finalize()
+ return True
@property
def total_samples(self):
diff --git a/Lib/profiling/sampling/cli.py b/Lib/profiling/sampling/cli.py
index 0330c15c014545..466b0aceae2dcc 100644
--- a/Lib/profiling/sampling/cli.py
+++ b/Lib/profiling/sampling/cli.py
@@ -117,6 +117,9 @@ def __call__(self, parser, namespace, values,
option_string=None):
"binary": BinaryCollector,
}
+BROWSER_COMPATIBLE_FORMATS = ("flamegraph", "diff_flamegraph", "heatmap")
+
+
def _setup_child_monitor(args, parent_pid):
# Build CLI args for child profilers (excluding --subprocesses to avoid
recursion)
child_cli_args = _build_child_profiler_args(args)
@@ -528,8 +531,12 @@ def _add_format_options(parser, include_compression=True,
include_binary=True):
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",
+ help=(
+ "Automatically open HTML output "
+ f"({', '.join('--' + f.replace('_', '-') for f in
BROWSER_COMPATIBLE_FORMATS)}) "
+ "in browser. "
+ "When using `--subprocesses`, only the main process opens the
browser"
+ ),
)
@@ -789,13 +796,12 @@ def progress_callback(current, total):
args.outfile
or _generate_output_filename(args.format, os.getpid())
)
- collector.export(filename)
+ export_ok = collector.export(filename)
# Auto-open browser for HTML output if --browser flag is set
if (
- args.format in (
- 'flamegraph', 'diff_flamegraph', 'heatmap'
- )
+ export_ok
+ and args.format in BROWSER_COMPATIBLE_FORMATS
and getattr(args, 'browser', False)
):
_open_in_browser(filename)
@@ -840,10 +846,14 @@ def _handle_output(collector, args, pid, mode):
filename = os.path.join(args.outfile,
_generate_output_filename(args.format, pid))
else:
filename = args.outfile or _generate_output_filename(args.format,
pid)
- collector.export(filename)
+ export_ok = collector.export(filename)
# Auto-open browser for HTML output if --browser flag is set
- if args.format in ('flamegraph', 'diff_flamegraph', 'heatmap') and
getattr(args, 'browser', False):
+ if (
+ export_ok
+ and args.format in BROWSER_COMPATIBLE_FORMATS
+ and getattr(args, 'browser', False)
+ ):
_open_in_browser(filename)
diff --git a/Lib/profiling/sampling/collector.py
b/Lib/profiling/sampling/collector.py
index 8e0f0c44c4f8f3..1dc3656e0ebe97 100644
--- a/Lib/profiling/sampling/collector.py
+++ b/Lib/profiling/sampling/collector.py
@@ -163,7 +163,11 @@ def collect_failed_sample(self):
@abstractmethod
def export(self, filename):
- """Export collected data to a file."""
+ """Export collected data.
+
+ Returns:
+ bool: True if output was generated, False if there was no data to
export.
+ """
@staticmethod
def _filter_internal_frames(frames):
diff --git a/Lib/profiling/sampling/gecko_collector.py
b/Lib/profiling/sampling/gecko_collector.py
index 361f6037f216fd..2bb5bd2f664d59 100644
--- a/Lib/profiling/sampling/gecko_collector.py
+++ b/Lib/profiling/sampling/gecko_collector.py
@@ -756,6 +756,7 @@ def spin():
print(
f"Open in Firefox Profiler: https://profiler.firefox.com/"
)
+ return True
def _build_marker_schema(self):
"""Build marker schema definitions for Firefox Profiler."""
diff --git a/Lib/profiling/sampling/heatmap_collector.py
b/Lib/profiling/sampling/heatmap_collector.py
index 78f1e39f6a002d..220b6b8150ac98 100644
--- a/Lib/profiling/sampling/heatmap_collector.py
+++ b/Lib/profiling/sampling/heatmap_collector.py
@@ -596,7 +596,7 @@ def export(self, output_path):
"""
if not self.file_samples:
print("Warning: No heatmap data to export")
- return
+ return False
try:
output_dir = self._prepare_output_directory(output_path)
@@ -610,6 +610,7 @@ def export(self, output_path):
self._generate_index_html(output_dir / 'index.html', file_stats)
self._print_export_summary(output_dir, file_stats)
+ return True
except Exception as e:
print(f"Error: Failed to export heatmap: {e}")
diff --git a/Lib/profiling/sampling/jsonl_collector.py
b/Lib/profiling/sampling/jsonl_collector.py
index 5aa42ef09024dc..41b0456d6f56d3 100644
--- a/Lib/profiling/sampling/jsonl_collector.py
+++ b/Lib/profiling/sampling/jsonl_collector.py
@@ -165,6 +165,7 @@ def export(self, filename):
)
self._write_message(output, self._build_end_record())
print(f"JSONL profile written to {filename}")
+ return True
def _build_meta_record(self):
record = {
diff --git a/Lib/profiling/sampling/pstats_collector.py
b/Lib/profiling/sampling/pstats_collector.py
index 43b1daf2a119d4..7132cffd58f094 100644
--- a/Lib/profiling/sampling/pstats_collector.py
+++ b/Lib/profiling/sampling/pstats_collector.py
@@ -63,6 +63,7 @@ def collect(self, stack_frames, timestamps_us=None):
def export(self, filename):
self.create_stats()
self._dump_stats(filename)
+ return True
def _dump_stats(self, file):
stats_with_marker = dict(self.stats)
diff --git a/Lib/profiling/sampling/stack_collector.py
b/Lib/profiling/sampling/stack_collector.py
index 42281dc6454c83..eb1a3fba93cf33 100644
--- a/Lib/profiling/sampling/stack_collector.py
+++ b/Lib/profiling/sampling/stack_collector.py
@@ -64,6 +64,7 @@ def export(self, filename):
for stack, count in lines:
f.write(f"{stack} {count}\n")
print(f"Collapsed stack output written to {filename}")
+ return True
class FlamegraphCollector(StackTraceCollector):
@@ -161,7 +162,7 @@ def export(self, filename):
print(
"Warning: No functions found in profiling data. Check if
sampling captured any data."
)
- return
+ return False
html_content = self._create_flamegraph_html(flamegraph_data)
@@ -169,6 +170,7 @@ def export(self, filename):
f.write(html_content)
print(f"Flamegraph saved to: {filename}")
+ return True
@staticmethod
@functools.lru_cache(maxsize=None)
diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_cli.py
b/Lib/test/test_profiling/test_sampling_profiler/test_cli.py
index 0181095ca21e37..3448258eca5d6c 100644
--- a/Lib/test/test_profiling/test_sampling_profiler/test_cli.py
+++ b/Lib/test/test_profiling/test_sampling_profiler/test_cli.py
@@ -7,6 +7,7 @@
import sys
import tempfile
import unittest
+from types import SimpleNamespace
from unittest import mock
try:
@@ -26,6 +27,7 @@
FORMAT_EXTENSIONS,
_create_collector,
_generate_output_filename,
+ _handle_output,
main,
)
from profiling.sampling.constants import (
@@ -727,6 +729,26 @@ def test_async_aware_flag_defaults_to_running(self):
call_kwargs = mock_sample.call_args[1]
self.assertEqual(call_kwargs.get("async_aware"), "running")
+ def test_handle_output_browser_not_opened_when_export_fails(self):
+ for format_type in ("flamegraph", "diff_flamegraph", "heatmap"):
+ with self.subTest(format=format_type):
+ collector = mock.MagicMock()
+ collector.export.return_value = False
+ args = SimpleNamespace(
+ format=format_type,
+ outfile="profile.html",
+ browser=True,
+ )
+
+ with (
+ mock.patch("profiling.sampling.cli.os.path.isdir",
return_value=False),
+ mock.patch("profiling.sampling.cli._open_in_browser") as
mock_open,
+ ):
+ _handle_output(collector, args, pid=12345, mode=0)
+
+ collector.export.assert_called_once_with("profile.html")
+ mock_open.assert_not_called()
+
def test_async_aware_with_async_mode_all(self):
"""Test --async-aware with --async-mode all."""
test_args = ["profiling.sampling.cli", "attach", "12345",
"--async-aware", "--async-mode", "all"]
diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py
b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py
index 1ab31af67fec52..56f3fe5e1c2605 100644
--- a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py
+++ b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py
@@ -539,9 +539,10 @@ def test_flamegraph_collector_export(self):
# Export flamegraph
with captured_stdout(), captured_stderr():
- collector.export(flamegraph_out.name)
+ export_ok = collector.export(flamegraph_out.name)
# Verify file was created and contains valid data
+ self.assertTrue(export_ok)
self.assertTrue(os.path.exists(flamegraph_out.name))
self.assertGreater(os.path.getsize(flamegraph_out.name), 0)
@@ -560,6 +561,21 @@ def test_flamegraph_collector_export(self):
self.assertIn('"value":', content)
self.assertIn('"children":', content)
+ def test_flamegraph_collector_empty_export_fails(self):
+ """Test empty flamegraph export reports no output."""
+ flamegraph_out = tempfile.NamedTemporaryFile(
+ suffix=".html", delete=False
+ )
+ self.addCleanup(close_and_unlink, flamegraph_out)
+
+ collector = FlamegraphCollector(1000)
+
+ with captured_stdout(), captured_stderr():
+ export_ok = collector.export(flamegraph_out.name)
+
+ self.assertFalse(export_ok)
+ self.assertEqual(os.path.getsize(flamegraph_out.name), 0)
+
def test_gecko_collector_basic(self):
"""Test basic GeckoCollector functionality."""
collector = GeckoCollector(1000)
@@ -1666,8 +1682,9 @@ def test_diff_flamegraph_export(self):
self.addCleanup(close_and_unlink, flamegraph_out)
with captured_stdout(), captured_stderr():
- diff.export(flamegraph_out.name)
+ export_ok = diff.export(flamegraph_out.name)
+ self.assertTrue(export_ok)
self.assertTrue(os.path.exists(flamegraph_out.name))
self.assertGreater(os.path.getsize(flamegraph_out.name), 0)
_______________________________________________
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]