diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 4c8d8266c869..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): """ @@ -2363,13 +2367,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 eaf868fd8bec..4ce590e22c5a 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 9725a79397bc..1dec7a45d36b 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -646,43 +646,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 + # 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 - pause_time = 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 1372157bc80d..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,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 }, }; 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