Skip to content
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 our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

External watch fix init #4030

Merged
merged 12 commits into from
Feb 8, 2024
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased

### Fixed

- Fixed duplicate watch methods being attached to DOM nodes https://github.com/Textualize/textual/pull/4030
- Fixed using `watch` to create additional watchers would trigger other watch methods https://github.com/Textualize/textual/issues/3878

## [0.49.0] - 2024-02-07

### Fixed
Expand Down
22 changes: 17 additions & 5 deletions src/textual/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,24 @@ class UnusedParameter:
"""Type used for arbitrary callables used in callbacks."""
IgnoreReturnCallbackType = Union[Callable[[], Awaitable[Any]], Callable[[], Any]]
"""A callback which ignores the return type."""
WatchCallbackType = Union[
Callable[[], Awaitable[None]],
Callable[[Any], Awaitable[None]],
WatchCallbackBothValuesType = Union[
Callable[[Any, Any], Awaitable[None]],
Callable[[], None],
Callable[[Any], None],
Callable[[Any, Any], None],
]
"""Type for watch methods that accept the old and new values of reactive objects."""
WatchCallbackNewValueType = Union[
Callable[[Any], Awaitable[None]],
Callable[[Any], None],
]
"""Type for watch methods that accept only the new value of reactive objects."""
WatchCallbackNoArgsType = Union[
Callable[[], Awaitable[None]],
Callable[[], None],
]
"""Type for watch methods that do not require the explicit value of the reactive."""
WatchCallbackType = Union[
WatchCallbackBothValuesType,
WatchCallbackNewValueType,
WatchCallbackNoArgsType,
]
"""Type used for callbacks passed to the `watch` method of widgets."""
93 changes: 53 additions & 40 deletions src/textual/reactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,21 @@
Generic,
Type,
TypeVar,
cast,
overload,
)

import rich.repr

from . import events
from ._callback import count_parameters
from ._types import MessageTarget, WatchCallbackType
from ._types import (
MessageTarget,
WatchCallbackBothValuesType,
WatchCallbackNewValueType,
WatchCallbackNoArgsType,
WatchCallbackType,
)

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


async def await_watcher(obj: Reactable, awaitable: Awaitable[object]) -> None:
"""Coroutine to await an awaitable returned from a watcher"""
_rich_traceback_omit = True
await awaitable
# Watcher may have changed the state, so run compute again
obj.post_message(events.Callback(callback=partial(Reactive._compute, obj)))


def invoke_watcher(
watcher_object: Reactable,
watch_function: WatchCallbackType,
old_value: object,
value: object,
) -> None:
"""Invoke a watch function.

Args:
watcher_object: The object watching for the changes.
watch_function: A watch function, which may be sync or async.
old_value: The old value of the attribute.
value: The new value of the attribute.
"""
_rich_traceback_omit = True
param_count = count_parameters(watch_function)
if param_count == 2:
watch_result = cast(WatchCallbackBothValuesType, watch_function)(
old_value, value
)
elif param_count == 1:
watch_result = cast(WatchCallbackNewValueType, watch_function)(value)
else:
watch_result = cast(WatchCallbackNoArgsType, watch_function)()
darrenburns marked this conversation as resolved.
Show resolved Hide resolved
if isawaitable(watch_result):
# Result is awaitable, so we need to await it within an async context
watcher_object.call_next(partial(await_watcher, watcher_object, watch_result))


@rich.repr.auto
class Reactive(Generic[ReactiveType]):
"""Reactive descriptor.
Expand Down Expand Up @@ -239,7 +283,7 @@ def __set__(self, obj: Reactable, value: ReactiveType) -> None:
obj.refresh(repaint=self._repaint, layout=self._layout)

@classmethod
def _check_watchers(cls, obj: Reactable, name: str, old_value: Any):
def _check_watchers(cls, obj: Reactable, name: str, old_value: Any) -> None:
"""Check watchers, and call watch methods / computes

Args:
Expand All @@ -252,39 +296,6 @@ def _check_watchers(cls, obj: Reactable, name: str, old_value: Any):
internal_name = f"_reactive_{name}"
value = getattr(obj, internal_name)

async def await_watcher(awaitable: Awaitable) -> None:
"""Coroutine to await an awaitable returned from a watcher"""
_rich_traceback_omit = True
await awaitable
# Watcher may have changed the state, so run compute again
obj.post_message(events.Callback(callback=partial(Reactive._compute, obj)))

def invoke_watcher(
watcher_object: Reactable,
watch_function: Callable,
old_value: object,
value: object,
) -> None:
"""Invoke a watch function.

Args:
watcher_object: The object watching for the changes.
watch_function: A watch function, which may be sync or async.
old_value: The old value of the attribute.
value: The new value of the attribute.
"""
_rich_traceback_omit = True
param_count = count_parameters(watch_function)
if param_count == 2:
watch_result = watch_function(old_value, value)
elif param_count == 1:
watch_result = watch_function(value)
else:
watch_result = watch_function()
if isawaitable(watch_result):
# Result is awaitable, so we need to await it within an async context
watcher_object.call_next(partial(await_watcher, watch_result))

