holoviz / param

Param: Make your Python code clearer and more reliable by declaring Parameters
https://param.holoviz.org
BSD 3-Clause "New" or "Revised" License
412 stars 69 forks source link

Add `param.watch` function/decorator #806

Open maximlt opened 11 months ago

maximlt commented 11 months ago

I've long been thinking that it'd be nice for Param to have a param.watch function and decorator and finally decided it was time to write this down. I'm going to completely ignore any implementation discussion for now and whether what I'm asking for can actually be implemented. My two main asks for param.watch are:

  1. being able to register side-effecty callbacks with a top-level param.watch function, a la obj.param.watch
  2. in the scope of a Parameterized class being able to replace @param.depends(..., watch=True) with a decorator @param.watch(...)

being able to register side-effecty callbacks with a top-level param.watch function, a la obj.param.watch

Registering side-effecty callbacks outside of the scope of the Parameterized class can currently be done using @pn.depends(..., watch=True) or with pn.bind(func, ..., watch=True). In the Panel world we've been moving away from suggesting using @param.depends in favor of pn.bind. However, I feel that while pn.bind is a great API on its own that is easy to understand as modeled on functools.partial, pn.bind together with watch=True changes the nature of the API in an unexpected and hard to understand way. watch being a parameter of pn.bind also pollutes it. On the other hand in the context of a Parameterized class the low-level obj.param.watch is available and allows for the fine-grained control you sometimes need when dealing with callbacks, e.g. comparing the new and old values of a parameter, doing something when the value is set even if it's not changed.

I have observed there are somehow three callback signatures I wish I could use when creating a callback:

  1. I don't care at all about the event passed, e.g. for a callback triggered by a button click
  2. I only care about the new value(s), like how the top-level @param.depends decorator works currently by calling the callback only with the new values
  3. I care about the Event objects passed to the callback as I want to inspect them further

obj.param.watch is modeled on 3) which is the most powerful approach but also the one that requires the most code to handle the payload specially when you're watching more than one parameters, it's also more difficult to write tests for these callbacks:

obj.param.watch(cb, ['a', 'b'])

def cb(*events):
    # yuk
    for event in events:
        if event.name == 'a':
            ...
        elif event.name == 'b':
            pass

It'd be great if I could declare with param.watch what payload I'd like to get. I'm guessing the default could be 2) instead of 3) as being the more common case.

The other features of obj.param.watch (what, onlychanged, precedence, unwatch, etc.) would certainly also be appropriate for param.watch.

Using param.watch would look like:

def cb1(a, b):
    print(a, b)

watchers = param.watch(cb1, [widget_a.param.value, widget_b.param.value])

def cb2(*events):
    print(events)

watchers = param.watch(cb2, [widget_a.param.value, widget_b.param.value], mode='event')

in the scope of a Parameterized class being able to replace @param.depends(..., watch=True) with a decorator @param.watch(...)

@param.depends() being only declarative by default certainly causes confusion (for me at least when I got started), it's a bit strange that decorating a callback with a set of parameters to depend on isn't enough for the callback to be called on their changes. Of course this all makes sense when you realize this declaration is to be used by a library like Panel to set up its own update mechanisms. If you use Param on its own, that's definitely a weird default.

I can't count how many times I have forgotten to add watch=True and I have at least one large app in which every @param.depends decorator needs to have watch=True.

For all these reasons I'm interested in a more explicit param.watch decorator to be used in the scope of a Parameterized class. I think what I suggested above with the ability to select the signature/payload mode could also be nice to have.

class P(param.Parameterized):

    x = param.Number()

    @param.watch('x')
    def cb(self): pass
MarcSkovMadsen commented 4 months ago

I came to write a separate Feature Request for .watch functionality for Reactive Expressions.

Looking at https://param.holoviz.org/user_guide/Reactive_Expressions.html its not clear to me how I can use reactive expressions to trigger side effects.

I think param.watch could and should cover reactive expressions too.

param.watch(some_function, some_parameter, some_reactive_expression)

UPDATE

Reactive functions actually have the .watch method as below.

import panel as pn

pn.extension()

is_stopped=pn.rx(True)

def name(stopped):
    if stopped:
        return "Start the wind turbine"
    else:
        return "Stop the wind turbine"

rx_name = pn.rx(name)(is_stopped)

submit = pn.widgets.Button(name=rx_name)

def start_stop_wind_turbine(clicked):
    print("running action")
    is_stopped.rx.value = not is_stopped.rx.value

b_stop_wind_turbine = submit.rx.watch(start_stop_wind_turbine)

pn.Column(submit, b_stop_wind_turbine).servable()