From 49c203f98a282e2e077660fbaba1039cad858521 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Thu, 24 Oct 2024 09:34:01 -0600 Subject: [PATCH 1/4] FIX: Add update capability to interval/singleshot timer properties --- .../tests/test_backends_interactive.py | 26 ++++++++++++++++--- src/_macosx.m | 18 +++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 05f59ce39fa4..5569173ed347 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -626,11 +626,13 @@ def _impl_test_interactive_timers(): # We only want singleshot if we specify that ourselves, otherwise we want # a repeating timer import os + import sys from unittest.mock import Mock import matplotlib.pyplot as plt # increase pause duration on CI to let things spin up # particularly relevant for gtk3cairo pause_time = 2 if os.getenv("CI") else 0.5 + expected_100ms_calls = int(pause_time / 0.1) fig = plt.figure() plt.pause(pause_time) timer = fig.canvas.new_timer(0.1) @@ -638,17 +640,33 @@ def _impl_test_interactive_timers(): timer.add_callback(mock) timer.start() plt.pause(pause_time) - timer.stop() - assert mock.call_count > 1 + # NOTE: The timer is as fast as possible, but this is different between backends + # so we can't assert on the exact number but it should be faster than 100ms + assert mock.call_count > expected_100ms_calls, \ + f"Expected more than {expected_100ms_calls} calls, got {mock.call_count}" + + # Test updating the interval updates a running timer + timer.interval = 100 + mock.call_count = 0 + plt.pause(pause_time) + # GTK4 on macos runners produces about 3x as many calls as expected + # It works locally and on Linux though, so only skip when running on CI + if not (os.getenv("CI") + and "gtk4" in os.getenv("MPLBACKEND") + and sys.platform == "darwin"): + # Could be off due to when the timers actually get fired (especially on CI) + assert 1 < mock.call_count <= expected_100ms_calls + 1, \ + f"Expected less than {expected_100ms_calls + 1} calls, " \ + "got {mock.call_count}" # Now turn it into a single shot timer and verify only one gets triggered mock.call_count = 0 timer.single_shot = True - timer.start() plt.pause(pause_time) assert mock.call_count == 1 - # Make sure we can start the timer a second time + # Make sure we can start the timer after stopping + timer.stop() timer.start() plt.pause(pause_time) assert mock.call_count == 2 diff --git a/src/_macosx.m b/src/_macosx.m index 09838eccaf98..4495bb585b03 100755 --- a/src/_macosx.m +++ b/src/_macosx.m @@ -1789,6 +1789,18 @@ - (void)flagsChanged:(NSEvent *)event Py_RETURN_NONE; } +static PyObject* +Timer__timer_update(Timer* self) +{ + // stop and invalidate a timer if it is already running and then create a new one + // where the start() method retrieves the updated interval internally + if (self->timer) { + Timer__timer_stop_impl(self); + gil_call_method((PyObject*)self, "_timer_start"); + } + Py_RETURN_NONE; +} + static void Timer_dealloc(Timer* self) { @@ -1815,6 +1827,12 @@ - (void)flagsChanged:(NSEvent *)event {"_timer_stop", (PyCFunction)Timer__timer_stop, METH_NOARGS}, + {"_timer_set_interval", + (PyCFunction)Timer__timer_update, + METH_NOARGS}, + {"_timer_set_single_shot", + (PyCFunction)Timer__timer_update, + METH_NOARGS}, {} // sentinel }, }; From 64a677e341bfed95b48ff754d495f0306d6e887c Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Fri, 25 Oct 2024 07:06:58 -0600 Subject: [PATCH 2/4] MNT/TST: Refactor test-interactive-timers to reduce test time --- .../tests/test_backends_interactive.py | 79 +++++++++---------- 1 file changed, 38 insertions(+), 41 deletions(-) diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 5569173ed347..8a127d619f59 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -621,55 +621,52 @@ def test_blitting_events(env): def _impl_test_interactive_timers(): + # NOTE: We run the timer tests in parallel to avoid longer sequential + # delays which adds to the testing time. Add new tests to one of + # the current event loop iterations if possible. + from unittest.mock import Mock + import matplotlib.pyplot as plt + + fig = plt.figure() + event_loop_time = 0.5 # in seconds + # A timer with <1 millisecond gets converted to int and therefore 0 # milliseconds, which the mac framework interprets as singleshot. # We only want singleshot if we specify that ourselves, otherwise we want # a repeating timer - import os - import sys - from unittest.mock import Mock - import matplotlib.pyplot as plt - # increase pause duration on CI to let things spin up - # particularly relevant for gtk3cairo - pause_time = 2 if os.getenv("CI") else 0.5 - expected_100ms_calls = int(pause_time / 0.1) - fig = plt.figure() - plt.pause(pause_time) - timer = fig.canvas.new_timer(0.1) - mock = Mock() - timer.add_callback(mock) - timer.start() - plt.pause(pause_time) + timer_repeating = fig.canvas.new_timer(0.1) + mock_repeating = Mock() + timer_repeating.add_callback(mock_repeating) + timer_repeating.start() + + timer_single_shot = fig.canvas.new_timer(100) + mock_single_shot = Mock() + timer_single_shot.add_callback(mock_single_shot) + # Start as a repeating timer then change to singleshot via the attribute + timer_single_shot.start() + timer_single_shot.single_shot = True + + fig.canvas.start_event_loop(event_loop_time) # NOTE: The timer is as fast as possible, but this is different between backends # so we can't assert on the exact number but it should be faster than 100ms - assert mock.call_count > expected_100ms_calls, \ - f"Expected more than {expected_100ms_calls} calls, got {mock.call_count}" + expected_100ms_calls = int(event_loop_time / 0.1) + assert mock_repeating.call_count > expected_100ms_calls, \ + f"Expected more than {expected_100ms_calls} calls, " \ + f"got {mock_repeating.call_count}" + assert mock_single_shot.call_count == 1 # Test updating the interval updates a running timer - timer.interval = 100 - mock.call_count = 0 - plt.pause(pause_time) - # GTK4 on macos runners produces about 3x as many calls as expected - # It works locally and on Linux though, so only skip when running on CI - if not (os.getenv("CI") - and "gtk4" in os.getenv("MPLBACKEND") - and sys.platform == "darwin"): - # Could be off due to when the timers actually get fired (especially on CI) - assert 1 < mock.call_count <= expected_100ms_calls + 1, \ - f"Expected less than {expected_100ms_calls + 1} calls, " \ - "got {mock.call_count}" - - # Now turn it into a single shot timer and verify only one gets triggered - mock.call_count = 0 - timer.single_shot = True - plt.pause(pause_time) - assert mock.call_count == 1 - - # Make sure we can start the timer after stopping - timer.stop() - timer.start() - plt.pause(pause_time) - assert mock.call_count == 2 + timer_repeating.interval = 100 + mock_repeating.call_count = 0 + # Make sure we can start the timer after stopping a singleshot timer + timer_single_shot.stop() + timer_single_shot.start() + + fig.canvas.start_event_loop(event_loop_time) + assert 1 < mock_repeating.call_count <= expected_100ms_calls + 1, \ + f"Expected less than {expected_100ms_calls + 1} calls, " \ + "got {mock.call_count}" + assert mock_single_shot.call_count == 2 plt.close("all") From 1a207a9fef1fdef8660847e9c22e954c4a0bcc56 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Fri, 25 Oct 2024 07:12:11 -0600 Subject: [PATCH 3/4] FIX: Event loop timers should only run for the specified time The implementation of start_event_loop would previously just count the number of sleeps that occurred. But this could lead to longer event loop times if flush_events() added time into the loop. We want the condition to be dependent on the end-time so we don't run our loop longer than necessary. --- lib/matplotlib/backend_bases.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 95ed49612b35..f6706d1ff16b 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2320,13 +2320,11 @@ def start_event_loop(self, timeout=0): """ if timeout <= 0: timeout = np.inf - timestep = 0.01 - counter = 0 + t_end = time.perf_counter() + timeout self._looping = True - while self._looping and counter * timestep < timeout: + while self._looping and time.perf_counter() < t_end: self.flush_events() - time.sleep(timestep) - counter += 1 + time.sleep(0.01) # Pause for 10ms def stop_event_loop(self): """ From 2d7a86f5e2dbfb7d32222a792753a155b34d8b16 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Fri, 25 Oct 2024 12:05:05 -0600 Subject: [PATCH 4/4] FIX: Add single shot update capability to TimerWx --- lib/matplotlib/backends/backend_wx.py | 4 ++++ lib/matplotlib/tests/test_backends_interactive.py | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/backends/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index c7e26b92134a..438fcb3b62b9 100644 --- a/lib/matplotlib/backends/backend_wx.py +++ b/lib/matplotlib/backends/backend_wx.py @@ -69,6 +69,10 @@ def _timer_set_interval(self): if self._timer.IsRunning(): self._timer_start() # Restart with new interval. + def _timer_set_single_shot(self): + if self._timer.IsRunning(): + self._timer_start() # Restart with new interval. + @_api.deprecated( "2.0", name="wx", obj_type="backend", removal="the future", diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 8a127d619f59..c4f41e800de0 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -653,7 +653,8 @@ def _impl_test_interactive_timers(): assert mock_repeating.call_count > expected_100ms_calls, \ f"Expected more than {expected_100ms_calls} calls, " \ f"got {mock_repeating.call_count}" - assert mock_single_shot.call_count == 1 + assert mock_single_shot.call_count == 1, \ + f"Expected 1 call, got {mock_single_shot.call_count}" # Test updating the interval updates a running timer timer_repeating.interval = 100 @@ -666,7 +667,8 @@ def _impl_test_interactive_timers(): assert 1 < mock_repeating.call_count <= expected_100ms_calls + 1, \ f"Expected less than {expected_100ms_calls + 1} calls, " \ "got {mock.call_count}" - assert mock_single_shot.call_count == 2 + assert mock_single_shot.call_count == 2, \ + f"Expected 2 calls, got {mock_single_shot.call_count}" plt.close("all")