private_watch_function = getattr(obj, f"_watch_{name}", None)
if callable(private_watch_function):
invoke_watcher(obj, private_watch_function, old_value, value)
Expand All @@ -294,7 +305,7 @@ def invoke_watcher(
invoke_watcher(obj, public_watch_function, old_value, value)

# Process "global" watchers
watchers: list[tuple[Reactable, Callable]]
watchers: list[tuple[Reactable, WatchCallbackType]]
watchers = getattr(obj, "__watchers", {}).get(name, [])
# Remove any watchers for reactables that have since closed
if watchers:
Expand Down Expand Up @@ -404,11 +415,13 @@ def _watch(
"""
if not hasattr(obj, "__watchers"):
setattr(obj, "__watchers", {})
watchers: dict[str, list[tuple[Reactable, Callable]]] = getattr(obj, "__watchers")
watchers: dict[str, list[tuple[Reactable, WatchCallbackType]]] = getattr(
obj, "__watchers"
)
watcher_list = watchers.setdefault(attribute_name, [])
if callback in watcher_list:
if any(callback == callback_from_list for _, callback_from_list in watcher_list):
Comment on lines -409 to +422
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was checking if it could find an apple in a list of tuples of oranges and apples.
Instead, we must unpack the tuples in the list and compare apples to apples.

return
watcher_list.append((node, callback))
if init:
current_value = getattr(obj, attribute_name, None)
Reactive._check_watchers(obj, attribute_name, current_value)
invoke_watcher(obj, callback, current_value, current_value)
watcher_list.append((node, callback))
107 changes: 107 additions & 0 deletions tests/test_reactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -598,3 +598,110 @@ def compose(self) -> ComposeResult:
app = MyApp()
async with app.run_test():
assert app.query_one(MyWidget).foo == "foobar"


async def test_no_duplicate_external_watchers() -> None:
"""Make sure we skip duplicated watchers."""

counter = 0

class Holder(Widget):
attr = var(None)

class MyApp(App[None]):
def __init__(self) -> None:
super().__init__()
self.holder = Holder()

def on_mount(self) -> None:
self.watch(self.holder, "attr", self.callback)
Copy link
Member

@darrenburns darrenburns Feb 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test name/docstring says it's checking that we skip duplicate watchers, but the test no longer involves a duplicate watcher. It seems to just be checking the basic usage of watch, which is probably covered by another test.

I reckon this should be updated such that are two watchers defined with self.watch(self.holder, "attr", self.callback), and checking that the self.callback fires only once on init, and once again on setting the value of attr.

Perhaps more importantly, another test should probably cover this case: If I define one watcher as self.watch(self.holder, "attr", self.first_callback) and then another with self.watch(self.holder, "attr", self.second_callback). Do I see one run of self.first_callback followed by one run of self.second_callback on init? Then when I assign attr, do I see the another one run of each callback in the correct order?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As for the first two paragraphs, I think I deleted the second callback when I was debugging the bug I mentioned in a previous commit and then forgot to add it again, thanks for spotting that...

As for the third paragraph... Do you mean something like the test test_external_watch_init_does_not_propagate_to_externals added in 8b349e3?

self.watch(self.holder, "attr", self.callback)

def callback(self) -> None:
nonlocal counter
counter += 1

app = MyApp()
async with app.run_test():
assert counter == 1
app.holder.attr = 73
assert counter == 2


async def test_external_watch_init_does_not_propagate() -> None:
"""Regression test for https://github.com/Textualize/textual/issues/3878.

Make sure that when setting an extra watcher programmatically and `init` is set,
we init only the new watcher and not the other ones, but at the same
time make sure both watchers work in regular circumstances.
"""

logs: list[str] = []

class SomeWidget(Widget):
test_1: var[int] = var(0)
test_2: var[int] = var(0, init=False)

def watch_test_1(self) -> None:
logs.append("test_1")

def watch_test_2(self) -> None:
logs.append("test_2")

class InitOverrideApp(App[None]):
def compose(self) -> ComposeResult:
yield SomeWidget()

def on_mount(self) -> None:
def watch_test_2_extra() -> None:
logs.append("test_2_extra")

self.watch(self.query_one(SomeWidget), "test_2", watch_test_2_extra)

app = InitOverrideApp()
async with app.run_test():
assert logs == ["test_1", "test_2_extra"]
app.query_one(SomeWidget).test_2 = 73
assert logs.count("test_2_extra") == 2
assert logs.count("test_2") == 1


async def test_external_watch_init_does_not_propagate_to_externals() -> None:
"""Regression test for https://github.com/Textualize/textual/issues/3878.

Make sure that when setting an extra watcher programmatically and `init` is set,
we init only the new watcher and not the other ones (even if they were
added dynamically with `watch`), but at the same time make sure all watchers
work in regular circumstances.
"""

logs: list[str] = []

class SomeWidget(Widget):
test_var: var[int] = var(0)

class MyApp(App[None]):
def compose(self) -> ComposeResult:
yield SomeWidget()

def add_first_watcher(self) -> None:
def first_callback() -> None:
logs.append("first")

self.watch(self.query_one(SomeWidget), "test_var", first_callback)

def add_second_watcher(self) -> None:
def second_callback() -> None:
logs.append("second")

self.watch(self.query_one(SomeWidget), "test_var", second_callback)

app = MyApp()
async with app.run_test():
assert logs == []
app.add_first_watcher()
assert logs == ["first"]
app.add_second_watcher()
assert logs == ["first", "second"]
app.query_one(SomeWidget).test_var = 73
assert logs == ["first", "second", "first", "second"]
Loading