Accumulate hooks

Hi there,

Is there a way to accumulate hooks in a way that composes well ?
I need to apply a custom hook inside a library to provide something similar to matplotlib twinx() feature, but that hook will get discarded by user code if they also need to set a hook.

In other words, the hooks option does not compose well:

def hook1(*args):
    print(1)
def hook2(*args):
    print(2)

def lib_func():
    # Using hook1 internally to workaround a problem or provide an extra feature
    return hv.Curve([0,1]).options(hooks=[hook1])

# Only prints "2"
lib_func().options(hooks=[hook2])

maybe
something like:

select = pn.widget.MultiSelect(options=["hook1", "hook2"])
hv_obj.apply.opts(hooks=select.param.value)

(not functional code)

That’s a good question. It’s the same problem we had with the HoloViews+Bokeh tools option, where we want people to be able to use the usual setting syntax to add a tool to the list, but don’t want them to have to look up and recapitulate the default tools to avoid overwriting default tools like pan. Our solution there was very hacky, i.e. to have a default_tools list separate from a tools list, with the latter empty by default so that it can be set easily without overwriting the defaults. Having those two parameters is very confusing for users, so I’m not excited about doing the same thing here. As far as I can see, the options are:

  1. Split hooks into default_hooks and hooks, with library maintainers expecting to use default_hooks, while users expecting to use hooks (initially empty).
  2. Add a syntax for options that specifies that the option should be appended to any existing value, not overwriting it. Ideally it would be like .opts(hooks+=[hook2]), but unfortunately Python doesn’t support += for keyword arguments. :frowning: Thus any such syntax is likely to be fairly obscure and not terribly clear.
  3. Focus on making it easy to look up an option’s current value, so that a user can explicitly add on to it. E.g. opts(hooks=self.hooks+[hook2]), where self would be replaced with some legal way to mean “look up ‘hooks’ on the current options”. I don’t have a good syntax to propose there, either.

These are all fairly problematic, so maybe someone can suggest something better instead? In any case I don’t think this is a Discourse topic, really; it’s a feature request for there to be a better way for users to add non-destructively to a List option.

There is another option, which is to consider values as being encapsulated in a container, with the “combination” operation being defined by the container:

class OptionSetter:
    def __init__(self, x):
        self.x = x

class accumulate(OptionSetter):
    def combine(self, curr):
        return list(curr) + [self.x]


# With opts doing something like
def opts(self, name, val):
    if isinstance(x, OptionSetter):
        val = val.combine(self.get_curr_value(name))

    self.set_value(name, val)

opts(hooks=accumulate(user_hook))

This also allows other useful OptionSetter such as:

class default(OptionSetter):
    """
    Only set the option if it has not been set already
    """
    def combine(self, curr):
        # Ideally a custom singleton other than None, unless we can ensure that
        # None is not a valid value for any option. We don't want to confuse an
        # option explicitly set to None with an option that was never set
        #
        # Note: isinstance(curr, default) indicates we would actually want to
        # store the wrapped value so that we can check what setter was used
        # later on. For simplicity, the other examples assume "curr" is
        # unwrapped.
        if curr is None or isinstance(curr, default):
            return self.x
        else:
            return curr


class at_most(OptionSetter):
    def combine(self, curr):
        if curr > self.x:
            return self.x
        else:
            return curr

class at_least(OptionSetter):
    def combine(self, curr):
        if curr < self.x:
            return self.x
        else:
            return curr

This brings the question: should the control of what combine() operation is used be given to the last call to opts(), or to the current value ?
Having it the way I put it in the example allows the final user of the object to have the final word (or at least by default). Having it the other way around allows the library writer to enforce constraints (such as an at_least()), which could still be overridable with a special override() setter.

Note: I have used this approach when writing this module, which basically creates a tree of properties. The tree is at the end flattened so we only get a list of leaves, but values for each property of the internal nodes get combined together top to bottom using the strategy I showed here. Since all objects are immutable, “modifying” the value for a property actually just means expanding the tree on the root side by adding a new root node with the given value for the property.

There is an example in the docstring module, which shows such per-property behavior (line 80 to 91):

https://lisa-linux-integrated-system-analysis.readthedocs.io/en/master/workloads.html#module-lisa.wlgen.rta

Opened an issue at: Accumulation behavior for list options (tools etc) · Issue #4984 · holoviz/holoviews · GitHub
So we can close this thread and carry on there