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]

Reply via email to