Skip to content

Commit 718f698

Browse files
authored
Merge branch 'main' into ansi-fix
2 parents 91e253e + 7c7dec0 commit 718f698

File tree

4 files changed

+179
-45
lines changed

4 files changed

+179
-45
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1010
### Fixed
1111

1212
- Fixed issue with ANSI colors not being converted to truecolor https://github.com/Textualize/textual/pull/4138
13+
- Fixed duplicate watch methods being attached to DOM nodes https://github.com/Textualize/textual/pull/4030
14+
- Fixed using `watch` to create additional watchers would trigger other watch methods https://github.com/Textualize/textual/issues/3878
1315

1416
## [0.49.0] - 2024-02-07
1517

src/textual/_types.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,24 @@ class UnusedParameter:
3131
"""Type used for arbitrary callables used in callbacks."""
3232
IgnoreReturnCallbackType = Union[Callable[[], Awaitable[Any]], Callable[[], Any]]
3333
"""A callback which ignores the return type."""
34-
WatchCallbackType = Union[
35-
Callable[[], Awaitable[None]],
36-
Callable[[Any], Awaitable[None]],
34+
WatchCallbackBothValuesType = Union[
3735
Callable[[Any, Any], Awaitable[None]],
38-
Callable[[], None],
39-
Callable[[Any], None],
4036
Callable[[Any, Any], None],
4137
]
38+
"""Type for watch methods that accept the old and new values of reactive objects."""
39+
WatchCallbackNewValueType = Union[
40+
Callable[[Any], Awaitable[None]],
41+
Callable[[Any], None],
42+
]
43+
"""Type for watch methods that accept only the new value of reactive objects."""
44+
WatchCallbackNoArgsType = Union[
45+
Callable[[], Awaitable[None]],
46+
Callable[[], None],
47+
]
48+
"""Type for watch methods that do not require the explicit value of the reactive."""
49+
WatchCallbackType = Union[
50+
WatchCallbackBothValuesType,
51+
WatchCallbackNewValueType,
52+
WatchCallbackNoArgsType,
53+
]
4254
"""Type used for callbacks passed to the `watch` method of widgets."""

src/textual/reactive.py

Lines changed: 53 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,21 @@
1616
Generic,
1717
Type,
1818
TypeVar,
19+
cast,
1920
overload,
2021
)
2122

2223
import rich.repr
2324

2425
from . import events
2526
from ._callback import count_parameters
26-
from ._types import MessageTarget, WatchCallbackType
27+
from ._types import (
28+
MessageTarget,
29+
WatchCallbackBothValuesType,
30+
WatchCallbackNewValueType,
31+
WatchCallbackNoArgsType,
32+
WatchCallbackType,
33+
)
2734

2835
if TYPE_CHECKING:
2936
from .dom import DOMNode
@@ -42,6 +49,43 @@ class TooManyComputesError(ReactiveError):
4249
"""Raised when an attribute has public and private compute methods."""
4350

4451

