Ipywidgets: widget composition in pure python

Created on 3 Apr 2020  Â·  15Comments  Â·  Source: jupyter-widgets/ipywidgets

I am loving this library, great work devs! I'm hoping you could help me work out a snag I'm facing.

I would like to create widgets that are compositions of other widgets and that have their own observe method. I'd like to do this in pure python if I can, and I think I am very close. What I have now is:

from ipywidgets import HBox, FloatRangeSlider, Button

class MyWidget(HBox):
    def __init__(self):
        super(MyWidget, self).__init__()

        self.slider = FloatRangeSlider()
        self.button = Button(description='next')

        self.button.on_click(self.move_up)

        self.children = [self.slider, self.button]

    def move_up(self, change):
        value = self.slider.value
        self.slider.set_state({'value': (value[0] + 1, value[1] + 1)})

my_widget = MyWidget()
my_widget.observe = my_widget.slider.observe
my_widget.observe(lambda x: print(x))
my_widget

This achieves the desired result, but the problem with my current solution is that I have to add the observe method on an instance of the MyWidget class rather than putting this somehow in the MyWidget class definition. I have tried variants of

class MyWidget(HBox):
    def __init__(self):
        super(MyWidget, self).__init__()

        self.slider = FloatRangeSlider()
        self.button = Button(description='next')

        self.button.on_click(self.move_up)

        self.children = [self.slider, self.button]

    def move_up(self, change):
        value = self.slider.value
        self.slider.set_state({'value': (value[0] + 1, value[1] + 1)})

    def observe(self, handler, **kwargs):
        self.slider.observe(handler, **kwargs)

my_widget = MyWidget()

but I haven't gotten anything to work. The error I get from the above is:

---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-62-3ea00d238837> in <module>
     17         self.slider.observe(handler)
     18 
---> 19 my_widget = MyWidget()

~/opt/anaconda3/lib/python3.7/site-packages/traitlets/traitlets.py in __new__(cls, *args, **kwargs)
    956         else:
    957             inst = new_meth(cls, *args, **kwargs)
--> 958         inst.setup_instance(*args, **kwargs)
    959         return inst
    960 

~/opt/anaconda3/lib/python3.7/site-packages/traitlets/traitlets.py in setup_instance(self, *args, **kwargs)
    984         self._trait_notifiers = {}
    985         self._trait_validators = {}
--> 986         super(HasTraits, self).setup_instance(*args, **kwargs)
    987 
    988     def __init__(self, *args, **kwargs):

~/opt/anaconda3/lib/python3.7/site-packages/traitlets/traitlets.py in setup_instance(self, *args, **kwargs)
    975             else:
    976                 if isinstance(value, BaseDescriptor):
--> 977                     value.instance_init(self)
    978 
    979 

~/opt/anaconda3/lib/python3.7/site-packages/traitlets/traitlets.py in instance_init(self, inst)
    922 
    923     def instance_init(self, inst):
--> 924         inst.observe(self, self.trait_names, type=self.type)
    925 
    926 

TypeError: observe() takes 2 positional arguments but 3 were given

Has anyone done something like this? Any tips on how I might get this to work as intended? I think pure python "widget composition" capability could be really useful for lots of users.

This is very similar to #700, in which @jasongrout suggested this would be possible in pure python. @pbugnion this seems up your alley as well as it is similar to #1543.

resolved-locked

All 15 comments

I know why, I'll tell you the solution first and then the reason why it doesn't work:

Solution:
Change the name of the observe method you defined in your class:

    def my_observe(self, handler, **kwargs):
        self.slider.observe(handler, **kwargs)

Reason:
If you print out the MRO you can see from which class you are inheriting. In fact you are inheriting from the HasTraits class of the traitlets module in the traitlets package. This is because you are inheriting from HBox which in turn inherits from HasTraits.

This HasTraits class that is you parent, already has an observe method which in turn is used by other private functions

When you define your own observe method you are overriding the observe method in the parent class and everything breaks. It would suffice to follow the traceback to have a clear understanding of when and how it broke.

Thanks for taking the time, @gioxc88. Some functions rely on the name of the method being observe, e.g. interactive_output. If I named my observe method my_observe, interactive_output would not work on my widget. Is there any way to keep the name observe?

I tried using the same function signature as HasTraits:

from ipywidgets import HBox, FloatRangeSlider, Button
from traitlets import All

