-
Notifications
You must be signed in to change notification settings - Fork 788
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
Decorator madness #4205
base: main
Are you sure you want to change the base?
Decorator madness #4205
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm excited for these changes but I think there are a couple of loose ends we must tie before merging.
tests/test_reactive.py
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We're missing, at least, a test that makes sure that init=False
in the watch decorator is respected.
Co-authored-by: Rodrigo Girão Serrão <[email protected]>
Co-authored-by: Rodrigo Girão Serrão <[email protected]>
Co-authored-by: Rodrigo Girão Serrão <[email protected]>
Co-authored-by: Rodrigo Girão Serrão <[email protected]>
Co-authored-by: Rodrigo Girão Serrão <[email protected]>
Co-authored-by: Rodrigo Girão Serrão <[email protected]>
Co-authored-by: Rodrigo Girão Serrão <[email protected]>
@@ -163,6 +163,31 @@ A common use for this is to restrict numbers to a given range. The following exa | |||
|
|||
If you click the buttons in the above example it will show the current count. When `self.count` is modified in the button handler, Textual runs `validate_count` which performs the validation to limit the value of count. | |||
|
|||
### Validate decorator | |||
|
|||
In addition to the the naming convention, you can also define a validate method via a decorator. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In addition to the the naming convention, you can also define a validate method via a decorator. | |
In addition to the naming convention, you can also define a validate method via a decorator. |
@@ -311,15 +354,15 @@ Compute methods are the final superpower offered by the `reactive` descriptor. T | |||
|
|||
You could be forgiven in thinking this sounds a lot like Python's property decorator. The difference is that Textual will cache the value of compute methods, and update them when any other reactive attribute changes. | |||
|
|||
The following example uses a computed attribute. It displays three inputs for each color component (red, green, and blue). If you enter numbers in to these inputs, the background color of another widget changes. | |||
The following example uses a computed attribute. It displays three inputs for each color component (red, green, and blue). If you enter numbers into these inputs, the background color of another widget changes. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The following example uses a computed attribute. It displays three inputs for each color component (red, green, and blue). If you enter numbers into these inputs, the background color of another widget changes. | |
The following example uses a computed attribute. It displays one input for each color component (red, green, and blue). If you enter numbers into these inputs, the background color of another widget changes. |
The following example uses a computed attribute. It displays three inputs for each color component (red, green, and blue). If you enter numbers into these inputs, the background color of another widget changes. | |
The following example uses a computed attribute. It displays three inputs, one for each color component (red, green, and blue). If you enter numbers into these inputs, the background color of another widget changes. |
### Compute decorator | ||
|
||
Compute methods may also be defined by the `compute` decorator on reactives. | ||
The following examples replaces the naming convention with an equivalent decorator: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The following examples replaces the naming convention with an equivalent decorator: | |
The following example replaces the naming convention with an equivalent decorator: |
@@ -50,6 +53,108 @@ class TooManyComputesError(ReactiveError): | |||
"""Raised when an attribute has public and private compute methods.""" | |||
|
|||
|
|||
class WatchDecorator(Generic[WatchMethodType]): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I do have a suggestion.
The decorators (Watch / Compute / Validate) shouldn't be generic over the type of the method they're decorating; rather, they should be generic over the type of the reactive involved because that's what can really change.
For example, for my compute methods, I know I'll always be decorating methods with type Callable[[Any], ReactiveType]
.
So, what maybe I can have is an alias for compute methods and then use that.
The suggestion below seems to eliminate an error you get with make typecheck
and it also gets rid of some IDE errors I get in several places.
However, I'm not 100% sure this is better.
It feels better, but I wouldn't bet my life on it.
# ...
ComputeMethodAlias: TypeAlias = Callable[[Any], ReactiveType] # <--
# ...
class ComputeDecorator(Generic[ReactiveType]): # <--
def __init__(self, reactive: Reactive[ReactiveType] | None = None) -> None: # <-- better typing here
self._reactive = reactive
@overload
def __call__(self) -> ComputeDecorator[ReactiveType]: ... # <--
@overload
def __call__(
self, method: ComputeMethodAlias[ReactiveType] # <--
) -> ComputeMethodAlias[ReactiveType]: ... # <--
def __call__(
self, method: ComputeMethodAlias[ReactiveType] | None = None # <--
) -> ComputeMethodAlias[ReactiveType] | ComputeDecorator[ReactiveType]: # <--
# ...
# ...
@rich.repr.auto
class Reactive(Generic[ReactiveType]):
# ...
def __init__(...):
# ...
self._compute_method: ComputeMethodAlias[ReactiveType] | None = None # <-- better typing
# ...
@property
def compute(self) -> ComputeDecorator[ReactiveType]: # <--
# ...
|
||
watcher_call_count = 0 | ||
|
||
@count.watch() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It was very difficult for me to spot the difference.
Would it be worth adding a comment # <--
to make the diff obvious so that no one deletes this test in the future by mistake?
|
||
|
||
async def test_watch_decorator_init_true(): | ||
"""Test watchers defined via a decorator, that is called.""" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"""Test watchers defined via a decorator, that is called.""" | |
"""Test watchers defined via a decorator, that is called, when the reactive has init set.""" |
With this change, how would we override what a watcher does in a widget subclass? If I have a For example, this fails to run because class Parent(Widget):
count: Reactive[int] = reactive(0, init=False)
@count.watch
def _count_parent(self, new_value: int) -> None:
print("parent watcher")
class Child(Parent):
@count.watch
def _count_parent(self, new_value: int) -> None:
print("child watcher")
Even if that's solved, there's still a bit of a developer experience issue that @davep mentioned, whereby we no longer know what the parent watch method is called without going looking for it. |
Going to park this for now. @darrenburns 's observation re inheritance may kill this idea entirely. |
@willmcgugan Wondering if we should close this? |
Think I'll keep it around for a bit. Would like to give it another pass at some point. |
You can work around the class M(type):
@classmethod
def __prepare__(cls, name, bases, **kwargs):
for base in bases:
if base.__name__ == 'A':
return dict(a=base.a)
return super().__prepare__(name, bases, **kwargs)
class A(metaclass=M):
a=1
class B(A):
print(a) will print out |
Decorators for watch, compute, and validate. Works like this: