Skip to content

Commit 36a4289

Browse files
committed
gh-145306: gate --browser on export success
1 parent c2d3d6b commit 36a4289

File tree

8 files changed

+127
-8
lines changed

8 files changed

+127
-8
lines changed

Lib/profiling/sampling/binary_collector.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ def export(self, filename=None):
9494
filename: Ignored (binary files are written incrementally)
9595
"""
9696
self._writer.finalize()
97+
return True
9798

9899
@property
99100
def total_samples(self):

Lib/profiling/sampling/cli.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -660,10 +660,14 @@ def _handle_output(collector, args, pid, mode):
660660
filename = os.path.join(args.outfile, _generate_output_filename(args.format, pid))
661661
else:
662662
filename = args.outfile or _generate_output_filename(args.format, pid)
663-
collector.export(filename)
663+
export_ok = collector.export(filename)
664664

665665
# Auto-open browser for HTML output if --browser flag is set
666-
if args.format in ('flamegraph', 'heatmap') and getattr(args, 'browser', False):
666+
if (
667+
export_ok
668+
and args.format in ('flamegraph', 'heatmap')
669+
and getattr(args, 'browser', False)
670+
):
667671
_open_in_browser(filename)
668672

669673

@@ -1203,10 +1207,14 @@ def progress_callback(current, total):
12031207
collector.print_stats(sort_mode, limit, not args.no_summary, PROFILING_MODE_WALL)
12041208
else:
12051209
filename = args.outfile or _generate_output_filename(args.format, os.getpid())
1206-
collector.export(filename)
1210+
export_ok = collector.export(filename)
12071211

12081212
# Auto-open browser for HTML output if --browser flag is set
1209-
if args.format in ('flamegraph', 'heatmap') and getattr(args, 'browser', False):
1213+
if (
1214+
export_ok
1215+
and args.format in ('flamegraph', 'heatmap')
1216+
and getattr(args, 'browser', False)
1217+
):
12101218
_open_in_browser(filename)
12111219

12121220
print(f"Replayed {count} samples")

Lib/profiling/sampling/collector.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,11 @@ def collect_failed_sample(self):
8181

8282
@abstractmethod
8383
def export(self, filename):
84-
"""Export collected data to a file."""
84+
"""Export collected data.
85+
86+
Returns:
87+
bool: True if output was generated, False if there was no data to export.
88+
"""
8589

8690
@staticmethod
8791
def _filter_internal_frames(frames):

Lib/profiling/sampling/gecko_collector.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,7 @@ def spin():
701701
print(
702702
f"Open in Firefox Profiler: https://profiler.firefox.com/"
703703
)
704+
return True
704705

705706
def _build_marker_schema(self):
706707
"""Build marker schema definitions for Firefox Profiler."""

Lib/profiling/sampling/heatmap_collector.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -714,7 +714,7 @@ def export(self, output_path):
714714
"""
715715
if not self.file_samples:
716716
print("Warning: No heatmap data to export")
717-
return
717+
return False
718718

719719
try:
720720
output_dir = self._prepare_output_directory(output_path)
@@ -728,6 +728,7 @@ def export(self, output_path):
728728
self._generate_index_html(output_dir / 'index.html', file_stats)
729729

730730
self._print_export_summary(output_dir, file_stats)
731+
return True
731732

732733
except Exception as e:
733734
print(f"Error: Failed to export heatmap: {e}")

Lib/profiling/sampling/pstats_collector.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def collect(self, stack_frames, timestamps_us=None):
6161
def export(self, filename):
6262
self.create_stats()
6363
self._dump_stats(filename)
64+
return True
6465

6566
def _dump_stats(self, file):
6667
stats_with_marker = dict(self.stats)

Lib/profiling/sampling/stack_collector.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def export(self, filename):
6161
for stack, count in lines:
6262
f.write(f"{stack} {count}\n")
6363
print(f"Collapsed stack output written to {filename}")
64+
return True
6465

6566

6667
class FlamegraphCollector(StackTraceCollector):
@@ -153,14 +154,15 @@ def export(self, filename):
153154
print(
154155
"Warning: No functions found in profiling data. Check if sampling captured any data."
155156
)
156-
return
157+
return False
157158

158159
html_content = self._create_flamegraph_html(flamegraph_data)
159160

160161
with open(filename, "w", encoding="utf-8") as f:
161162
f.write(html_content)
162163

163164
print(f"Flamegraph saved to: {filename}")
165+
return True
164166

165167
@staticmethod
166168
@functools.lru_cache(maxsize=None)

Lib/test/test_profiling/test_sampling_profiler/test_cli.py

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import subprocess
55
import sys
66
import unittest
7+
from types import SimpleNamespace
78
from unittest import mock
89