class MyWidget(HBox):
    def __init__(self):

        self.slider = FloatRangeSlider()
        self.button = Button(description='next')

        super(MyWidget, self).__init__()

        self.button.on_click(self.move_up)

        self.children = [self.slider, self.button]

    def move_up(self, change):
        value = self.slider.value
        self.slider.set_state({'value': (value[0] + 1, value[1] + 1)})

    def observe(self, handler, names=All, type='change'):
        return self.slider.observe(handler, names, type)

my_widget = MyWidget()

but then I get this puzzling error:

---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-10-1847da5944aa> in <module>
     22         self.slider.observe(handler, **kwargs)
     23 
---> 24 my_widget = MyWidget()

~/opt/anaconda3/lib/python3.7/site-packages/traitlets/traitlets.py in __new__(cls, *args, **kwargs)
    956         else:
    957             inst = new_meth(cls, *args, **kwargs)
--> 958         inst.setup_instance(*args, **kwargs)
    959         return inst
    960 

~/opt/anaconda3/lib/python3.7/site-packages/traitlets/traitlets.py in setup_instance(self, *args, **kwargs)
    984         self._trait_notifiers = {}
    985         self._trait_validators = {}
--> 986         super(HasTraits, self).setup_instance(*args, **kwargs)
    987 
    988     def __init__(self, *args, **kwargs):

~/opt/anaconda3/lib/python3.7/site-packages/traitlets/traitlets.py in setup_instance(self, *args, **kwargs)
    975             else:
    976                 if isinstance(value, BaseDescriptor):
--> 977                     value.instance_init(self)
    978 
    979 

~/opt/anaconda3/lib/python3.7/site-packages/traitlets/traitlets.py in instance_init(self, inst)
    922 
    923     def instance_init(self, inst):
--> 924         inst.observe(self, self.trait_names, type=self.type)
    925 
    926 

<ipython-input-10-1847da5944aa> in observe(self, handler, names, type)
     20 
     21     def observe(self, handler, names=All, type='change'):
---> 22         self.slider.observe(handler, **kwargs)
     23 
     24 my_widget = MyWidget()

AttributeError: 'MyWidget' object has no attribute 'slider'

the error is clear from the first line of the traceback :

/opt/anaconda3/lib/python3.7/site-packages/traitlets/traitlets.py in __new__(cls, *args, **kwargs)

this happens because __new__ is a class method and while you have self.slider which is an instance attribute.

This means that only instances of the MyWidget class have a slider attribute but not the class itself.

Try to do:

class MyWidget(HBox):

    slider = FloatRangeSlider()
    def __init__(self):

        self.button = Button(description='next')

        super(MyWidget, self).__init__()

        self.button.on_click(self.move_up)

        self.children = [self.slider, self.button]

    def move_up(self, change):
        value = self.slider.value
        self.slider.set_state({'value': (value[0] + 1, value[1] + 1)})

    def observe(self, handler, names=All, type='change'):
        return self.slider.observe(handler, names, type)

my_widget = MyWidget()

this will work but still I am not sure want you want to achieve so I don't know if it will work for your purposes

@gioxc88 thanks again for the help. I tried implementing it this way, but when I try to display the resulting widget by running my_widget after, I get this message instead of the widget:

A Jupyter widget could not be displayed because the widget state could not be found. This could happen if the kernel storing the widget is no longer available, or if the widget state was not saved in the notebook. You may be able to create the widget by running the appropriate cells.

Also, with this new approach, if I create multiple instances of MyWidget, would they each have their own slider? I would want them to have distinct components.

yes I see now what you want to achieve but not sure how to go about it.
That being said I am trying to leverage this ipywidgets (which I find very useful and promising) to build a complex app. So I also want to know how to do what you are asking and if I find a solution and nobody answer before I will share it here

OK, sounds good. Thanks for the help anyway, you helped me understand the problem better.

What if you define a 'value' trait on your class, and internally link it to the slider value? You can do this by using multiple inheritance to inherit from ValueWidget. If you want it to work with interact, it will also need a description attribute, so I also inherit from DescriptionWidget below:

from ipywidgets import HBox, FloatRangeSlider, Button, ValueWidget, link, interact
from ipywidgets.widgets.widget_description import DescriptionWidget

class MyWidget(HBox, ValueWidget, DescriptionWidget):
    def __init__(self):
        super(MyWidget, self).__init__()

        self.slider = FloatRangeSlider()
        self.button = Button(description='next')
        self.button.on_click(self.move_up)

        self.children = [self.slider, self.button]
        link((self.slider, 'value'), (self, 'value'))
        link((self.slider, 'description'), (self, 'description'))

    def move_up(self, change):
        value = self.slider.value
        self.slider.set_state({'value': (value[0] + 1, value[1] + 1)})


