One callback called twice instead of two callbacks once

One of the things I needed in my projects was the (hopefully still under active development) “dynamic depends” (GH branch), so I’ve tried to implement it using param.watch and callbacks managing Watchers of the outer and inner (nested) Parameters. Just to be clear, by “dynamic depends” I mean being able to watch instance.param.nested_param and keep it working after instance.param’s value changes.

I ran into two problems.
First: Watchers’ fns get called as mentioned in the topic.
Second: Unwatching nested parameters doesn’t work (either by event.old.param.unwatch or watcher.inst.param.unwatch(watcher))

Below is simplified code presenting the first issue, that I’d like to focus on:

import inspect

import param as pm


R"""
1. add_info attribute to selected method using decorator notation
2. use_info attribute in Parameterized __init__ for each object with
added _info
    - create watcher closure
    - define nested event handler for watching/unwatching selected parameter
3. call event_handler once to set up initial watcher
4. change watched parameter's values
5. observe first callback fired twice???

_param_watchers hold reference to correct callbacks
event_handler closure contains correct references as well

"""


def add_info(param_attr_name):

    """Adds _info attribute to wrapped method.

    No other side effects. Returns the same object.
    """

    def get_fn_with_info(fn):
        fn._info = dict(param_attr_name=param_attr_name)
        return fn

    return get_fn_with_info


def use_info(owner, callback, param_attr_name):

    R"""Creates a watcher configured with _info attribute of wrapped method.

    The wrapped method itself is to be used as a callback function for nested param changes
    in dynamic depends scenario. Unused here.
    """

    watcher = None

    def event_handler(event=None):
        nonlocal watcher
        if event is None:
            owner.message(f'Installing: {callback.__name__}')
        else:

            owner.message(f'Change event: {callback.__name__}')
        if watcher is not None:
            watcher.inst.param.unwatch(watcher)
        watcher = owner.param.watch(event_handler, param_attr_name)

    event_handler()


class C(pm.Parameterized):

    watched_int = pm.Integer()

    def __init__(self, **params):
        super().__init__(**params)
        for name, obj in inspect.getmembers(self):
            info = getattr(obj, '_info', None)
            if info is None:
                continue
            use_info(
                owner=self,
                callback=obj,
                param_attr_name=info['param_attr_name'],
            )

    @add_info(param_attr_name='watched_int')
    def callback_1(self, *events):
        ...

    @add_info(param_attr_name='watched_int')
    def callback_2(self, *events):
        ...


inst = C(name='inst', watched_int=0)
inst.watched_int = 1
inst.watched_int = 2
inst.watched_int = 3

Output:
INFO:param.inst: Installing: callback_1
INFO:param.inst: Installing: callback_2
INFO:param.inst: Change event: callback_1
INFO:param.inst: Change event: callback_1
INFO:param.inst: Change event: callback_2
INFO:param.inst: Change event: callback_2
INFO:param.inst: Change event: callback_1
INFO:param.inst: Change event: callback_1

Expected output:
INFO:param.inst: Installing: callback_1
INFO:param.inst: Installing: callback_2
INFO:param.inst: Change event: callback_1
INFO:param.inst: Change event: callback_2
INFO:param.inst: Change event: callback_1
INFO:param.inst: Change event: callback_2
INFO:param.inst: Change event: callback_1
INFO:param.inst: Change event: callback_2

I’m not certain it’s a bug and most likely just me not getting something right, so I’m asking here for help first.

So I tried another approach mixing pm.depends for the outer Parameter with custom Watchers for nested ones. It works but currently direct calling of decorated method is not possible. It doesn’t address the issue, but I think is enough for me to work with.

from __future__ import annotations

import functools
import inspect
import types

import param as pm


def events_as_dict(fn):
    R"""Ensures wrapped event handler gets data as **{event_name: event}."""

    @functools.wraps(fn)
    def wrapper(*events):
        return fn(**{e.name: e for e in events})

    return wrapper


def ddepends(param_name, nested_param_names, mode='args', trigger=True):
    R"""Decorated method is called after nested parameters change.
    Warning. Calling wrapped method directly is not possible.

    Parameters:
    param_name: str
        Outer param.Parameter attribute name defined in the class using this
        decorator. Another param.Parameterized class must be accessible through
        this name.
    nested_param_names: [str, ...]
        Parameters to be watched in the nested param.Parameterized instance.
        Must be accessible class_instance.param_name.nested_param_names[n].
    mode: str
        "args": decorated_function(*events)
        "kwargs": decorated_function(**{event_name: event})
    trigger: bool
        If True calls decorated method once without arguments (events).
    """

    def wrapper(original_method):
        nested_watcher = None

        def set_up_nested_watchers(self):
            nonlocal nested_watcher
            if nested_watcher is not None:
                nested_watcher.inst.param.unwatch(nested_watcher)
            top_param = getattr(self, param_name)
            updated_method = types.MethodType(original_method, self)
            if mode == 'args':
                pass
            elif mode == 'kwargs':
                updated_method = events_as_dict(updated_method)
            else:
                raise ValueError(f'Invalid mode {mode}. Use args or kwargs')
            nested_watcher = top_param.param.watch(
                updated_method, nested_param_names
            )
            if trigger:
                updated_method()

        set_up_nested_watchers._ddepends = dict(param_name=param_name)
        return pm.depends(param_name, watch=True)(set_up_nested_watchers)

    return wrapper


class ParameterizedDdepends(pm.Parameterized):
    R"""Ensures ddpends watchers are installed by triggering param changes."""

    def __init__(self, **params):
        super().__init__(**params)
        names_to_trigger = set()
        for name, obj in inspect.getmembers(self):
            dd = getattr(obj, '_ddepends', None)
            if dd is None:
                continue
            if dd['param_name'] not in params:
                raise TypeError(
                    f"ddepends requires param to be set: {dd['param_name']}"
                )
            names_to_trigger.add(dd['param_name'])
        for name in names_to_trigger:
            self.param.trigger(name)


class I(pm.Parameterized):
    a = pm.Integer()
    b = pm.Integer()


class O(ParameterizedDdepends):
    i = pm.ClassSelector(I, instantiate=True)

    @ddepends(param_name='i', nested_param_names=['a', 'b'])
    def on_nested_change(self, *args, **kwargs):
        self.message(f"{self.name}->{self.i.name}*{len(args)}**{len(kwargs)}")


i1 = I(name='i1')
i2 = I(name='i2')
o = O(name='o', i=i1)
i1.a += 1
i1.a += 1
i2.b += 1
i2.b += 1
o.i = i2
i1.a += 1
i1.a += 1
with pm.batch_watch(i2):
    i2.a += 1
    i2.b += 1

Output (as expected):
INFO:param.o: test
INFO:param.o: o->i10**0
INFO:param.o: o->i1
10
INFO:param.o: o->i1*1
0
INFO:param.o: test
INFO:param.o: o->i20**0
INFO:param.o: o->i2
2**0