52+
async def await_watcher(obj: Reactable, awaitable: Awaitable[object]) -> None:
53+
"""Coroutine to await an awaitable returned from a watcher"""
54+
_rich_traceback_omit = True
55+
await awaitable
56+
# Watcher may have changed the state, so run compute again
57+
obj.post_message(events.Callback(callback=partial(Reactive._compute, obj)))
58+
59+
60+
def invoke_watcher(
61+
watcher_object: Reactable,
62+
watch_function: WatchCallbackType,
63+
old_value: object,
64+
value: object,
65+
) -> None:
66+
"""Invoke a watch function.
67+
68+
Args:
69+
watcher_object: The object watching for the changes.
70+
watch_function: A watch function, which may be sync or async.
71+
old_value: The old value of the attribute.
72+
value: The new value of the attribute.
73+
"""
74+
_rich_traceback_omit = True
75+
param_count = count_parameters(watch_function)
76+
if param_count == 2:
77+
watch_result = cast(WatchCallbackBothValuesType, watch_function)(
78+
old_value, value
79+
)
80+
elif param_count == 1:
81+
watch_result = cast(WatchCallbackNewValueType, watch_function)(value)
82+
else:
83+
watch_result = cast(WatchCallbackNoArgsType, watch_function)()
84+
if isawaitable(watch_result):
85+
# Result is awaitable, so we need to await it within an async context
86+
watcher_object.call_next(partial(await_watcher, watcher_object, watch_result))
87+
88+
4589
@rich.repr.auto
4690
class Reactive(Generic[ReactiveType]):
4791
"""Reactive descriptor.
@@ -239,7 +283,7 @@ def __set__(self, obj: Reactable, value: ReactiveType) -> None:
239283
obj.refresh(repaint=self._repaint, layout=self._layout)
240284

241285
@classmethod
242-
def _check_watchers(cls, obj: Reactable, name: str, old_value: Any):
286+
def _check_watchers(cls, obj: Reactable, name: str, old_value: Any) -> None:
243287
"""Check watchers, and call watch methods / computes
244288
245289
Args:
@@ -252,39 +296,6 @@ def _check_watchers(cls, obj: Reactable, name: str, old_value: Any):
252296
internal_name = f"_reactive_{name}"
253297
value = getattr(obj, internal_name)
254298

