Skip to content

Commit dc32205

Browse files
authored
Clock: Enhance and fix @triggered decorator (instance isolation, debouncing, lazy initialization) and add deterministic pytest fixture for manual clock advancement (kivy#9275)
* fix and improve triggered decorator * fix pep8 * add kivy_clock_advance and replace time.sleep in tests * fix pep8
1 parent 81b4c5a commit dc32205

5 files changed

Lines changed: 566 additions & 45 deletions

File tree

doc/sources/migration.rst

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,68 @@ You no longer need to manually clean up groups:
875875
+---------------------------+---------------------------+----------------------------------------+
876876

877877

878+
=====
879+
Clock
880+
=====
881+
882+
*Improved @triggered Decorator Behavior, Instance Isolation and Debouncing*
883+
884+
In Kivy 3.x.x, the :func:`~kivy.clock.triggered` decorator has been significantly
885+
improved. Previously, when used as a method decorator, it shared a single trigger
886+
and state across all instances of a class. This meant that calling the method on
887+
one instance would throttle calls on all other instances, and arguments from
888+
different instances could overwrite each other.
889+
890+
**Behavior Changes**
891+
892+
* Improved **Instance Isolation**: Each instance now has its own isolated trigger and
893+
argument storage. Calling a triggered method on ``widget_a`` no longer affects
894+
``widget_b``.
895+
* Improved **Lazy Initialization**: Triggers are now created only when the decorated
896+
function is first called, improving initialization performance.
897+
* New **is_triggered Property**: A new ``is_triggered`` property was added to the decorated
898+
function/method, allowing you to check if a call is currently pending.
899+
* New **debounce Parameter**: A new ``debounce=False`` (default) parameter was
900+
added.
901+
902+
* **Throttling** (default): Subsequent calls while a trigger is active
903+
update the arguments but do *not* reset the timer. The function fires once
904+
after the initial timeout.
905+
* **Debouncing** (``debounce=True``): Subsequent calls cancel any pending
906+
execution and reschedule it. The function only fires after the caller
907+
stops calling it for the duration of the ``timeout``.
908+
909+
**Migration Impact**
910+
911+
This is primarily a **bug fix** and a set of **new features**. It should not
912+
require code changes for most applications. However, if your codebase
913+
intentionally relied on the legacy shared throttling behavior across different
914+
instances, you can restore this behavior by using the **``@classmethod``**
915+
decorator above ``@triggered``.
916+
917+
This ensures the trigger is bound to the class object rather than individual
918+
instances, restoring the shared behavior in an idiomatic way.
919+
920+
.. code-block:: python
921+
922+
class MyWidget(Widget):
923+
# Default in 3.x.x: Isolated per instance
924+
@triggered(0.1)
925+
def sync_ui(self, *args):
926+
pass
927+
928+
# Shared behavior (same as legacy 2.x.x): Shared by all instances
929+
@classmethod
930+
@triggered(0.1)
931+
def sync_shared_data(cls, *args):
932+
pass
933+
934+
# Optional: Debouncing (0.1s from the LAST call)
935+
@triggered(0.1, debounce=True)
936+
def search_input(self, text):
937+
pass
938+
939+
878940
Application Storage Directories
879941
================================
880942

kivy/clock.py

Lines changed: 208 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,8 @@ def handle_exception(self, inst):
456456

457457
from sys import platform
458458
from os import environ
459-
from functools import wraps, partial
459+
from functools import wraps, partial, update_wrapper
460+
from weakref import WeakKeyDictionary, ref as weakref_ref
460461
from kivy.context import register_context
461462
from kivy.config import Config
462463
from kivy.logger import Logger
@@ -1094,62 +1095,227 @@ def callback_func(dt):
10941095
return delayed_func
10951096

10961097

1097-
def triggered(timeout=0, interval=False):
1098-
'''Decorator that will trigger the call of the function at the specified
1099-
timeout, through the method :meth:`CyClockBase.create_trigger`. Subsequent
1100-
calls to the decorated function (while the timeout is active) are ignored.
1098+
# The @triggered decorator uses two classes to handle the duality of Python
1099+
# functions (global functions vs. instance methods):
1100+
#
1101+
# 1. _TriggeredWrapper (The Descriptor):
1102+
# - Lives at the module level (global) or class level.
1103+
# - For global functions: Manages its own `_trigger` and state in __call__.
1104+
# - For class methods: Implements the Descriptor Protocol (__get__) to
1105+
# intercept access via an instance (e.g., `widget.method()`).
1106+
# - Uses a WeakKeyDictionary to map instances to their respective
1107+
# _BoundTrigger objects, ensuring no memory leaks.
1108+
#
1109+
# 2. _BoundTrigger (The Instance State):
1110+
# - The object returned when accessing a method on an instance.
1111+
# - Maintains a `Clock.create_trigger` and argument buffers
1112+
# (`_args`, `_kwargs`) that are fully ISOLATED for each instance of the
1113+
# widget/object.
1114+
# - This isolation allows debouncing/throttling to work independently:
1115+
# triggering on 'Widget A' does not affect or cancel the schedule of
1116+
# 'Widget B'.
1117+
#
1118+
# This separation resolves "cross-talk" between instances while keeping
1119+
# the decorator flexible enough for use outside of classes.
1120+
1121+
1122+
class _TriggeredWrapper:
1123+
def __init__(self, func, timeout, interval, debounce):
1124+
self._func = func
1125+
self._timeout = timeout
1126+
self._interval = interval
1127+
self._debounce = debounce
1128+
self._args = []
1129+
self._kwargs = {}
1130+
self._trigger = None
1131+
self._instances = WeakKeyDictionary()
1132+
update_wrapper(self, func, updated=())
11011133

1102-
It can be helpful when an expensive function (i.e. call to a server) can be
1103-
triggered by different methods. Setting a proper timeout will delay the
1104-
calling and only one of them will be triggered.
1134+
@property
1135+
def is_triggered(self):
1136+
"""Returns True if the trigger is currently scheduled."""
1137+
if self._trigger is not None:
1138+
return self._trigger.is_triggered
1139+
return False
1140+
1141+
def _ensure_trigger(self):
1142+
if self._trigger is None:
1143+
self._trigger = Clock.create_trigger(
1144+
self.cb_function, timeout=self._timeout, interval=self._interval
1145+
)
1146+
1147+
def cb_function(self, dt):
1148+
self._func(*tuple(self._args), **self._kwargs)
1149+
1150+
def __call__(self, *args, **kwargs):
1151+
self._ensure_trigger()
1152+
if self._debounce:
1153+
self._trigger.cancel()
1154+
self._args[:] = args
1155+
self._kwargs.clear()
1156+
self._kwargs.update(kwargs)
1157+
self._trigger()
1158+
1159+
def cancel(self):
1160+
if self._trigger is not None:
1161+
self._trigger.cancel()
1162+
1163+
def __get__(self, instance, owner):
1164+
if instance is None:
1165+
return self
1166+
1167+
if instance not in self._instances:
1168+
# Create a bound version for this instance
1169+
inst_weak = weakref_ref(instance)
1170+
inst_args = []
1171+
inst_kwargs = {}
1172+
func = self._func
1173+
timeout = self._timeout
1174+
interval = self._interval
1175+
debounce = self._debounce
1176+
1177+
def inst_cb(dt):
1178+
inst = inst_weak()
1179+
if inst is not None:
1180+
func(inst, *tuple(inst_args), **inst_kwargs)
1181+
1182+
inst_trigger = Clock.create_trigger(
1183+
inst_cb, timeout=timeout, interval=interval
1184+
)
1185+
1186+
bound_trigger = _BoundTrigger(
1187+
inst_trigger, inst_args, inst_kwargs, inst_weak, debounce
1188+
)
1189+
update_wrapper(bound_trigger, func)
1190+
self._instances[instance] = bound_trigger
1191+
1192+
return self._instances[instance]
1193+
1194+
1195+
class _BoundTrigger(object):
1196+
1197+
def __init__(self, trigger, args, kwargs, inst_weak, debounce):
1198+
self._trigger = trigger
1199+
self._args = args
1200+
self._kwargs = kwargs
1201+
self._inst_weak = inst_weak
1202+
self._debounce = debounce
1203+
1204+
def __call__(self, *args, **kwargs):
1205+
if self._inst_weak() is not None:
1206+
if self._debounce:
1207+
self._trigger.cancel()
1208+
self._args[:] = args
1209+
self._kwargs.clear()
1210+
self._kwargs.update(kwargs)
1211+
self._trigger()
11051212

1106-
@triggered(timeout, interval=False)
1107-
def callback(id):
1108-
print('The callback has been called with id=%d' % id)
1213+
@property
1214+
def is_triggered(self):
1215+
"""Returns True if the trigger is currently scheduled."""
1216+
return self._trigger.is_triggered
11091217

1110-
>> callback(id=1)
1111-
>> callback(id=2)
1112-
The callback has been called with id=2
1218+
def cancel(self):
1219+
"""Unschedule the trigger."""
1220+
self._trigger.cancel()
11131221

1114-
The decorated callback can also be unscheduled using:
11151222

1116-
>> callback.cancel()
1223+
def triggered(timeout=0, interval=False, debounce=False):
1224+
"""Decorator that schedules the execution of a function after a specified
1225+
timeout using :meth:`CyClockBase.create_trigger`.
11171226
1118-
.. versionadded:: 1.10.1
1119-
'''
1120-
fun = None
1227+
This decorator provides several key behaviors:
11211228
1122-
if callable(timeout):
1123-
fun = timeout
1124-
timeout = 0
1229+
* **Throttling** (default): Subsequent calls while a trigger is active are
1230+
ignored. The function fires once after the initial timeout.
1231+
* **Debouncing**: If ``debounce=True``, subsequent calls cancel any
1232+
pending execution and reschedule it. The function only fires after the
1233+
caller stops calling it for the duration of the ``timeout``.
1234+
* **Last Arguments Win**: If called multiple times before the trigger
1235+
fires, the arguments from the **latest** call are used.
1236+
* **Instance Isolation**: When used as a method decorator, each instance
1237+
gets its own isolated trigger and state.
1238+
* **Thread Safety**: Safe to call from external threads.
11251239
1126-
def wrapper_triggered(func):
1240+
The decorated function gains a ``.cancel()`` method to unschedule any
1241+
pending execution, and an ``.is_triggered`` property to check its status.
1242+
1243+
:param timeout: The delay (in seconds) before the function is called.
1244+
:type timeout: float, defaults to 0
1245+
:param interval: If True, the trigger will be repeating (standard Clock
1246+
interval behavior).
1247+
:type interval: bool, defaults to False
1248+
:param debounce: If True, subsequent calls will cancel and reschedule the
1249+
trigger.
1250+
:type debounce: bool, defaults to False
1251+
1252+
Example of a global function without debouncing::
1253+
1254+
@triggered(0.1)
1255+
def sync_data(user_id):
1256+
print(f"Syncing data for {user_id}")
1257+
1258+
sync_data(1)
1259+
sync_data(2) # Overwrites arguments of the first call
1260+
# 0.1s later: "Syncing data for 2"
11271261
1128-
_args = []
1129-
_kwargs = {}
1262+
Example of a global function with debouncing::
11301263
1131-
def cb_function(dt):
1132-
func(*tuple(_args), **_kwargs)
1264+
@triggered(0.1, debounce=True)
1265+
def sync_data(user_id):
1266+
print(f"Syncing data for {user_id}")
11331267
1134-
cb_trigger = Clock.create_trigger(
1135-
cb_function,
1136-
timeout=timeout,
1137-
interval=interval)
1268+
sync_data(1)
1269+
# 0.05s later
1270+
sync_data(2) # Cancels the first call and reschedules for 0.1s from now
1271+
# 0.1s after the LAST call ("sync_data(2)"): "Syncing data for 2"
11381272
1139-
@wraps(func)
1140-
def trigger_function(*args, **kwargs):
1141-
_args[:] = []
1142-
_args.extend(list(args))
1143-
_kwargs.clear()
1144-
_kwargs.update(kwargs)
1145-
cb_trigger()
1273+
Example of an instance method::
11461274
1147-
def trigger_cancel():
1148-
cb_trigger.cancel()
1275+
class DataMapper:
1276+
@triggered(0.2)
1277+
def refresh(self, *args):
1278+
# This will be throttled per-instance
1279+
pass
11491280
1150-
setattr(trigger_function, 'cancel', trigger_cancel)
1281+
dm1, dm2 = DataMapper(), DataMapper()
1282+
dm1.refresh() # Scheduled
1283+
dm2.refresh() # Scheduled independently
11511284
1152-
return trigger_function
1285+
Example with ``classmethod`` and ``staticmethod`` (always put
1286+
``@triggered`` below them)::
1287+
1288+
class Utils:
1289+
@classmethod
1290+
@triggered(0.1)
1291+
def global_refresh(cls, *args):
1292+
# shared by all instances
1293+
pass
1294+
1295+
@staticmethod
1296+
@triggered(0.1, debounce=True)
1297+
def log_event(message):
1298+
pass
1299+
1300+
To cancel a pending call::
1301+
1302+
sync_data.cancel()
1303+
1304+
.. versionadded:: 1.10.1
1305+
.. versionchanged:: 3.0.0
1306+
Fixed behavior for instance methods to ensure state isolation between
1307+
different objects. Added ``debounce`` parameter.
1308+
"""
1309+
fun = None
1310+
1311+
# handle both shorthand usage (@triggered)
1312+
# and parameterized usage (@triggered(0.1))
1313+
if callable(timeout):
1314+
fun = timeout
1315+
timeout = 0
1316+
1317+
def wrapper_triggered(func):
1318+
return _TriggeredWrapper(func, timeout, interval, debounce)
11531319

11541320
if fun is not None:
11551321
return wrapper_triggered(fun)

kivy/tests/conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
kivy_eventloop = os.environ.get('KIVY_EVENTLOOP', 'asyncio')
55

66
try:
7-
from .fixtures import kivy_app, kivy_clock, kivy_metrics, \
8-
kivy_exception_manager
7+
from .fixtures import kivy_app, kivy_clock, kivy_clock_advance, \
8+
kivy_metrics, kivy_exception_manager
99
except (SyntaxError, ImportError):
1010
# async app tests would be skipped due to async_run forcing it to skip so
1111
# it's ok to fail here as it won't be used anyway

kivy/tests/fixtures.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
import time
1818
import os.path
1919

20-
__all__ = ('kivy_clock', 'kivy_metrics', 'kivy_exception_manager', 'kivy_app',
20+
__all__ = ('kivy_clock', 'kivy_clock_advance', 'kivy_metrics',
21+
'kivy_exception_manager', 'kivy_app',
2122
'kivy_init')
2223

2324
@pytest.fixture()
@@ -91,6 +92,28 @@ def kivy_clock():
9192
context.pop()
9293

9394

95+
@pytest.fixture()
96+
def kivy_clock_advance(kivy_clock):
97+
"""A fixture that provides a helper to advance the Clock
98+
deterministically by mocking its time and ticking it.
99+
Usage:
100+
def test_foo(kivy_clock_advance):
101+
kivy_clock_advance(0.1) # advances the clock by 0.1s
102+
"""
103+
class ClockController:
104+
def __init__(self, clock):
105+
self.clock = clock
106+
self.now = 100.0
107+
clock.time = lambda: self.now
108+
clock._last_tick = self.now
109+
110+
def __call__(self, secs):
111+
self.now += secs
112+
self.clock.tick()
113+
114+
return ClockController(kivy_clock)
115+
116+
94117
@pytest.fixture()
95118
def kivy_metrics():
96119
from kivy.context import Context

0 commit comments

Comments
 (0)