Repository: ipython/ipython

Issue:
Two link objects with name conflicts
Right now we have two things called `link`:
- One in `traitlets` that links attributes of any `HasTraits` subclass.
- One in `widgets` that does a JS based link of any `Widget` subclass.

Their API is nearly identical and it is very common to import both at the same time. I spent about 2 hours tracking down a super subtle bug because I had both imported and didn't realize which one I was using.

I think we should consider naming the JS one to `jslink`.



Discussion:

Resolving patch:
diff --git a/IPython/html/widgets/__init__.py b/IPython/html/widgets/__init__.py
--- a/IPython/html/widgets/__init__.py
+++ b/IPython/html/widgets/__init__.py
@@ -11,7 +11,7 @@
 from .widget_selectioncontainer import Tab, Accordion
 from .widget_string import HTML, Latex, Text, Textarea
 from .interaction import interact, interactive, fixed, interact_manual
-from .widget_link import Link, link, DirectionalLink, dlink
+from .widget_link import jslink, jsdlink
 
 # Deprecated classes
 from .widget_bool import CheckboxWidget, ToggleButtonWidget
diff --git a/IPython/html/widgets/widget_link.py b/IPython/html/widgets/widget_link.py
--- a/IPython/html/widgets/widget_link.py
+++ b/IPython/html/widgets/widget_link.py
@@ -2,31 +2,47 @@
 
 Propagate changes between widgets on the javascript side
 """
-#-----------------------------------------------------------------------------
-# Copyright (c) 2014, the IPython Development Team.
-#
+
+# Copyright (c) IPython Development Team.
 # Distributed under the terms of the Modified BSD License.
-#
-# The full license is in the file COPYING.txt, distributed with this software.
-#-----------------------------------------------------------------------------
 
-#-----------------------------------------------------------------------------
-# Imports
-#-----------------------------------------------------------------------------
 from .widget import Widget
-from IPython.utils.traitlets import Unicode, Tuple, Any
+from IPython.testing.skipdoctest import skip_doctest
+from IPython.utils.traitlets import Unicode, Tuple, List,Instance, TraitError
 
-#-----------------------------------------------------------------------------
-# Classes
-#-----------------------------------------------------------------------------
+class WidgetTraitTuple(Tuple):
+    """Traitlet for validating a single (Widget, 'trait_name') pair"""
+    
+    def __init__(self, **kwargs):
+        super(WidgetTraitTuple, self).__init__(Instance(Widget), Unicode, **kwargs)
+    
+    def validate_elements(self, obj, value):
+        value = super(WidgetTraitTuple, self).validate_elements(obj, value)
+        widget, trait_name = value
+        trait = widget.traits().get(trait_name)
+        trait_repr = "%s.%s" % (widget.__class__.__name__, trait_name)
+        # Can't raise TraitError because the parent will swallow the message
+        # and throw it away in a new, less informative TraitError
+        if trait is None:
+            raise TypeError("No such trait: %s" % trait_repr)
+        elif not trait.get_metadata('sync'):
+            raise TypeError("%s cannot be synced" % trait_repr)
+        
+        return value
 
 
 class Link(Widget):
-    """Link Widget"""
+    """Link Widget
+    
+    one trait:
+    widgets, a list of (widget, 'trait_name') tuples which should be linked in the frontend.
+    """
     _model_name = Unicode('LinkModel', sync=True)
-    widgets = Tuple(sync=True, allow_none=False)
+    widgets = List(WidgetTraitTuple, sync=True)
 
-    def __init__(self, widgets=(), **kwargs):
+    def __init__(self, widgets, **kwargs):
+        if len(widgets) < 2:
+            raise TypeError("Require at least two widgets to link")
         kwargs['widgets'] = widgets
         super(Link, self).__init__(**kwargs)
 
@@ -35,19 +51,39 @@ def unlink(self):
         self.close()
 
 
-def link(*args):
+@skip_doctest
+def jslink(*args):
+    """Link traits from different widgets together on the frontend so they remain in sync.
+
+    Parameters
+    ----------
+    *args : two or more (Widget, 'trait_name') tuples that should be kept in sync.
+
+    Examples
+    --------
+
+    >>> c = link((widget1, 'value'), (widget2, 'value'), (widget3, 'value'))
+    """
     return Link(widgets=args)
 
 
 class DirectionalLink(Widget):
-    """Directional Link Widget"""
+    """A directional link
+    
+    source: a (Widget, 'trait_name') tuple for the source trait
+    targets: one or more (Widget, 'trait_name') tuples that should be updated
+    when the source trait changes.
+    """
     _model_name = Unicode('DirectionalLinkModel', sync=True)
-    targets = Any(sync=True)
-    source = Tuple(sync=True)
+    targets = List(WidgetTraitTuple, sync=True)
+    source = WidgetTraitTuple(sync=True)
 
     # Does not quite behave like other widgets but reproduces
     # the behavior of IPython.utils.traitlets.directional_link
-    def __init__(self, source, targets=(), **kwargs):
+    def __init__(self, source, targets, **kwargs):
+        if len(targets) < 1:
+            raise TypeError("Require at least two widgets to link")
+        
         kwargs['source'] = source
         kwargs['targets'] = targets
         super(DirectionalLink, self).__init__(**kwargs)
@@ -56,6 +92,20 @@ def __init__(self, source, targets=(), **kwargs):
     def unlink(self):
         self.close()
 
+@skip_doctest
+def jsdlink(source, *targets):
+    """Link the trait of a source widget with traits of target widgets in the frontend.
+
+    Parameters
+    ----------
+    source : a (Widget, 'trait_name') tuple for the source trait
+    *targets : one or more (Widget, 'trait_name') tuples that should be updated
+    when the source trait changes.
+
+    Examples
+    --------
+
+    >>> c = dlink((src_widget, 'value'), (tgt_widget1, 'value'), (tgt_widget2, 'value'))
+    """
+    return DirectionalLink(source=source, targets=targets)
 
-def dlink(source, *targets):
-    return DirectionalLink(source, targets)
diff --git a/IPython/utils/traitlets.py b/IPython/utils/traitlets.py
--- a/IPython/utils/traitlets.py
+++ b/IPython/utils/traitlets.py
@@ -172,13 +172,24 @@ def getmembers(object, predicate=None):
     results.sort()
     return results
 
+def _validate_link(*tuples):
+    """Validate arguments for traitlet link functions"""
+    for t in tuples:
+        if not len(t) == 2:
+            raise TypeError("Each linked traitlet must be specified as (HasTraits, 'trait_name'), not %r" % t)
+        obj, trait_name = t
+        if not isinstance(obj, HasTraits):
+            raise TypeError("Each object must be HasTraits, not %r" % type(obj))
+        if not trait_name in obj.traits():
+            raise TypeError("%r has no trait %r" % (obj, trait_name))
+
 @skip_doctest
 class link(object):
     """Link traits from different objects together so they remain in sync.
 
     Parameters
     ----------