910
try:
@@ -15,7 +16,7 @@
1516

1617
from test.support import is_emscripten, requires_remote_subprocess_debugging
1718

18-
from profiling.sampling.cli import main
19+
from profiling.sampling.cli import _handle_output, _handle_replay, main
1920
from profiling.sampling.errors import SamplingScriptNotFoundError, SamplingModuleNotFoundError, SamplingUnknownProcessError
2021

2122
class TestSampleProfilerCLI(unittest.TestCase):
@@ -700,6 +701,106 @@ def test_async_aware_incompatible_with_all_threads(self):
700701
self.assertIn("--all-threads", error_msg)
701702
self.assertIn("incompatible with --async-aware", error_msg)
702703

704+
def test_handle_output_browser_not_opened_when_export_fails(self):
705+
collector = mock.MagicMock()
706+
collector.export.return_value = False
707+
args = SimpleNamespace(
708+
format="flamegraph",
709+
outfile="profile.html",
710+
browser=True,
711+
)
712+
713+
with (
714+
mock.patch("profiling.sampling.cli.os.path.isdir", return_value=False),
715+
mock.patch("profiling.sampling.cli._open_in_browser") as mock_open,
716+
):
717+
_handle_output(collector, args, pid=12345, mode=0)
718+
719+
collector.export.assert_called_once_with("profile.html")
720+
mock_open.assert_not_called()
721+
722+
def test_handle_output_browser_opened_when_export_succeeds(self):
723+
collector = mock.MagicMock()
724+
collector.export.return_value = True
725+
args = SimpleNamespace(
726+
format="flamegraph",
727+
outfile="profile.html",
728+
browser=True,
729+
)
730+
731+
with (
732+
mock.patch("profiling.sampling.cli.os.path.isdir", return_value=False),
733+
mock.patch("profiling.sampling.cli._open_in_browser") as mock_open,
734+
):
735+
_handle_output(collector, args, pid=12345, mode=0)
736+
737+
collector.export.assert_called_once_with("profile.html")
738+
mock_open.assert_called_once_with("profile.html")
739+
740+
def test_replay_browser_not_opened_when_export_fails(self):
741+
args = SimpleNamespace(
742+
input_file="profile.bin",
743+
format="flamegraph",
744+
outfile="profile.html",
745+
browser=True,
746+
sort=None,
747+
limit=None,
748+
no_summary=False,
749+
)
750+
collector = mock.MagicMock()
751+
collector.export.return_value = False
752+
753+
with (
754+
mock.patch("profiling.sampling.cli.os.path.exists", return_value=True),
755+
mock.patch("profiling.sampling.cli.BinaryReader") as mock_reader_cls,
756+
mock.patch("profiling.sampling.cli._create_collector", return_value=collector),
757+
mock.patch("profiling.sampling.cli._open_in_browser") as mock_open,
758+
):
759+
reader = mock_reader_cls.return_value.__enter__.return_value
760+
reader.get_info.return_value = {
761+
"sample_count": 1,
762+
"sample_interval_us": 1000,
763+
"compression_type": 0,
764+
}
765+
reader.replay_samples.return_value = 1
766+
767+
_handle_replay(args)
768+
769+
collector.export.assert_called_once_with("profile.html")
770+
mock_open.assert_not_called()
771+
772+
def test_replay_browser_opened_when_export_succeeds(self):
773+
args = SimpleNamespace(
774+
input_file="profile.bin",
775+
format="flamegraph",
776+
outfile="profile.html",
777+
browser=True,
778+
sort=None,
779+
limit=None,
780+
no_summary=False,
781+
)
782+
collector = mock.MagicMock()
783+
collector.export.return_value = True
784+
785+
with (
786+
mock.patch("profiling.sampling.cli.os.path.exists", return_value=True),
787+
mock.patch("profiling.sampling.cli.BinaryReader") as mock_reader_cls,
788+
mock.patch("profiling.sampling.cli._create_collector", return_value=collector),
789+
mock.patch("profiling.sampling.cli._open_in_browser") as mock_open,
790+
):
791+
reader = mock_reader_cls.return_value.__enter__.return_value
792+
reader.get_info.return_value = {
793+
"sample_count": 1,
794+
"sample_interval_us": 1000,
795+
"compression_type": 0,
796+
}
797+
reader.replay_samples.return_value = 1
798+
799+
_handle_replay(args)
800+
801+
collector.export.assert_called_once_with("profile.html")
802+
mock_open.assert_called_once_with("profile.html")
803+
703804
@unittest.skipIf(is_emscripten, "subprocess not available")
704805
def test_run_nonexistent_script_exits_cleanly(self):
705806
"""Test that running a non-existent script exits with a clean error."""

0 commit comments

Comments
 (0)