255-
async def await_watcher(awaitable: Awaitable) -> None:
256-
"""Coroutine to await an awaitable returned from a watcher"""
257-
_rich_traceback_omit = True
258-
await awaitable
259-
# Watcher may have changed the state, so run compute again
260-
obj.post_message(events.Callback(callback=partial(Reactive._compute, obj)))
261-
262-
def invoke_watcher(
263-
watcher_object: Reactable,
264-
watch_function: Callable,
265-
old_value: object,
266-
value: object,
267-
) -> None:
268-
"""Invoke a watch function.
269-
270-
Args:
271-
watcher_object: The object watching for the changes.
272-
watch_function: A watch function, which may be sync or async.
273-
old_value: The old value of the attribute.
274-
value: The new value of the attribute.
275-
"""
276-
_rich_traceback_omit = True
277-
param_count = count_parameters(watch_function)
278-
if param_count == 2:
279-
watch_result = watch_function(old_value, value)
280-
elif param_count == 1:
281-
watch_result = watch_function(value)
282-
else:
283-
watch_result = watch_function()
284-
if isawaitable(watch_result):
285-
# Result is awaitable, so we need to await it within an async context
286-
watcher_object.call_next(partial(await_watcher, watch_result))
287-
288299
private_watch_function = getattr(obj, f"_watch_{name}", None)
289300
if callable(private_watch_function):
290301
invoke_watcher(obj, private_watch_function, old_value, value)
@@ -294,7 +305,7 @@ def invoke_watcher(
294305
invoke_watcher(obj, public_watch_function, old_value, value)
295306

296307
# Process "global" watchers
297-
watchers: list[tuple[Reactable, Callable]]
308+
watchers: list[tuple[Reactable, WatchCallbackType]]
298309
watchers = getattr(obj, "__watchers", {}).get(name, [])
299310
# Remove any watchers for reactables that have since closed
300311
if watchers:
@@ -404,11 +415,13 @@ def _watch(
404415
"""
405416
if not hasattr(obj, "__watchers"):
406417
setattr(obj, "__watchers", {})
407-
watchers: dict[str, list[tuple[Reactable, Callable]]] = getattr(obj, "__watchers")
418+
watchers: dict[str, list[tuple[Reactable, WatchCallbackType]]] = getattr(
419+
obj, "__watchers"
420+
)
408421
watcher_list = watchers.setdefault(attribute_name, [])
409-
if callback in watcher_list:
422+
if any(callback == callback_from_list for _, callback_from_list in watcher_list):
410423
return
411-
watcher_list.append((node, callback))
412424
if init:
413425
current_value = getattr(obj, attribute_name, None)
414-
Reactive._check_watchers(obj, attribute_name, current_value)
426+
invoke_watcher(obj, callback, current_value, current_value)
427+
watcher_list.append((node, callback))

tests/test_reactive.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,3 +598,110 @@ def compose(self) -> ComposeResult:
598598
app = MyApp()
599599
async with app.run_test():
600600
assert app.query_one(MyWidget).foo == "foobar"
601+
602+
603+
async def test_no_duplicate_external_watchers() -> None:
604+
"""Make sure we skip duplicated watchers."""
605+
606+
counter = 0
607+
608+
class Holder(Widget):
609+
attr = var(None)
610+
611+
class MyApp(App[None]):
612+
def __init__(self) -> None:
613+
super().__init__()
614+
self.holder = Holder()
615+
616+
def on_mount(self) -> None:
617+
self.watch(self.holder, "attr", self.callback)
618+
self.watch(self.holder, "attr", self.callback)
619+
620+
def callback(self) -> None:
621+
nonlocal counter
622+
counter += 1
623+
624+
app = MyApp()
625+
async with app.run_test():
626+
assert counter == 1
627+
app.holder.attr = 73
628+
assert counter == 2
629+
630+
631+
async def test_external_watch_init_does_not_propagate() -> None:
632+
"""Regression test for https://github.com/Textualize/textual/issues/3878.
633+
634+
Make sure that when setting an extra watcher programmatically and `init` is set,
635+
we init only the new watcher and not the other ones, but at the same
636+
time make sure both watchers work in regular circumstances.
637+
"""
638+
639+
logs: list[str] = []
640+
641+
class SomeWidget(Widget):
642+
test_1: var[int] = var(0)
643+
test_2: var[int] = var(0, init=False)
644+
645+
def watch_test_1(self) -> None:
646+
logs.append("test_1")
647+
648+
def watch_test_2(self) -> None:
649+
logs.append("test_2")
650+
651+
class InitOverrideApp(App[None]):
652+
def compose(self) -> ComposeResult:
653+
yield SomeWidget()
654+
655+
def on_mount(self) -> None:
656+
def watch_test_2_extra() -> None:
657+
logs.append("test_2_extra")
658+
659+
self.watch(self.query_one(SomeWidget), "test_2", watch_test_2_extra)
660+
661+
app = InitOverrideApp()
662+
async with app.run_test():
663+
assert logs == ["test_1", "test_2_extra"]
664+
app.query_one(SomeWidget).test_2 = 73
665+
assert logs.count("test_2_extra") == 2
666+
assert logs.count("test_2") == 1
667+
668+
669+
async def test_external_watch_init_does_not_propagate_to_externals() -> None:
670+
"""Regression test for https://github.com/Textualize/textual/issues/3878.
671+
672+
Make sure that when setting an extra watcher programmatically and `init` is set,
673+
we init only the new watcher and not the other ones (even if they were
674+
added dynamically with `watch`), but at the same time make sure all watchers
675+
work in regular circumstances.
676+
"""
677+
678+
logs: list[str] = []
679+
680+
class SomeWidget(Widget):
681+
test_var: var[int] = var(0)
682+
683+
class MyApp(App[None]):
684+
def compose(self) -> ComposeResult:
685+
yield SomeWidget()
686+
687+
def add_first_watcher(self) -> None:
688+
def first_callback() -> None:
689+
logs.append("first")
690+
691+
self.watch(self.query_one(SomeWidget), "test_var", first_callback)
692+
693+
def add_second_watcher(self) -> None:
694+
def second_callback() -> None:
695+
logs.append("second")
696+
697+
self.watch(self.query_one(SomeWidget), "test_var", second_callback)
698+
699+
app = MyApp()
700+
async with app.run_test():
701+
assert logs == []
702+
app.add_first_watcher()
703+
assert logs == ["first"]
704+
app.add_second_watcher()
705+
assert logs == ["first", "second"]
706+
app.query_one(SomeWidget).test_var = 73
707+
assert logs == ["first", "second", "first", "second"]

0 commit comments

Comments
 (0)