From 233fc2a99b13454cb94688239151237019bd06d5 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Thu, 24 Oct 2024 09:34:01 -0600 Subject: [PATCH 01/13] 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 9725a79397bc..b5132e2f980f 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -650,9 +650,11 @@ def _impl_test_interactive_timers(): # milliseconds, which the mac framework interprets as singleshot. # We only want singleshot if we specify that ourselves, otherwise we want # a repeating timer + import sys from unittest.mock import Mock import matplotlib.pyplot as plt pause_time = 0.5 + expected_100ms_calls = int(pause_time / 0.1) fig = plt.figure() plt.pause(pause_time) timer = fig.canvas.new_timer(0.1) @@ -660,17 +662,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 1372157bc80d..996d3338df95 100755 --- a/src/_macosx.m +++ b/src/_macosx.m @@ -1814,6 +1814,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) { @@ -1840,6 +1852,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 94b8ae4d70df6c32a5a3875db1c61fb2c2a95139 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Fri, 25 Oct 2024 07:06:58 -0600 Subject: [PATCH 02/13] MNT/TST: Refactor test-interactive-timers to reduce test time --- .../tests/test_backends_interactive.py | 76 +++++++++---------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index b5132e2f980f..861074b51670 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -646,52 +646,52 @@ def test_fallback_to_different_backend(): 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 sys - from unittest.mock import Mock - import matplotlib.pyplot as plt - pause_time = 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 13289af59679d31872eb7ee939c1f9c2e6a8e132 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Fri, 25 Oct 2024 07:12:11 -0600 Subject: [PATCH 03/13] 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 4c8d8266c869..a85edb029e2f 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2363,13 +2363,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 c1c8e7a76e993ef215ebb82e9d281cb75b0e9d3b Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Fri, 25 Oct 2024 12:05:05 -0600 Subject: [PATCH 04/13] 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 f83a69d8361e..9eb054bf4089 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 861074b51670..0673ecae3cc7 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -678,7 +678,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 @@ -691,7 +692,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") From 5f0359ec7db649cead8e659b366cc49a49706bc1 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Fri, 25 Oct 2024 11:22:55 -0600 Subject: [PATCH 05/13] FIX: Only call timer updates when things change on the timer If we set interval in a loop, this could cause the timing to become dependent on the callback processing time. --- lib/matplotlib/backend_bases.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index a85edb029e2f..52db8b9b4ff5 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -1061,7 +1061,9 @@ def __init__(self, interval=None, callbacks=None): and `~.TimerBase.remove_callback` can be used. """ self.callbacks = [] if callbacks is None else callbacks.copy() - # Set .interval and not ._interval to go through the property setter. + # Go through the property setters for validation and updates + self._interval = None + self._single = None self.interval = 1000 if interval is None else interval self.single_shot = False @@ -1094,8 +1096,9 @@ def interval(self, interval): # milliseconds, and some error or give warnings. # Some backends also fail when interval == 0, so ensure >= 1 msec interval = max(int(interval), 1) - self._interval = interval - self._timer_set_interval() + if interval != self._interval: + self._interval = interval + self._timer_set_interval() @property def single_shot(self): @@ -1104,8 +1107,9 @@ def single_shot(self): @single_shot.setter def single_shot(self, ss): - self._single = ss - self._timer_set_single_shot() + if ss != self._single: + self._single = ss + self._timer_set_single_shot() def add_callback(self, func, *args, **kwargs): """ From 1408a72aa885d23c6430c745d0bb0cd56fe41a28 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Fri, 25 Oct 2024 11:22:55 -0600 Subject: [PATCH 06/13] FIX: Avoid drift in Tk's repeating timer The Tk timer would reset itself after the callback had processed to add a new interval. This meant that if a long callback was being added we would get a drift in the timer. We need to manually track the original firing time and intervals based on that. --- lib/matplotlib/backends/_backend_tk.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index eaf868fd8bec..e39f56cdc430 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -6,6 +6,7 @@ import os.path import pathlib import sys +import time import tkinter as tk import tkinter.filedialog import tkinter.font @@ -132,6 +133,9 @@ def __init__(self, parent, *args, **kwargs): def _timer_start(self): self._timer_stop() self._timer = self.parent.after(self._interval, self._on_timer) + # Keep track of the firing time for repeating timers since + # we have to do this manually in Tk + self._timer_start_count = time.perf_counter_ns() def _timer_stop(self): if self._timer is not None: @@ -139,6 +143,9 @@ def _timer_stop(self): self._timer = None def _on_timer(self): + # We want to measure the time spent in the callback, so we need to + # record the time before calling the base class method. + timer_fire_ms = (time.perf_counter_ns() - self._timer_start_count) // 1_000_000 super()._on_timer() # Tk after() is only a single shot, so we need to add code here to # reset the timer if we're not operating in single shot mode. However, @@ -146,7 +153,20 @@ def _on_timer(self): # don't recreate the timer in that case. if not self._single and self._timer: if self._interval > 0: - self._timer = self.parent.after(self._interval, self._on_timer) + # We want to adjust our fire time independent of the time + # spent in the callback and not drift over time, so reference + # to the start count. + after_callback_ms = ((time.perf_counter_ns() - self._timer_start_count) + // 1_000_000) + if after_callback_ms - timer_fire_ms < self._interval: + next_interval = self._interval - after_callback_ms % self._interval + # minimum of 1ms + next_interval = max(1, next_interval) + else: + # Account for the callback being longer than the interval, where + # we really want to fire the next timer as soon as possible. + next_interval = 1 + self._timer = self.parent.after(next_interval, self._on_timer) else: # Edge case: Tcl after 0 *prepends* events to the queue # so a 0 interval does not allow any other events to run. From e33ad7b02df61639e84aed51ee8349f9686f0dbf Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Fri, 25 Oct 2024 11:22:55 -0600 Subject: [PATCH 07/13] TST: Add test for timer drift in interactive backends Make sure that the interactive timers don't drift when long callbacks are associated with them. --- .../tests/test_backends_interactive.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 0673ecae3cc7..f3961ab1a74a 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -662,15 +662,24 @@ def _impl_test_interactive_timers(): 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) + + # 100ms timer triggers and the callback takes 75ms to run + # Test that we don't drift and that we get called on every 100ms + # interval and not every 175ms + mock_slow_callback = Mock() + mock_slow_callback.side_effect = lambda: time.sleep(0.075) + timer_slow_callback = fig.canvas.new_timer(100) + timer_slow_callback.add_callback(mock_slow_callback) + + timer_repeating.start() # Start as a repeating timer then change to singleshot via the attribute timer_single_shot.start() timer_single_shot.single_shot = True - + timer_slow_callback.start() 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 @@ -680,6 +689,9 @@ def _impl_test_interactive_timers(): f"got {mock_repeating.call_count}" assert mock_single_shot.call_count == 1, \ f"Expected 1 call, got {mock_single_shot.call_count}" + assert mock_slow_callback.call_count >= expected_100ms_calls - 1, \ + f"Expected at least {expected_100ms_calls - 1} calls, " \ + f"got {mock_slow_callback.call_count}" # Test updating the interval updates a running timer timer_repeating.interval = 100 From 7d7296070dfcb6f12338467b3664f511d42f6072 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Fri, 25 Oct 2024 11:22:55 -0600 Subject: [PATCH 08/13] TST: Add backend bases test --- lib/matplotlib/tests/test_backend_bases.py | 24 +++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/tests/test_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index 0205eac42fb3..b6a50bcec665 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -1,9 +1,10 @@ import importlib +from unittest.mock import patch from matplotlib import path, transforms from matplotlib.backend_bases import ( FigureCanvasBase, KeyEvent, LocationEvent, MouseButton, MouseEvent, - NavigationToolbar2, RendererBase) + NavigationToolbar2, RendererBase, TimerBase) from matplotlib.backend_tools import RubberbandBase from matplotlib.figure import Figure from matplotlib.testing._markers import needs_pgf_xelatex @@ -581,3 +582,24 @@ def test_interactive_pan_zoom_events(tool, button, patch_vis, forward_nav, t_s): # Check if twin-axes are properly triggered assert ax_t.get_xlim() == pytest.approx(ax_t_twin.get_xlim(), abs=0.15) assert ax_b.get_xlim() == pytest.approx(ax_b_twin.get_xlim(), abs=0.15) + + +def test_timer_properties(): + # Setting a property to the same value should not trigger the + # private setter call again. + timer = TimerBase(100) + with patch.object(timer, '_timer_set_interval') as mock: + timer.interval = 200 + mock.assert_called_once() + assert timer.interval == 200 + timer.interval = 200 + # Make sure it wasn't called again + mock.assert_called_once() + + with patch.object(timer, '_timer_set_single_shot') as mock: + timer.single_shot = True + mock.assert_called_once() + assert timer._single + timer.single_shot = True + # Make sure it wasn't called again + mock.assert_called_once() From d06ddda0f2c3484a5a78f717bc56413fddd95145 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Mon, 28 Oct 2024 11:13:07 -0600 Subject: [PATCH 09/13] FIX/ENH: macos: dispatch timer tasks asynchronously to the main loop Previously, the timers were dependent on the length of time it took for the timer callback to execute. This dispatches the callback to the task queue to avoid synchronously waiting on long-running callback tasks. --- src/_macosx.m | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/_macosx.m b/src/_macosx.m index 996d3338df95..a78121f78533 100755 --- a/src/_macosx.m +++ b/src/_macosx.m @@ -1773,19 +1773,18 @@ - (void)flagsChanged:(NSEvent *)event } // hold a reference to the timer so we can invalidate/stop it later - self->timer = [NSTimer timerWithTimeInterval: interval - repeats: !single - block: ^(NSTimer *timer) { - gil_call_method((PyObject*)self, "_on_timer"); - if (single) { - // A single-shot timer will be automatically invalidated when it fires, so - // we shouldn't do it ourselves when the object is deleted. - self->timer = NULL; - } + self->timer = [NSTimer scheduledTimerWithTimeInterval: interval + repeats: !single + block: ^(NSTimer *timer) { + dispatch_async(dispatch_get_main_queue(), ^{ + gil_call_method((PyObject*)self, "_on_timer"); + if (single) { + // A single-shot timer will be automatically invalidated when it fires, so + // we shouldn't do it ourselves when the object is deleted. + self->timer = NULL; + } + }); }]; - // Schedule the timer on the main run loop which is needed - // when updating the UI from a background thread - [[NSRunLoop mainRunLoop] addTimer: self->timer forMode: NSRunLoopCommonModes]; exit: Py_XDECREF(py_interval); From f9dbbc739faf3096789f9eb4edbae7a476762fe8 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Thu, 31 Oct 2024 11:01:35 -0600 Subject: [PATCH 10/13] TST: Add text to identify failing tests --- lib/matplotlib/backends/_backend_tk.py | 8 ++- lib/matplotlib/tests/test_backend_bases.py | 7 +++ .../tests/test_backends_interactive.py | 52 +++++++------------ 3 files changed, 34 insertions(+), 33 deletions(-) diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index e39f56cdc430..4ce590e22c5a 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -127,8 +127,8 @@ class TimerTk(TimerBase): def __init__(self, parent, *args, **kwargs): self._timer = None - super().__init__(*args, **kwargs) self.parent = parent + super().__init__(*args, **kwargs) def _timer_start(self): self._timer_stop() @@ -178,6 +178,12 @@ def _on_timer(self): else: self._timer = None + def _timer_set_interval(self): + self._timer_start() + + def _timer_set_single_shot(self): + self._timer_start() + class FigureCanvasTk(FigureCanvasBase): required_interactive_framework = "tk" diff --git a/lib/matplotlib/tests/test_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index b6a50bcec665..70e5c466d7fc 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -603,3 +603,10 @@ def test_timer_properties(): timer.single_shot = True # Make sure it wasn't called again mock.assert_called_once() + + # 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, so make sure our interval is set to a minimum of 1ms. + timer.interval = 0.1 + assert timer.interval == 1 diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index f3961ab1a74a..7ce9b5cf2647 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -653,13 +653,11 @@ def _impl_test_interactive_timers(): import matplotlib.pyplot as plt fig = plt.figure() - event_loop_time = 0.5 # in seconds + event_loop_time = 1 # in seconds + expected_200ms_calls = int(event_loop_time / 0.2) - # 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 - timer_repeating = fig.canvas.new_timer(0.1) + # Start at 2s interval (would only get one firing), then update to 200ms + timer_repeating = fig.canvas.new_timer(2000) mock_repeating = Mock() timer_repeating.add_callback(mock_repeating) @@ -667,52 +665,42 @@ def _impl_test_interactive_timers(): mock_single_shot = Mock() timer_single_shot.add_callback(mock_single_shot) - # 100ms timer triggers and the callback takes 75ms to run - # Test that we don't drift and that we get called on every 100ms - # interval and not every 175ms - mock_slow_callback = Mock() - mock_slow_callback.side_effect = lambda: time.sleep(0.075) - timer_slow_callback = fig.canvas.new_timer(100) - timer_slow_callback.add_callback(mock_slow_callback) - timer_repeating.start() + # Test updating the interval updates a running timer + timer_repeating.interval = 200 # Start as a repeating timer then change to singleshot via the attribute timer_single_shot.start() timer_single_shot.single_shot = True - timer_slow_callback.start() + 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 - 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, " \ + assert 1 < mock_repeating.call_count <= expected_200ms_calls + 1, \ + f"Interval update: Expected between 2 and {expected_200ms_calls + 1} calls, " \ f"got {mock_repeating.call_count}" assert mock_single_shot.call_count == 1, \ - f"Expected 1 call, got {mock_single_shot.call_count}" - assert mock_slow_callback.call_count >= expected_100ms_calls - 1, \ - f"Expected at least {expected_100ms_calls - 1} calls, " \ - f"got {mock_slow_callback.call_count}" + f"Singleshot: Expected 1 call, got {mock_single_shot.call_count}" - # Test updating the interval updates a running timer - timer_repeating.interval = 100 + # 200ms timer triggers and the callback takes 100ms to run + # Test that we don't drift and that we get called on every 200ms + # interval and not every 300ms + mock_repeating.side_effect = lambda: time.sleep(0.1) 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}" + # Not exact timers, so add a little slop. We really want to make sure we are + # getting more than 3 (every 300ms). + assert mock_repeating.call_count >= expected_200ms_calls - 1, \ + f"Slow callback: Expected at least {expected_200ms_calls - 1} calls, " \ + f"got {mock_repeating.call_count}" assert mock_single_shot.call_count == 2, \ - f"Expected 2 calls, got {mock_single_shot.call_count}" + f"Singleshot: Expected 2 calls, got {mock_single_shot.call_count}" plt.close("all") @pytest.mark.parametrize("env", _get_testable_interactive_backends()) def test_interactive_timers(env): - if env["MPLBACKEND"] == "gtk3cairo" and os.getenv("CI"): - pytest.skip("gtk3cairo timers do not work in remote CI") if env["MPLBACKEND"] == "wx": pytest.skip("wx backend is deprecated; tests failed on appveyor") _run_helper(_impl_test_interactive_timers, From 09f22cece2474f9d2308b09faa02999ac2a7b8da Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Tue, 5 Nov 2024 09:02:58 -0700 Subject: [PATCH 11/13] FIX: macos should invalidate the previous timer when creating a new one --- src/_macosx.m | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/_macosx.m b/src/_macosx.m index a78121f78533..4a18247509ba 100755 --- a/src/_macosx.m +++ b/src/_macosx.m @@ -1754,6 +1754,15 @@ - (void)flagsChanged:(NSEvent *)event (void*) self, (void*)(self->timer)); } +static void +Timer__timer_stop_impl(Timer* self) +{ + if (self->timer) { + [self->timer invalidate]; + self->timer = NULL; + } +} + static PyObject* Timer__timer_start(Timer* self, PyObject* args) { @@ -1772,6 +1781,8 @@ - (void)flagsChanged:(NSEvent *)event goto exit; } + // Stop the current timer if it is already running + Timer__timer_stop_impl(self); // hold a reference to the timer so we can invalidate/stop it later self->timer = [NSTimer scheduledTimerWithTimeInterval: interval repeats: !single @@ -1797,15 +1808,6 @@ - (void)flagsChanged:(NSEvent *)event } } -static void -Timer__timer_stop_impl(Timer* self) -{ - if (self->timer) { - [self->timer invalidate]; - self->timer = NULL; - } -} - static PyObject* Timer__timer_stop(Timer* self) { From ff670293ec47769b9ea9586b045ab84ad2499431 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Tue, 5 Nov 2024 09:00:52 -0700 Subject: [PATCH 12/13] TST: Add interactive timer tests This adds more robust interactive timer tests to assert against some of the discrepencies that were found in testing. - Run loop shouldn't depend on callback time - Slow callbacks shouldn't cause a timer to drift over time, it should continually fire at the requested cadence - When start() is called again it should invalidate the previous timer associated with that Timer object --- .../tests/test_backends_interactive.py | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 7ce9b5cf2647..06aa7e05dac4 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -649,14 +649,12 @@ 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. + import time from unittest.mock import Mock import matplotlib.pyplot as plt fig = plt.figure() - event_loop_time = 1 # in seconds - expected_200ms_calls = int(event_loop_time / 0.2) - - # Start at 2s interval (would only get one firing), then update to 200ms + # Start at 2s interval (wouldn't get any firings), then update to 100ms timer_repeating = fig.canvas.new_timer(2000) mock_repeating = Mock() timer_repeating.add_callback(mock_repeating) @@ -667,42 +665,55 @@ def _impl_test_interactive_timers(): timer_repeating.start() # Test updating the interval updates a running timer - timer_repeating.interval = 200 + timer_repeating.interval = 100 # 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) - assert 1 < mock_repeating.call_count <= expected_200ms_calls + 1, \ - f"Interval update: Expected between 2 and {expected_200ms_calls + 1} calls, " \ - f"got {mock_repeating.call_count}" + fig.canvas.start_event_loop(0.5) + assert 2 <= mock_repeating.call_count <= 5, \ + f"Interval update: Expected 2-5 calls, got {mock_repeating.call_count}" assert mock_single_shot.call_count == 1, \ f"Singleshot: Expected 1 call, got {mock_single_shot.call_count}" - # 200ms timer triggers and the callback takes 100ms to run - # Test that we don't drift and that we get called on every 200ms - # interval and not every 300ms - mock_repeating.side_effect = lambda: time.sleep(0.1) + # 250ms timer triggers and the callback takes 150ms to run + # Test that we don't drift and that we get called on every 250ms + # firing and not every 400ms + timer_repeating.interval = 250 + mock_repeating.side_effect = lambda: time.sleep(0.15) + # calling start() again on a repeating timer should remove the old + # one, so we don't want double the number of calls here either because + # two timers are potentially running. + timer_repeating.start() 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() + event_loop_time = 2 # in seconds + expected_calls = int(event_loop_time / (timer_repeating.interval / 1000)) + + t_start = time.perf_counter() fig.canvas.start_event_loop(event_loop_time) - # Not exact timers, so add a little slop. We really want to make sure we are - # getting more than 3 (every 300ms). - assert mock_repeating.call_count >= expected_200ms_calls - 1, \ - f"Slow callback: Expected at least {expected_200ms_calls - 1} calls, " \ + t_loop = time.perf_counter() - t_start + # Should be around 2s, but allow for some slop on CI. We want to make sure + # we aren't getting 2 + (callback time) 0.5s/iteration, which would be 4+ s. + assert 1.8 < t_loop < 3, \ + f"Event loop: Expected to run for around 2s, but ran for {t_loop:.2f}s" + # Not exact timers, so add some slop. (Quite a bit for CI resources) + assert abs(mock_repeating.call_count - expected_calls) <= 2, \ + f"Slow callback: Expected {expected_calls} calls, " \ f"got {mock_repeating.call_count}" assert mock_single_shot.call_count == 2, \ f"Singleshot: Expected 2 calls, got {mock_single_shot.call_count}" - plt.close("all") @pytest.mark.parametrize("env", _get_testable_interactive_backends()) def test_interactive_timers(env): if env["MPLBACKEND"] == "wx": pytest.skip("wx backend is deprecated; tests failed on appveyor") + if env["MPLBACKEND"].startswith("gtk3") and is_ci_environment(): + pytest.xfail("GTK3 backend timer is slow on CI resources") _run_helper(_impl_test_interactive_timers, timeout=_test_timeout, extra_env=env) From 6842ed298455a503a20a7745ec0fc4389820e396 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Wed, 20 Nov 2024 09:20:55 -0700 Subject: [PATCH 13/13] TST: Update some times for interactive timer test on CI --- .../tests/test_backends_interactive.py | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 06aa7e05dac4..1dec7a45d36b 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -676,11 +676,13 @@ def _impl_test_interactive_timers(): assert mock_single_shot.call_count == 1, \ f"Singleshot: Expected 1 call, got {mock_single_shot.call_count}" - # 250ms timer triggers and the callback takes 150ms to run - # Test that we don't drift and that we get called on every 250ms - # firing and not every 400ms - timer_repeating.interval = 250 - mock_repeating.side_effect = lambda: time.sleep(0.15) + # 500ms timer triggers and the callback takes 400ms to run + # Test that we don't drift and that we get called on every 500ms + # firing and not every 900ms + timer_repeating.interval = 500 + # sleep for 80% of the interval + sleep_time = timer_repeating.interval / 1000 * 0.8 + mock_repeating.side_effect = lambda: time.sleep(sleep_time) # calling start() again on a repeating timer should remove the old # one, so we don't want double the number of calls here either because # two timers are potentially running. @@ -690,18 +692,21 @@ def _impl_test_interactive_timers(): timer_single_shot.stop() timer_single_shot.start() - event_loop_time = 2 # in seconds + # CI resources are inconsistent, so we need to allow for some slop + event_loop_time = 10 if os.getenv("CI") else 3 # in seconds expected_calls = int(event_loop_time / (timer_repeating.interval / 1000)) t_start = time.perf_counter() fig.canvas.start_event_loop(event_loop_time) t_loop = time.perf_counter() - t_start - # Should be around 2s, but allow for some slop on CI. We want to make sure - # we aren't getting 2 + (callback time) 0.5s/iteration, which would be 4+ s. - assert 1.8 < t_loop < 3, \ - f"Event loop: Expected to run for around 2s, but ran for {t_loop:.2f}s" + # Should be around event_loop_time, but allow for some slop on CI. + # We want to make sure we aren't getting + # event_loop_time + (callback time)*niterations + assert event_loop_time * 0.95 < t_loop < event_loop_time / 0.7, \ + f"Event loop: Expected to run for around {event_loop_time}s, " \ + f"but ran for {t_loop:.2f}s" # Not exact timers, so add some slop. (Quite a bit for CI resources) - assert abs(mock_repeating.call_count - expected_calls) <= 2, \ + assert abs(mock_repeating.call_count - expected_calls) / expected_calls <= 0.3, \ f"Slow callback: Expected {expected_calls} calls, " \ f"got {mock_repeating.call_count}" assert mock_single_shot.call_count == 2, \ pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy