jupyter-widgets / ipywidgets

Interactive Widgets for the Jupyter Notebook
https://ipywidgets.readthedocs.io
BSD 3-Clause "New" or "Revised" License
3.13k stars 950 forks source link

Creating views for generic HasTraits models #2296

Open jasongrout opened 5 years ago

jasongrout commented 5 years ago

An excellent question on StackOverflow is about defining ipywidget views for a Traits model. This is a pattern that I think would be good to include in the documentation (though perhaps a simplified version...):

Setup

From the StackOverflow post:

from traitlets import HasTraits, Float, observe, Enum
import math

class FXModel(HasTraits):
    domestic_qty = Float()
    foreign_qty = Float()
    fx_rate  = Float(float('nan')) # in units of domestic_qty/foreign_qty
    lock = Enum(['domestic', 'foreign'], default_value='domestic')
    _calculating = Enum([None, 'domestic', 'foreign'], default_value=None)

    def calc_foreign(self):
        if not math.isnan(self.fx_rate):
            self._calculating = 'foreign'
            self.foreign_qty = self.domestic_qty / self.fx_rate
            self._calculating = None

    def calc_domestic(self):
        if not math.isnan(self.fx_rate):
            self._calculating = 'domestic'
            self.domestic_qty = self.foreign_qty * self.fx_rate
            self._calculating = None

    @observe('domestic_qty')
    def on_domestic(self, change):
        if self._calculating is None:
            self.calc_foreign()

    @observe('foreign_qty')
    def on_foreign(self, change):
        if self._calculating is None:
            self.calc_domestic()

    @observe('fx_rate')
    def on_fxrate(self, change):
        if self.lock == 'domestic':
            self.calc_foreign()
        else:
            self.calc_domestic()

    def __repr__(self):
        return ("""
        domestic_qty: {:.4g}
        foreign_qty:  {:.4g}
        fx_rate:      {:.4g}
        lock:         {}""".format(
            self.domestic_qty,
            self.foreign_qty,
            self.fx_rate,
            self.lock
        ))

View

So here's how to make a custom view using ipywidget views, using the widgets from the post. The key is the link calls to bind attributes to widget values.

import ipywidgets as widgets
from traitlets import link
from IPython.display import display

class FXWidgetView:
    def __init__(self, model):
        self.model = model
        self.domestic_label = widgets.Label("Domestic quantity")
        self.domestic_field = widgets.FloatText()

        self.foreign_label = widgets.Label("Foreign quantity")
        self.foreign_field = widgets.FloatText()

        self.fx_label = widgets.Label("Exchange rate (domestic/foreign)")
        self.fx_field = widgets.FloatText()

        self.lock_label = widgets.Label("If rates change, keep ")
        self.lock_field = widgets.Dropdown(options=["domestic", "foreign"])
        self.lock_label_post = widgets.Label('fixed')

        self.ipyview = widgets.HBox([widgets.VBox([self.domestic_label, self.foreign_label, self.fx_label, self.lock_label]),
                      widgets.VBox([self.domestic_field, self.foreign_field, self.fx_field, widgets.HBox([self.lock_field, self.lock_label_post])])])

        link((model, 'domestic_qty'), (self.domestic_field, 'value'))
        link((model, 'foreign_qty'), (self.foreign_field, 'value'))
        link((model, 'fx_rate'), (self.fx_field, 'value'))
        link((model, 'lock'), (self.lock_field, 'value'))

    def _ipython_display_(self):
        display(self.ipyview)

I'll point out that Param has been advocating this sort of separation for a while, and @jbednar has pointed out that ipywidgets could implement convenience functions to support this pattern as well. I think that's a good idea - to have some simple convenience functions that are a step up from the interact functions that take a HasTraits class, introspect it, and provide default widgets for different traits for common cases.

jasongrout commented 5 years ago

Putting to minor release, in case we add the introspecting convenience functions. Adding docs documenting this pattern is a patch release, of course.

jbednar commented 5 years ago

Thanks for bringing this to my attention, Jason. I'd be very happy to see this paradigm spread to traitlets/ipywidgets!

I'd go further and claim that the missing functions aren't just about convenience, they are about encapsulation, with major implications. Here, the FXWidgetView class had to be built specifically for viewing the FXModel, because it relies on knowing domain-specific details like the name and type of each of the FXModel widgets. If traits are then added to or deleted from or renamed in FXModel, the corresponding GUI code needs to be updated for it to keep working. As a result, anyone who modifies FXModel has to learn about ipywidgets, has to know FXWidgetView exists and where to find it, and has to make sure to update and test FXWidgets on every change to FXModel. Thus without the convenience functions, the domain-specific code (FXModel) has to be maintained in lockstep with the GUI code (FXWidgetView). I think tying the GUI and model code together like that is a problem because the same model code might be applicable to many contexts (command-line, batch, Jupyter notebooks, native GUIs) and because the people with expertise to update the model aren't necessarily the people with GUI expertise or interest.

As a trivial example of the alternative approach of rigorously separating the model and GUI code, you can see the NYCTaxiExplorer class in nyc_taxi_panel, which is equivalent to FXModel in that it declares attributes and how they relate to each other. But then generating the specific widgets involved does not require any GUI code; you just pass the model instance to Panel) and Panel does the rest. All the information needed to build the GUI is declared in the model class, but without tying it to any particular domain-specific GUI implementation.

The Param library that supports this is very similar to Traitlets in functionality, but it was designed from the start to support a complete break between the model and the GUI (view+controller) code. Over the years there have been many different GUI toolkits supported for Param (Tk, ipywidgets, Bokeh, and now Panel), but the same domain-specific Param code from 2004 still works (as designed!). E.g. the large class hierarchy from ImaGen, written in 2005, worked as-is with Panel when we wrote Panel a few months ago. You can see a similar but self-contained example of a complex hierarchy in the attractors dashboard, with new attributes added as needed for each class and new GUI-editable subclasses that can be added without changing the GUI code at all.

Depending on users' needs, implementing this approach for Traitlets could take a few different forms. Panel currently works this way only for Parameterized objects, but it could be extended to provide similar functionality for HasTraits objects. Panel's support for Parameterized objects is only 500 lines (in panel/param.py), so adding similar support for HasTraits might not be a huge job, though since Panel was explicitly built around Param there will probably be some friction with how Traitlets does things. Plus our own knowledge of Param is much deeper than our knowledge of Traitlets, so it would take us some time to figure out the differences. The advantage of extending Panel in this way would be that HasTraits objects could then be GUI-editable both in a Jupyter notebook and in a separate standalone dashboard, because of Panel being built on Bokeh's widgets that work the same in notebook and deployed-server contexts.

What you're proposing here is different, of course, i.e. making HasTraits objects editable in a Jupyter notebook using ipywidgets, which makes it parallel to Param+Panel instead of using any part of what we developed. But we at HoloViz would be very happy for the ipywidgets team to adopt and guide people to working in that style, because of all the benefits that it gives independent of any particular toolkit. If that approach took off in the ipywidgets world, we'd be very likely to eventually add HasTraits support to Panel so that users' domain-specific code would be as broadly applicable as possible. Meanwhile, we'd be happy to provide any help or feedback we could on any work you are doing in this area...

jspille commented 4 years ago

Good Example, well done. However I tried to make an HTML page out of it, using 'from ipywidgets.embed import embed_minimal_html'. As background, I'm working on a 'ipyleaflet' app. But 'traitlets link' does not work. What needs to be changed to use 'widgets.jslink' instead? Links with 'widgets.jslink' work in HTML exports.