Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Timer consistency across backends#29062

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Open
greglucas wants to merge13 commits intomatplotlib:main
base:main
Choose a base branch
Loading
fromgreglucas:timer-consistency
Open
Show file tree
Hide file tree
Changes fromall commits
Commits
Show all changes
13 commits
Select commitHold shift + click to select a range
84e7e60
FIX: Add update capability to interval/singleshot timer properties
greglucasOct 24, 2024
c04db6a
MNT/TST: Refactor test-interactive-timers to reduce test time
greglucasOct 25, 2024
b54c829
FIX: Event loop timers should only run for the specified time
greglucasOct 25, 2024
1ef70ca
FIX: Add single shot update capability to TimerWx
greglucasOct 25, 2024
88f8b77
FIX: Only call timer updates when things change on the timer
greglucasOct 25, 2024
018edd9
FIX: Avoid drift in Tk's repeating timer
greglucasOct 25, 2024
459410f
TST: Add test for timer drift in interactive backends
greglucasOct 25, 2024
ebe66bc
TST: Add backend bases test
greglucasOct 25, 2024
a5cd96d
FIX/ENH: macos: dispatch timer tasks asynchronously to the main loop
greglucasOct 28, 2024
d1e52b0
TST: Add text to identify failing tests
greglucasOct 31, 2024
577a0cf
FIX: macos should invalidate the previous timer when creating a new one
greglucasNov 5, 2024
52a584d
TST: Add interactive timer tests
greglucasNov 5, 2024
1affa9e
TST: Update some times for interactive timer test on CI
greglucasNov 20, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletionslib/matplotlib/backend_bases.py
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand DownExpand Up@@ -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):
Expand All@@ -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):
"""
Expand DownExpand Up@@ -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 andcounter * timestep < timeout:
while self._looping andtime.perf_counter() < t_end:
self.flush_events()
time.sleep(timestep)
counter += 1
time.sleep(0.01) # Pause for 10ms

def stop_event_loop(self):
"""
Expand Down
30 changes: 28 additions & 2 deletionslib/matplotlib/backends/_backend_tk.py
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@
import os.path
import pathlib
import sys
import time
import tkinter as tk
import tkinter.filedialog
import tkinter.font
Expand DownExpand Up@@ -126,27 +127,46 @@ 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:
self.parent.after_cancel(self._timer)
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,
# if _timer is None, this means that _timer_stop has been called; so
# 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.
Expand All@@ -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"
Expand Down
4 changes: 4 additions & 0 deletionslib/matplotlib/backends/backend_wx.py
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
31 changes: 30 additions & 1 deletionlib/matplotlib/tests/test_backend_bases.py
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -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
Expand DownExpand Up@@ -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
95 changes: 64 additions & 31 deletionslib/matplotlib/tests/test_backends_interactive.py
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)

Expand Down
59 changes: 39 additions & 20 deletionssrc/_macosx.m
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
{
Expand All@@ -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);
Expand All@@ -1798,19 +1808,22 @@ - (void)flagsChanged:(NSEvent *)event
}
}

staticvoid
Timer__timer_stop_impl(Timer* self)
staticPyObject*
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;
}

Expand DownExpand Up@@ -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
},
};
Expand Down
Loading

[8]ページ先頭

©2009-2025 Movatter.jp