diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 2158990f578a..edb04edd59ee 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -1059,7 +1059,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 @@ -1103,8 +1105,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): @@ -1113,8 +1116,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): """ @@ -2370,13 +2374,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): """ diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index 0bbff1379ffa..14f8a7b306a1 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 @@ -126,12 +127,15 @@ 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() 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. @@ -158,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/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_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index 0205eac42fb3..70e5c466d7fc 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,31 @@ 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() + + # 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 a27783fa4be1..5a1ec29f387b 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -645,46 +645,79 @@ def test_fallback_to_different_backend(): def _impl_test_interactive_timers(): - # 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 + # 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 - # increase pause duration on CI to let things spin up - # particularly relevant for gtk3cairo - pause_time = 2 if os.getenv("CI") else 0.5 - 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.stop() - assert mock.call_count > 1 - - # 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 - timer.start() - plt.pause(pause_time) - assert mock.call_count == 2 - plt.close("all") + fig = plt.figure() + # 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) + + timer_single_shot = fig.canvas.new_timer(100) + mock_single_shot = Mock() + timer_single_shot.add_callback(mock_single_shot) + + timer_repeating.start() + # Test updating the interval updates a running timer + 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(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}" + + # 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. + 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() + + # 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 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) / expected_calls <= 0.3, \ + 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}" @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") + 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) diff --git a/src/_macosx.m b/src/_macosx.m index aa2a6e68cda5..d7396e4b43f2 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,20 +1781,21 @@ - (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 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); @@ -1798,19 +1808,22 @@ - (void)flagsChanged:(NSEvent *)event } } -static void -Timer__timer_stop_impl(Timer* self) +static PyObject* +Timer__timer_stop(Timer* self) { - if (self->timer) { - [self->timer invalidate]; - self->timer = NULL; - } + Timer__timer_stop_impl(self); + Py_RETURN_NONE; } static PyObject* -Timer__timer_stop(Timer* self) +Timer__timer_update(Timer* self) { - Timer__timer_stop_impl(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; } @@ -1840,6 +1853,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 }, };