my_widget = MyWidget()
my_widget.observe(lambda x: print(x))
my_widget

This may not be the cleanest way to do things, but have fun experimenting!

Thanks @jasongrout, this is perfect!

I really want to thank @gioxc88 for digging into this and exposing what the problem was.

yes, thanks to both of you.

Now that we have a value attribute, here's a version that's a bit cleaner:

from ipywidgets import HBox, FloatRangeSlider, Button, ValueWidget, link, interact
from ipywidgets.widgets.widget_description import DescriptionWidget

class MyWidget(HBox, ValueWidget, DescriptionWidget):
    def __init__(self):
        super(MyWidget, self).__init__()

        self.slider = FloatRangeSlider()
        self.button = Button(description='next')
        self.button.on_click(self.move_up)

        self.children = [self.slider, self.button]
        link((self.slider, 'value'), (self, 'value'))
        link((self.slider, 'description'), (self, 'description'))

    def move_up(self, change):
        self.value = (self.value[0] + 1, self.value[1] + 1)


my_widget = MyWidget()
my_widget.observe(lambda x: print(x))
my_widget

Note also that the ipywidgets 8 slider will let you grab the bar between the two endpoints of a range slider and move both endpoints in lock-step.

@jasongrout ooooo that would be great! When can we expect that to be released?

Hopefully in the next few months

For posterity, I came up against a similar problem where I couldn't use links and I got it to work by using observers on the inner widgets to control the value of the main widget.

In this case, I also output the start and stop of a window, but my widgets are FloatSlider for the start of the window and BoundedFloatText for the duration of the window. It's a bit funky inside the observer method for the slider, but it works

from ipywidgets import Layout, HBox

class StartAndDurationController(HBox, ValueWidget, DescriptionWidget):
    def __init__(self, tmax, tmin=0, start_value=None, duration=1., dtype='float', description='window (s)',
                 **kwargs):

        self.tmin = tmin
        self.tmax = tmax
        self.start_value = start_value
        self.dtype = dtype

        self.slider = widgets.FloatSlider(
            value=start_value,
            min=tmin,
            max=tmax,
            step=0.01,
            description=description,
            continuous_update=False,
            orientation='horizontal',
            readout=True,
            readout_format='.2f',
            style={'description_width': 'initial'},
            layout=Layout(width='100%'))

        self.duration = widgets.BoundedFloatText(
            value=duration,
            min=0,
            max=tmax - tmin,
            step=0.1,
            description='duration (s):',
            style={'description_width': 'initial'},
            layout=Layout(width='150px')
        )

        super(StartAndDurationController, self).__init__()
        link((self.slider, 'description'), (self, 'description'))

        self.value = (self.slider.value, self.slider.value + self.duration.value)

        self.forward_button = widgets.Button(description='â–¶', layout=Layout(width='50px'))
        self.forward_button.on_click(self.move_up)

        self.backwards_button = widgets.Button(description='â—€', layout=Layout(width='50px'))
        self.backwards_button.on_click(self.move_down)

        self.children = [self.slider, self.duration, self.backwards_button, self.forward_button]

        self.slider.observe(self.monitor_slider)
        self.duration.observe(self.monitor_duration)

    def monitor_slider(self, change):
        if 'new' in change:
            if isinstance(change['new'], dict):
                if 'value' in change['new']:
                    value = change['new']['value']
                else:
                    return
            else:
                value = change['new']
        if value + self.duration.value > self.tmax:
            self.slider.value = self.tmax - self.duration.value
        else:
            self.value = (value, value + self.duration.value)


    def monitor_duration(self, change):
        if 'new' in change:
            if isinstance(change['new'], dict):
                if 'value' in change['new']:
                    value = change['new']['value']
                    if self.slider.value + value > self.tmax:
                        self.slider.value = self.tmax - value
                    self.value = (self.slider.value, self.slider.value + value)

    def move_up(self, change):
        if self.slider.value + 2 * self.duration.value < self.tmax:
            self.slider.value += self.duration.value
        else:
            self.slider.value = self.tmax - self.duration.value

    def move_down(self, change):
        if self.slider.value - self.duration.value > self.tmin:
            self.slider.value -= self.duration.value
        else:
            self.slider.value = self.tmin

start_and_duration_controller = StartAndDurationController(10)
start_and_duration_controller.observe(lambda x: print(x['new']))
start_and_duration_controller
Was this page helpful?
0 / 5 - 0 ratings