Repository: ipython/ipython

Issue:
Bug when constructing a selection widget with both values and labels
Length validation causes selection widgets constructed with both values and labels to fail.



Discussion:

Resolving patch:
diff --git a/IPython/html/widgets/widget_selection.py b/IPython/html/widgets/widget_selection.py
--- a/IPython/html/widgets/widget_selection.py
+++ b/IPython/html/widgets/widget_selection.py
@@ -1,4 +1,4 @@
-"""SelectionWidget class.  
+"""SelectionWidget classes.
 
 Represents an enumeration using a widget.
 """
@@ -13,68 +13,92 @@
 #-----------------------------------------------------------------------------
 # Imports
 #-----------------------------------------------------------------------------
+
+from collections import OrderedDict
 from threading import Lock
 
 from .widget import DOMWidget
-from IPython.utils.traitlets import Unicode, List, Bool, Any, Dict
+from IPython.utils.traitlets import Unicode, List, Bool, Any, Dict, TraitError
+from IPython.utils.py3compat import unicode_type
 
 #-----------------------------------------------------------------------------
 # SelectionWidget
 #-----------------------------------------------------------------------------
 class _SelectionWidget(DOMWidget):
-    value = Any(help="Selected value") 
-    values = List(help="List of values the user can select")
-    labels = List(help="""List of string representations for each value.  
-        These string representations are used to display the values in the
-        front-end.""", sync=True) # Only synced to the back-end.
+    """Base class for Selection widgets
+    
+    ``values`` can be specified as a list or dict. If given as a list,
+    it will be transformed to a dict of the form ``{str(value):value}``.
+    """
+    
+    value = Any(help="Selected value")
+    values = Dict(help="""Dictionary of {name: value} the user can select.
+    
+    The keys of this dictionary are the strings that will be displayed in the UI,
+    representing the actual Python choices.
+    
+    The keys of this dictionary are also available as value_names.
+    """)
+    value_name = Unicode(help="The name of the selected value", sync=True)
+    value_names = List(Unicode, help="""Read-only list of names for each value.
+        
+        If values is specified as a list, this is the string representation of each element.
+        Otherwise, it is the keys of the values dictionary.
+        
+        These strings are used to display the choices in the front-end.""", sync=True)
     disabled = Bool(False, help="Enable or disable user changes", sync=True)
     description = Unicode(help="Description of the value this widget represents", sync=True)
+    
 
-    _value = Unicode(sync=True) # Bi-directionally synced.
-
-    def __init__(self, *pargs, **kwargs):
-        """Constructor"""
+    def __init__(self, *args, **kwargs):
         self.value_lock = Lock()
-        self.on_trait_change(self._string_value_set, ['_value'])
-        DOMWidget.__init__(self, *pargs, **kwargs)
-
-    def _labels_changed(self, name=None, old=None, new=None):
-        """Handles when the value_names Dict has been changed.
-
-        This method sets the _reverse_value_names Dict to the inverse of the new
-        value for the value_names Dict."""
-        if len(new) != len(self.values):
-            raise TypeError('Labels list must be the same size as the values list.')
-
-    def _values_changed(self, name=None, old=None, new=None):
-        """Handles when the value_names Dict has been changed.
-
-        This method sets the _reverse_value_names Dict to the inverse of the new
-        value for the value_names Dict."""
-        if len(new) != len(self.labels):
-            self.labels = [(self.labels[i] if i < len(self.labels) else str(v)) for i, v in enumerate(new)]
+        self._in_values_changed = False
+        if 'values' in kwargs:
+            values = kwargs['values']
+            # convert list values to an dict of {str(v):v}
+            if isinstance(values, list):
+                # preserve list order with an OrderedDict
+                kwargs['values'] = OrderedDict((unicode_type(v), v) for v in values)
+        DOMWidget.__init__(self, *args, **kwargs)
+    
+    def _values_changed(self, name, old, new):
+        """Handles when the values dict has been changed.
+
+        Setting values implies setting value names from the keys of the dict.
+        """
+        self._in_values_changed = True
+        try:
+            self.value_names = list(new.keys())
+        finally:
+            self._in_values_changed = False
+        
+        # ensure that the chosen value is one of the choices
+        if self.value not in new.values():
+            self.value = next(iter(new.values()))
+    
+    def _value_names_changed(self, name, old, new):
+        if not self._in_values_changed:
+            raise TraitError("value_names is a read-only proxy to values.keys(). Use the values dict instead.")
 
     def _value_changed(self, name, old, new):
         """Called when value has been changed"""
         if self.value_lock.acquire(False):
             try:
-                # Make sure the value is in the list of values.
-                if new in self.values:
-                    # Set the string version of the value.
-                    self._value = self.labels[self.values.index(new)]
-                else:
-                    raise TypeError('Value must be a value in the values list.')
+                # Reverse dictionary lookup for the value name
+                for k,v in self.values.items():
+                    if new == v:
+                        # set the selected value name
+                        self.value_name = k
+                        return
+                raise KeyError(new)
             finally:
                 self.value_lock.release()
 
-    def _string_value_set(self, name, old, new):
-        """Called when _value has been changed."""
+    def _value_name_changed(self, name, old, new):
+        """Called when the value name has been changed (typically by the frontend)."""
         if self.value_lock.acquire(False):
             try:
-                if new in self.labels:
-                    self.value = self.values[self.labels.index(new)]
-                else:
-                    self.value = None
+                self.value = self.values[new]
             finally:
                 self.value_lock.release()
 


Test patch:
diff --git a/IPython/html/tests/casperjs/test_cases/widgets_selection.js b/IPython/html/tests/casperjs/test_cases/widgets_selection.js
--- a/IPython/html/tests/casperjs/test_cases/widgets_selection.js
+++ b/IPython/html/tests/casperjs/test_cases/widgets_selection.js
@@ -76,7 +76,7 @@ casper.notebook_test(function () {
             'Widget list exists.');
 
         // Verify that no items are selected.
-        this.test.assert(verify_selection(this, -1), 'No items selected.');
+        this.test.assert(verify_selection(this, 0), 'Default first item selected.');
     });
 
     index = this.append_cell(
@@ -123,7 +123,9 @@ casper.notebook_test(function () {
 
     index = this.append_cell(
         'for widget in selection:\n' +
-        '    widget.values = list(widget.values) + ["z"]\n' +
+        '    d = widget.values.copy()\n' +
+        '    d["z"] = "z"\n' +
+        '    widget.values = d\n' +
         'selection[0].value = "z"');
     this.execute_cell_then(index, function(index){