-    obj : pairs of objects/attributes
+    *args : pairs of objects/attributes
 
     Examples
     --------
@@ -190,6 +201,7 @@ class link(object):
     def __init__(self, *args):
         if len(args) < 2:
             raise TypeError('At least two traitlets must be provided.')
+        _validate_link(*args)
 
         self.objects = {}
 
@@ -245,6 +257,9 @@ class directional_link(object):
     updating = False
 
     def __init__(self, source, *targets):
+        if len(targets) < 1:
+            raise TypeError('At least two traitlets must be provided.')
+        _validate_link(source, *targets)
         self.source = source
         self.targets = targets
 
@@ -276,9 +291,7 @@ def unlink(self):
         self.source = None
         self.targets = []
 
-def dlink(source, *targets):
-    """Shorter helper function returning a directional_link object"""
-    return directional_link(source, *targets)
+dlink = directional_link
 
 #-----------------------------------------------------------------------------
 # Base TraitType for all traits


Test patch:
diff --git a/IPython/html/widgets/tests/test_link.py b/IPython/html/widgets/tests/test_link.py
new file mode 100644
--- /dev/null
+++ b/IPython/html/widgets/tests/test_link.py
@@ -0,0 +1,39 @@
+# Copyright (c) IPython Development Team.
+# Distributed under the terms of the Modified BSD License.
+
+import nose.tools as nt
+
+from .. import jslink, jsdlink, ToggleButton
+from .test_interaction import setup, teardown
+
+def test_jslink_args():
+    with nt.assert_raises(TypeError):
+        jslink()
+    w1 = ToggleButton()
+    with nt.assert_raises(TypeError):
+        jslink((w1, 'value'))
+    
+    w2 = ToggleButton()
+    jslink((w1, 'value'), (w2, 'value'))
+
+    with nt.assert_raises(TypeError):
+        jslink((w1, 'value'), (w2, 'nosuchtrait'))
+
+    with nt.assert_raises(TypeError):
+        jslink((w1, 'value'), (w2, 'traits'))
+
+def test_jsdlink_args():
+    with nt.assert_raises(TypeError):
+        jsdlink()
+    w1 = ToggleButton()
+    with nt.assert_raises(TypeError):
+        jsdlink((w1, 'value'))
+    
+    w2 = ToggleButton()
+    jsdlink((w1, 'value'), (w2, 'value'))
+
+    with nt.assert_raises(TypeError):
+        jsdlink((w1, 'value'), (w2, 'nosuchtrait'))
+
+    with nt.assert_raises(TypeError):
+        jsdlink((w1, 'value'), (w2, 'traits'))
diff --git a/IPython/utils/tests/test_traitlets.py b/IPython/utils/tests/test_traitlets.py
--- a/IPython/utils/tests/test_traitlets.py
+++ b/IPython/utils/tests/test_traitlets.py
@@ -1187,6 +1187,25 @@ def b_callback(name, old, new):
         a.value = 4
         self.assertEqual(''.join(callback_count), 'ab')
         del callback_count[:]
+    
+    def test_validate_args(self):
+        class A(HasTraits):
+            value = Int()
+        class B(HasTraits):
+            count = Int()
+        a = A(value=9)
+        b = B(count=8)
+        b.value = 5
+        
+        with self.assertRaises(TypeError):
+            link((a, 'value'))
+        with self.assertRaises(TypeError):
+            link((a, 'value', 'count'), (b, 'count'))
+        with self.assertRaises(TypeError):
+            link((a, 'value'), (b, 'value'))
+        with self.assertRaises(TypeError):
+            link((a, 'traits'), (b, 'count'))
+
 
 class TestDirectionalLink(TestCase):
     def test_connect_same(self):
@@ -1253,6 +1272,25 @@ class A(HasTraits):
         a.value = 5
         self.assertNotEqual(a.value, b.value)
 
+    def test_validate_args(self):
+        class A(HasTraits):
+            value = Int()
+        class B(HasTraits):
+            count = Int()
+        a = A(value=9)
+        b = B(count=8)
+        b.value = 5
+        
+        with self.assertRaises(TypeError):
+            directional_link((a, 'value'))
+        with self.assertRaises(TypeError):
+            directional_link((a, 'value', 'count'), (b, 'count'))
+        with self.assertRaises(TypeError):
+            directional_link((a, 'value'), (b, 'value'))
+        with self.assertRaises(TypeError):
+            directional_link((a, 'traits'), (b, 'count'))
+
+
 class Pickleable(HasTraits):
     i = Int()
     j = Int()