traitlets icon indicating copy to clipboard operation
traitlets copied to clipboard

Improving link and dlink

Open krey opened this issue 6 years ago • 6 comments

link and dlink are good for simple uses, but lack certain features

  1. They are not compatible with Output widgets, making debugging a lot harder, see https://github.com/jupyter-widgets/ipywidgets/issues/2306
  2. They contain redundant code
  3. They are built around the "lock" pattern, but in an ad-hoc way: there's no context manager. Such a thing would be quite useful for a user needing a slightly more sophisticated linking pattern, say, linking a 2-Tuple trait with 2 Tuple traits, etc.

Is there any interest adding some optional arguments to link and dlink, and creating a Lock context manager?

I would be happy to do a PR

krey avatar Mar 15 '19 13:03 krey

Linking with contexts

Consider the following example

from traitlets import HasTraits, Int, observe, link
import ipywidgets as widgets

class Model(HasTraits):
    value = Int()
    
    @observe('value')
    def _on_value(self, change):
        raise RuntimeError

model = Model()
field = widgets.IntText()

link((model, 'value'), (field, 'value'))

When you type into field, a RuntimeError is raised but this is not possible to capture, even using the new optional transform argument of link.

Adding an extra optional context argument to link, the problem could be solved as

import ipywidgets as widgets
from traitlets import HasTraits, Int, observe

class Model(HasTraits):
    value = Int()
    
    @observe('value')
    def _on_value(self, change):
        raise RuntimeError
        
        
model = Model()
field = widgets.IntText()
output = widgets.Output()

link((model, 'value'), (field, 'value'), context=lambda: output)

The implementation is just adding a with self.context() block to link._update_source, link._update_target and link.link and a default context manager:

class TrivialContext(object):
    def __enter__(self):
        pass
    def __exit__(self, *args):
        pass

Alternatively, this change could be made on the ipywidgets side, instead of just importing link and dlink from traitlets.

krey avatar Mar 18 '19 12:03 krey

Adding a Lock pattern

The lock pattern is used internally in traitlets for cross validation, as well as in linking, as mentioned above. It's also useful in building traitlets models.

Consider the following app that let's you perform a heavy calculation (summing two integers)

import ipywidgets as widgets
import time
from traitlets import HasTraits, Int, link

# defining the model
class Model(HasTraits):
    a = Int()
    b = Int()
    sum = Int()
    
    @observe('a', 'b')
    def _on_a_b(self, change):
        self._update_sum()

    def _update_sum(self):
        time.sleep(1)
        self.sum = self.a + self.b

model = Model()
model.a = 3
model.b = 4
print(model.sum)

As written, it's inefficient because the model provides no way of setting a and b simultaneously without recomputing the sum twice.

Now consider the same app, enhanced with a lock pattern:

class Model(HasTraits):
    a = Int()
    b = Int()
    sum = Int()
    _sum_lock = Instance(Lock, args=())
    
    @observe('a', 'b')
    def _on_a_b(self, change):
        self._update_sum()
        
    def set_both(self, a, b):
        with self._sum_lock.locked():
            self.a = a
            self.b = b
        self._update_sum()
    
    def _update_sum(self):
        with self._sum_lock.locked() as grabbed:
            Lock.require(grabbed)
            time.sleep(1)
            self.sum = self.a + self.b

model = Model()
model.set_both(3, 4)
print(model.sum)

And an example implementation of the Lock class

from contextlib import contextmanager

class SkipException(Exception):
    pass

class Lock(object):
    def __init__(self):
        self._locked = False

    @contextmanager
    def locked(self):
        """
        Switch to a locked state
        Returns whether lock was grabbed
        """
        grabbed = not self._locked
        self._locked = True
        try:
            yield grabbed
        except SkipException:
            pass
        finally:
            self._locked = not grabbed
    
    @staticmethod
    def require(grabbed):
        """
        Escapes context if lock was not grabbed
        """
        if not grabbed:
            raise SkipException

The pattern could be used to rewrite the traitlets internals mentioned above and is quite useful when developing HasTraits models as well.

In particular, there is no need to have separate code for link and dlink. link can simply create two dlinks using the same lock.

krey avatar Mar 18 '19 13:03 krey

@krey would hold_trait_notifications accomplish this? I know the given example is about validation, but I know that @observe handlers are also put on hold:

https://traitlets.readthedocs.io/en/stable/using_traitlets.html?highlight=hold_trait_notifications#validation

rmorshea avatar Apr 03 '19 00:04 rmorshea

@rmorshea I'm interested in addressing the following properties of link and dlink (copied from the original post):

  1. They are not compatible with Output widgets, making debugging a lot harder, see jupyter-widgets/ipywidgets#2306
  2. They contain redundant code
  3. They are built around the "lock" pattern, but in an ad-hoc way: there's no context manager. Such a thing would be quite useful for a user needing a slightly more sophisticated linking pattern, say, linking a 2-Tuple trait with 2 Tuple traits, etc.

Which one are you referring to when you write "accomplish this"?

krey avatar Apr 03 '19 13:04 krey

@krey those are definitely problems that should be addressed, however I think a past proposal for a mute_trait_notifications context manager might suite you needs. The version from the proposal is global, but it could be made to accepts a list of optional trait names such that only those provided would have notifications muted.

rmorshea avatar Apr 04 '19 00:04 rmorshea

This also seems related to https://github.com/ipython/traitlets/issues/389

rmorshea avatar Apr 04 '19 02:04 rmorshea