Skip to content

Commit 1fbe7c6

Browse files
Add pause, resume and stop methods to Watcher (#174)
While a watcher is paused, changes to its dependencies do not trigger a re-evaluation or callback. If a dependency changed while the watcher was paused, then the watcher triggers once upon resume, matching the behavior of Vue's watch API. The stop method is a named equivalent of calling the watcher object directly. Fixes #150 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 0c21a21 commit 1fbe7c6

3 files changed

Lines changed: 194 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Observe nested structures of dicts, lists, tuples and sets. Returns an observabl
2727

2828
* `watcher = watch(func, callback, deep=False, immediate=False)`
2929

30-
React to changes in the state accessed in `func` with `callback(old_value, new_value)`. Returns a watcher object. `del`elete it to disable the callback.
30+
React to changes in the state accessed in `func` with `callback(old_value, new_value)`. Returns a watcher object. `del`elete it to disable the callback. Use `watcher.pause()` and `watcher.resume()` to temporarily suspend the watcher: if a dependency changed while the watcher was paused, it triggers once upon resume. Call `watcher.stop()` (or the watcher object itself) to stop it permanently.
3131

3232
* `wrapped_func = computed(func)`
3333

observ/watcher.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ class Watcher(Generic[T]):
174174
"_deps",
175175
"_new_deps",
176176
"_number_of_callback_args",
177+
"_paused",
178+
"_pending_update",
177179
"_tasks",
178180
"callback",
179181
"callback_async",
@@ -206,6 +208,8 @@ def __init__(
206208
"""
207209
self.id = next(_ids)
208210
self._active = True
211+
self._paused = False
212+
self._pending_update = False
209213
if callable(fn):
210214
if is_bound_method(fn):
211215
self.fn = weak(fn.__self__, fn.__func__)
@@ -254,7 +258,16 @@ def __call__(self):
254258
any used resource. This can be used when special
255259
life-cycle management is needed for watchers.
256260
"""
261+
self.stop()
262+
263+
def stop(self) -> None:
264+
"""
265+
Stop the watcher and clean up any used resource.
266+
Equivalent to calling the watcher object directly.
267+
"""
257268
self._active = False
269+
self._paused = False
270+
self._pending_update = False
258271

259272
# Clear resources
260273
self.fn = lambda: ()
@@ -265,6 +278,27 @@ def __call__(self):
265278
self._deps.clear()
266279
self._new_deps.clear()
267280

281+
def pause(self) -> None:
282+
"""
283+
Temporarily pause the watcher: while paused, changes to
284+
dependencies will not trigger a re-evaluation or callback.
285+
Resume the watcher with `resume()`.
286+
"""
287+
self._paused = True
288+
289+
def resume(self) -> None:
290+
"""
291+
Resume the watcher after it was paused. If any of its
292+
dependencies changed while the watcher was paused, it
293+
will trigger once upon resume.
294+
"""
295+
if not self._paused:
296+
return
297+
self._paused = False
298+
if self._pending_update:
299+
self._pending_update = False
300+
self.update()
301+
268302
def __del__(self):
269303
if Watcher.on_destroyed:
270304
Watcher.on_destroyed(self)
@@ -277,7 +311,19 @@ def active(self):
277311
"""
278312
return self._active
279313

314+
@property
315+
def paused(self):
316+
"""
317+
Returns whether this watcher is currently paused.
318+
Use `pause()` and `resume()` to control this state.
319+
"""
320+
return self._paused
321+
280322
def update(self) -> None:
323+
if self._paused:
324+
self._pending_update = True
325+
return
326+
281327
if self.lazy:
282328
self.dirty = True
283329
return
@@ -298,6 +344,11 @@ def run(self) -> None:
298344
# Early return for when the watcher has been deactivated
299345
if not self._active:
300346
return
347+
# A watcher that was queued before it was paused should
348+
# not run until it is resumed
349+
if self._paused:
350+
self._pending_update = True
351+
return
301352
value = self.get()
302353
if self.deep or isinstance(value, Container) or value != self.value:
303354
old_value = self.value

tests/test_watcher.py

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from unittest.mock import Mock
22

3-
from observ import reactive, scheduler, watch
3+
from observ import computed, reactive, scheduler, watch
44

55

66
def test_watcher_active(noop_request_flush):
@@ -85,3 +85,144 @@ def create_watchers(new):
8585
# watcher.callback = None
8686

8787
assert callback_args == [0]
88+
89+
90+
def test_watcher_stop(noop_request_flush):
91+
a = reactive({"foo": "bar"})
92+
callback = Mock()
93+
94+
watcher = watch(lambda: a["foo"], callback)
95+
96+
assert watcher.active
97+
98+
watcher.stop()
99+
100+
assert not watcher.active
101+
102+
a["foo"] = "baz"
103+
scheduler.flush()
104+
105+
callback.assert_not_called()
106+
107+
108+
def test_watcher_pause_resume(noop_request_flush):
109+
a = reactive({"foo": "bar"})
110+
callback = Mock()
111+
112+
watcher = watch(lambda: a["foo"], callback)
113+
114+
assert not watcher.paused
115+
116+
watcher.pause()
117+
118+
assert watcher.paused
119+
120+
a["foo"] = "baz"
121+
a["foo"] = "qux"
122+
scheduler.flush()
123+
124+
# No callback while paused
125+
callback.assert_not_called()
126+
127+
# Changes during the pause trigger the watcher once upon resume
128+
watcher.resume()
129+
130+
assert not watcher.paused
131+
132+
scheduler.flush()
133+
callback.assert_called_once_with("qux")
134+
135+
136+
def test_watcher_resume_without_changes(noop_request_flush):
137+
a = reactive({"foo": "bar"})
138+
callback = Mock()
139+
140+
watcher = watch(lambda: a["foo"], callback)
141+
142+
watcher.pause()
143+
watcher.resume()
144+
scheduler.flush()
145+
146+
callback.assert_not_called()
147+
148+
# Resuming a watcher that is not paused is a no-op
149+
watcher.resume()
150+
scheduler.flush()
151+
152+
callback.assert_not_called()
153+
154+
155+
def test_watcher_pause_resume_sync(noop_request_flush):
156+
a = reactive({"foo": "bar"})
157+
callback = Mock()
158+
159+
watcher = watch(lambda: a["foo"], callback, sync=True)
160+
161+
watcher.pause()
162+
163+
a["foo"] = "baz"
164+
165+
callback.assert_not_called()
166+
167+
# Sync watchers trigger immediately on resume
168+
watcher.resume()
169+
170+
callback.assert_called_once_with("baz")
171+
172+
173+
def test_watcher_pause_while_queued(noop_request_flush):
174+
a = reactive({"foo": "bar"})
175+
callback = Mock()
176+
177+
watcher = watch(lambda: a["foo"], callback)
178+
179+
# Queue the watcher before pausing it
180+
a["foo"] = "baz"
181+
watcher.pause()
182+
scheduler.flush()
183+
184+
callback.assert_not_called()
185+
186+
watcher.resume()
187+
scheduler.flush()
188+
189+
callback.assert_called_once_with("baz")
190+
191+
192+
def test_watcher_stop_while_paused(noop_request_flush):
193+
a = reactive({"foo": "bar"})
194+
callback = Mock()
195+
196+
watcher = watch(lambda: a["foo"], callback)
197+
198+
watcher.pause()
199+
a["foo"] = "baz"
200+
watcher.stop()
201+
202+
assert not watcher.paused
203+
204+
watcher.resume()
205+
scheduler.flush()
206+
207+
callback.assert_not_called()
208+
209+
210+
def test_computed_pause_resume(noop_request_flush):
211+
a = reactive({"count": 1})
212+
213+
@computed
214+
def double():
215+
return a["count"] * 2
216+
217+
assert double() == 2
218+
219+
double.__watcher__.pause()
220+
221+
a["count"] = 2
222+
223+
# While paused, the computed expression is not invalidated
224+
assert double() == 2
225+
226+
double.__watcher__.resume()
227+
228+
assert double() == 4

0 commit comments

Comments
 (0)