Skip to content

Commit a51766a

Browse files
committed
Address feedback and ensuring the TUI works at the end
1 parent 99b19e0 commit a51766a

3 files changed

Lines changed: 145 additions & 27 deletions

File tree

Lib/profiling/sampling/live_collector/collector.py

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@ def __init__(
159159
self.filter_input_mode = False # Currently entering filter text
160160
self.filter_input_buffer = "" # Buffer for filter input
161161
self.finished = False # Program has finished, showing final state
162+
self.finish_timestamp = None # When profiling finished (for time freezing)
163+
self.finish_wall_time = None # Wall clock time when profiling finished
162164

163165
# Thread tracking state
164166
self.thread_ids = [] # List of thread IDs seen
@@ -183,6 +185,20 @@ def __init__(
183185
# Trend tracking (initialized after colors are set up)
184186
self._trend_tracker = None
185187

188+
@property
189+
def elapsed_time(self):
190+
"""Get the elapsed time, frozen when finished."""
191+
if self.finished and self.finish_timestamp is not None:
192+
return self.finish_timestamp - self.start_time
193+
return time.perf_counter() - self.start_time if self.start_time else 0
194+
195+
@property
196+
def current_time_display(self):
197+
"""Get the current time for display, frozen when finished."""
198+
if self.finished and self.finish_wall_time is not None:
199+
return time.strftime("%H:%M:%S", time.localtime(self.finish_wall_time))
200+
return time.strftime("%H:%M:%S")
201+
186202
def _get_or_create_thread_data(self, thread_id):
187203
"""Get or create ThreadData for a thread ID."""
188204
if thread_id not in self.per_thread_data:
@@ -384,9 +400,7 @@ def collect(self, stack_frames):
384400

385401
def _prepare_display_data(self, height):
386402
"""Prepare data for display rendering."""
387-
elapsed = (
388-
time.perf_counter() - self.start_time if self.start_time else 0
389-
)
403+
elapsed = self.elapsed_time
390404
stats_list = self._build_stats_list()
391405

392406
# Calculate available space for stats
@@ -707,16 +721,28 @@ def reset_stats(self):
707721
# Clear trend tracking
708722
if self._trend_tracker is not None:
709723
self._trend_tracker.clear()
724+
# Reset finished state and finish timestamp
725+
self.finished = False
726+
self.finish_timestamp = None
727+
self.finish_wall_time = None
710728
self.start_time = time.perf_counter()
711729
self._last_display_update = self.start_time
712730

713731
def mark_finished(self):
714732
"""Mark the profiling session as finished."""
715733
self.finished = True
734+
# Capture the finish timestamp to freeze all timing displays
735+
self.finish_timestamp = time.perf_counter()
736+
self.finish_wall_time = time.time() # Wall clock time for display
716737
# Force a final display update to show the finished message
717738
if self.display is not None:
718739
self._update_display()
719740

741+
def _handle_finished_input_update(self, had_input):
742+
"""Update display after input when program is finished."""
743+
if self.finished and had_input and self.display is not None:
744+
self._update_display()
745+
720746
def _show_terminal_too_small(self, height, width):
721747
"""Display a message when terminal is too small."""
722748
A_BOLD = self.display.get_attr("A_BOLD")
@@ -809,12 +835,7 @@ def _handle_input(self):
809835
self.display.set_nodelay(True)
810836
ch = self.display.get_input()
811837

812-
# If showing help, any key closes it
813-
if self.show_help and ch != -1:
814-
self.show_help = False
815-
return
816-
817-
# Handle filter input mode
838+
# Handle filter input mode FIRST - takes precedence over all commands
818839
if self.filter_input_mode:
819840
if ch == 27: # ESC key
820841
self.filter_input_mode = False
@@ -832,14 +853,19 @@ def _handle_input(self):
832853
self.filter_input_buffer = self.filter_input_buffer[:-1]
833854
elif ch >= 32 and ch < 127: # Printable characters
834855
self.filter_input_buffer += chr(ch)
835-
return
836856

837-
# If finished, only allow 'q' to quit
838-
if self.finished:
839-
if ch == ord("q") or ch == ord("Q"):
840-
self.running = False
857+
# Update display if input was processed while finished
858+
self._handle_finished_input_update(ch != -1)
841859
return
842860

861+
# Handle help toggle keys
862+
if ch == ord("h") or ch == ord("H") or ch == ord("?"):
863+
self.show_help = not self.show_help
864+
865+
# If showing help, any other key closes it
866+
elif self.show_help and ch != -1:
867+
self.show_help = False
868+
843869
# Handle regular commands
844870
if ch == ord("q") or ch == ord("Q"):
845871
self.running = False
@@ -850,14 +876,13 @@ def _handle_input(self):
850876
elif ch == ord("S"):
851877
self._cycle_sort(reverse=True)
852878

853-
elif ch == ord("h") or ch == ord("H") or ch == ord("?"):
854-
self.show_help = not self.show_help
855-
856879
elif ch == ord("p") or ch == ord("P"):
857880
self.paused = not self.paused
858881

859882
elif ch == ord("r") or ch == ord("R"):
860-
self.reset_stats()
883+
# Don't allow reset when profiling is finished
884+
if not self.finished:
885+
self.reset_stats()
861886

862887
elif ch == ord("+") or ch == ord("="):
863888
# Decrease update interval (faster refresh)
@@ -915,6 +940,9 @@ def _handle_input(self):
915940
self.current_thread_index + 1
916941
) % len(self.thread_ids)
917942

943+
# Update display if input was processed while finished
944+
self._handle_finished_input_update(ch != -1)
945+
918946
def init_curses(self, stdscr):
919947
"""Initialize curses display and suppress stdout/stderr."""
920948
self.stdscr = stdscr

Lib/profiling/sampling/live_collector/widgets.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ def draw_header_info(self, line, width, elapsed):
175175
self.add_str(line, 0, title, A_BOLD | self.colors["cyan"])
176176
line += 1
177177

178-
current_time = time.strftime("%H:%M:%S")
178+
current_time = self.collector.current_time_display
179179
uptime = self.format_uptime(elapsed)
180180

181181
# Calculate display refresh rate

Lib/test/test_profiling/test_sampling_profiler/test_live_collector_interaction.py

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -318,27 +318,30 @@ def test_finished_state_displays_banner(self):
318318
self.assertTrue(self.display.contains_text("PROFILING COMPLETE"))
319319
self.assertTrue(self.display.contains_text("Press 'q' to Quit"))
320320

321-
def test_finished_state_ignores_most_input(self):
322-
"""Test that finished state only responds to 'q' key."""
321+
def test_finished_state_allows_ui_controls(self):
322+
"""Test that finished state allows UI controls but prioritizes quit."""
323323
self.collector.finished = True
324324
self.collector.running = True
325325

326-
# Try pressing 's' (sort) - should be ignored
326+
# Try pressing 's' (sort) - should work and trigger display update
327+
original_sort = self.collector.sort_by
327328
self.display.simulate_input(ord("s"))
328329
self.collector._handle_input()
329330
self.assertTrue(self.collector.running) # Still running
331+
self.assertNotEqual(self.collector.sort_by, original_sort) # Sort changed
330332

331-
# Try pressing 'p' (pause) - should be ignored
333+
# Try pressing 'p' (pause) - should work
332334
self.display.simulate_input(ord("p"))
333335
self.collector._handle_input()
334336
self.assertTrue(self.collector.running) # Still running
335-
self.assertFalse(self.collector.paused) # Not paused
337+
self.assertTrue(self.collector.paused) # Now paused
336338

337-
# Try pressing 'r' (reset) - should be ignored
338-
old_total = self.collector.total_samples = 100
339+
# Try pressing 'r' (reset) - should be ignored when finished
340+
self.collector.total_samples = 100
339341
self.display.simulate_input(ord("r"))
340342
self.collector._handle_input()
341-
self.assertEqual(self.collector.total_samples, old_total) # Not reset
343+
self.assertTrue(self.collector.running) # Still running
344+
self.assertEqual(self.collector.total_samples, 100) # NOT reset when finished
342345

343346
# Press 'q' - should stop
344347
self.display.simulate_input(ord("q"))
@@ -365,6 +368,35 @@ def test_finished_state_footer_message(self):
365368
# Check that footer contains finished message
366369
self.assertTrue(self.display.contains_text("PROFILING FINISHED"))
367370

371+
def test_finished_state_freezes_time(self):
372+
"""Test that time displays are frozen when finished."""
373+
import time as time_module
374+
375+
# Set up collector with known start time
376+
self.collector.start_time = time_module.perf_counter() - 10.0 # 10 seconds ago
377+
378+
# Mark as finished - this should freeze the time
379+
self.collector.mark_finished()
380+
381+
# Get the frozen elapsed time
382+
frozen_elapsed = self.collector.elapsed_time
383+
frozen_time_display = self.collector.current_time_display
384+
385+
# Wait a bit to ensure time would advance
386+
time_module.sleep(0.1)
387+
388+
# Time should remain frozen
389+
self.assertEqual(self.collector.elapsed_time, frozen_elapsed)
390+
self.assertEqual(self.collector.current_time_display, frozen_time_display)
391+
392+
# Verify finish timestamp was set
393+
self.assertIsNotNone(self.collector.finish_timestamp)
394+
395+
# Reset should clear the frozen state
396+
self.collector.reset_stats()
397+
self.assertFalse(self.collector.finished)
398+
self.assertIsNone(self.collector.finish_timestamp)
399+
368400

369401
class TestLiveCollectorFiltering(unittest.TestCase):
370402
"""Tests for filtering functionality."""
@@ -1131,5 +1163,63 @@ def test_function_counts_are_per_thread_in_per_thread_mode(self):
11311163
self.assertEqual(thread_222_funcs, {"func4", "func5"})
11321164

11331165

1166+
class TestLiveCollectorNewFeatures(unittest.TestCase):
1167+
"""Tests for new features added to live collector."""
1168+
1169+
def setUp(self):
1170+
"""Set up test fixtures."""
1171+
self.display = MockDisplay()
1172+
self.collector = LiveStatsCollector(1000, display=self.display)
1173+
self.collector.start_time = time.perf_counter()
1174+
1175+
def test_filter_input_takes_precedence_over_commands(self):
1176+
"""Test that filter input mode blocks command keys like 'h' and 'p'."""
1177+
# Enter filter input mode
1178+
self.collector.filter_input_mode = True
1179+
self.collector.filter_input_buffer = ""
1180+
1181+
# Press 'h' - should add to filter buffer, not show help
1182+
self.display.simulate_input(ord("h"))
1183+
self.collector._handle_input()
1184+
1185+
self.assertFalse(self.collector.show_help) # Help not triggered
1186+
self.assertEqual(self.collector.filter_input_buffer, "h") # Added to filter
1187+
self.assertTrue(self.collector.filter_input_mode) # Still in filter mode
1188+
1189+
def test_reset_blocked_when_finished(self):
1190+
"""Test that reset command is blocked when profiling is finished."""
1191+
# Set up some sample data and mark as finished
1192+
self.collector.total_samples = 100
1193+
self.collector.finished = True
1194+
1195+
# Press 'r' for reset
1196+
self.display.simulate_input(ord("r"))
1197+
self.collector._handle_input()
1198+
1199+
# Should NOT have been reset
1200+
self.assertEqual(self.collector.total_samples, 100)
1201+
self.assertTrue(self.collector.finished)
1202+
1203+
def test_time_display_fix_when_finished(self):
1204+
"""Test that time display shows correct frozen time when finished."""
1205+
import time as time_module
1206+
1207+
# Mark as finished to freeze time
1208+
self.collector.mark_finished()
1209+
1210+
# Should have set both timestamps correctly
1211+
self.assertIsNotNone(self.collector.finish_timestamp)
1212+
self.assertIsNotNone(self.collector.finish_wall_time)
1213+
1214+
# Get the frozen time display
1215+
frozen_time = self.collector.current_time_display
1216+
1217+
# Wait a bit
1218+
time_module.sleep(0.1)
1219+
1220+
# Should still show the same frozen time (not jump to wrong time)
1221+
self.assertEqual(self.collector.current_time_display, frozen_time)
1222+
1223+
11341224
if __name__ == "__main__":
11351225
unittest.main()

0 commit comments

Comments
 (0)