Repository: bokeh/bokeh

Issue:
"Columns must be of the same length" warning does not show context
I'm getting warnings like the following:

```python
/home/mrocklin/Software/anaconda/lib/python3.6/site-packages/bokeh/models/sources.py:88: BokehUserWarning: ColumnDataSource's columns must be of the same length
  lambda: warnings.warn("ColumnDataSource's columns must be of the same length", BokehUserWarning))
```

These are great and alert me to the fact that I'm doing something incorrectly.  However, I have a dozen ColumnDataSources, so I'm still stuck trying to figure out where the error is.  It would be very helpful to add the names and lengths of the column data source to the warning 

    {k: len(v) for k, v in source.data.items()}


Discussion:
I started looking into this but I'm not sure how to include information about the object in the warning.  It looks like this is using a custom type checking module with which I am not familiar:

```python
    data = ColumnData(String, Seq(Any), help="""
    Mapping of column names to sequences of data. The data can be, e.g,
    Python lists or tuples, NumPy arrays, etc.
    """).asserts(lambda _, data: len(set(len(x) for x in data.values())) <= 1,
                 lambda: warnings.warn("ColumnDataSource's columns must be of the same length. "
                     "Current lengths: %s" % {k: len(v) for k, v in data.items()}, BokehUserWarning))
```

This fails because data is not around when creating the warning

Resolving patch:
diff --git a/bokeh/core/property/bases.py b/bokeh/core/property/bases.py
--- a/bokeh/core/property/bases.py
+++ b/bokeh/core/property/bases.py
@@ -276,17 +276,13 @@ def prepare_value(self, obj_or_cls, name, value):
                 else:
                     result = fn(obj, value)
 
-                if isinstance(result, bool):
-                    if not result:
-                        if isinstance(msg_or_fn, string_types):
-                            raise ValueError(msg_or_fn)
-                        else:
-                            msg_or_fn()
-                elif result is not None:
+                assert isinstance(result, bool)
+
+                if not result:
                     if isinstance(msg_or_fn, string_types):
-                        raise ValueError(msg_or_fn % result)
+                        raise ValueError(msg_or_fn)
                     else:
-                        msg_or_fn(result)
+                        msg_or_fn(obj, name, value)
 
         return self._wrap_container(value)
 
@@ -295,11 +291,48 @@ def has_ref(self):
         return False
 
     def accepts(self, tp, converter):
+        ''' Declare that other types may be converted to this property type.
+
+        Args:
+            tp (Property) :
+                A type that may be converted automatically to this property
+                type.
+
+            converter (callable) :
+                A function accepting ``value`` to perform conversion of the
+                value to this property type.
+
+        Returns:
+            self
+
+        '''
+
         tp = ParameterizedProperty._validate_type_param(tp)
         self.alternatives.append((tp, converter))
         return self
 
     def asserts(self, fn, msg_or_fn):
+        ''' Assert that prepared values satisfy given conditions.
+
+        Assertions are intended in enforce conditions beyond simple value
+        type validation. For instance, this method can be use to assert that
+        the columns of a ``ColumnDataSource`` all collectively have the same
+        length at all times.
+
+        Args:
+            fn (callable) :
+                A function accepting ``(obj, value)`` that returns True if the value
+                passes the assertion, or False othwise
+
+            msg_or_fn (str or callable) :
+                A message to print in case the assertion fails, or a function
+                accepting ``(obj, name, value)`` to call in in case the assertion
+                fails.
+
+        Returns:
+            self
+
+        '''
         self.assertions.append((fn, msg_or_fn))
         return self
 
diff --git a/bokeh/models/glyphs.py b/bokeh/models/glyphs.py
--- a/bokeh/models/glyphs.py
+++ b/bokeh/models/glyphs.py
@@ -407,11 +407,11 @@ class ImageRGBA(Glyph):
 
     rows = NumberSpec(None, help="""
     The numbers of rows in the images
-    """).asserts(False, lambda: deprecated((0, 12, 4), "ImageRGBA.rows", "2D array representation"))
+    """).asserts(False, lambda obj, name, value: deprecated((0, 12, 4), "ImageRGBA.rows", "2D array representation"))
 
     cols = NumberSpec(None, help="""
     The numbers of columns in the images
-    """).asserts(False, lambda: deprecated((0, 12, 4), "ImageRGBA.cols", "2D array representation"))
+    """).asserts(False, lambda obj, name, value: deprecated((0, 12, 4), "ImageRGBA.cols", "2D array representation"))
 
     dw = DistanceSpec(help="""
     The widths of the plot regions that the images will occupy.
diff --git a/bokeh/models/sources.py b/bokeh/models/sources.py
--- a/bokeh/models/sources.py
+++ b/bokeh/models/sources.py
@@ -86,8 +86,9 @@ class ColumnDataSource(ColumnarDataSource):
     Mapping of column names to sequences of data. The data can be, e.g,
     Python lists or tuples, NumPy arrays, etc.
     """).asserts(lambda _, data: len(set(len(x) for x in data.values())) <= 1,
-                 lambda: warnings.warn("ColumnDataSource's columns must be of the same length", BokehUserWarning))
-
+                 lambda obj, name, data: warnings.warn(
+                    "ColumnDataSource's columns must be of the same length. " +
+                    "Current lengths: %s" % ", ".join(sorted(str((k, len(v))) for k, v in data.items())), BokehUserWarning))
 
     def __init__(self, *args, **kw):
         ''' If called with a single argument that is a dict or


Test patch:
diff --git a/bokeh/core/property/tests/test_bases.py b/bokeh/core/property/tests/test_bases.py
new file mode 100644
--- /dev/null
+++ b/bokeh/core/property/tests/test_bases.py
@@ -0,0 +1,44 @@
+import pytest
+
+from bokeh.core.has_props import HasProps
+
+import bokeh.core.property.bases as pb
+
+def test_property_assert_bools():
+    hp = HasProps()
+    p = pb.Property()
+
+    p.asserts(True, "true")
+    assert p.prepare_value(hp, "foo", 10) == 10
+
+    p.asserts(False, "false")
+    with pytest.raises(ValueError) as e:
+        p.prepare_value(hp, "foo", 10)
+        assert str(e) == "false"
+
+def test_property_assert_functions():
+    hp = HasProps()
+    p = pb.Property()
+
+    p.asserts(lambda obj, value: True, "true")
+    p.asserts(lambda obj, value: obj is hp, "true")
+    p.asserts(lambda obj, value: value==10, "true")
+    assert p.prepare_value(hp, "foo", 10) == 10
+
+    p.asserts(lambda obj, value: False, "false")
+    with pytest.raises(ValueError) as e:
+        p.prepare_value(hp, "foo", 10)
+        assert str(e) == "false"
+
+def test_property_assert_msg_funcs():
+    hp = HasProps()
+    p = pb.Property()
+
+    def raise_(ex):
+        raise ex
+
+    p.asserts(False, lambda obj, name, value: raise_(ValueError("bad %s %s %s" % (hp==obj, name, value))))
+
+    with pytest.raises(ValueError) as e:
+        p.prepare_value(hp, "foo", 10)
+        assert str(e) == "bad True name, 10"
diff --git a/bokeh/models/tests/test_sources.py b/bokeh/models/tests/test_sources.py
--- a/bokeh/models/tests/test_sources.py
+++ b/bokeh/models/tests/test_sources.py
@@ -178,25 +178,25 @@ def test_data_column_lengths(self):
         with warnings.catch_warnings(record=True) as warns:
             ColumnDataSource(data=dict(a=[10, 11], b=[20, 21, 22]))
             self.assertEquals(len(warns), 1)
-            self.assertEquals(str(warns[0].message), "ColumnDataSource's columns must be of the same length")
+            self.assertEquals(str(warns[0].message), "ColumnDataSource's columns must be of the same length. Current lengths: ('a', 2), ('b', 3)")
 
         ds = ColumnDataSource()
         with warnings.catch_warnings(record=True) as warns:
             ds.data = dict(a=[10, 11], b=[20, 21, 22])
             self.assertEquals(len(warns), 1)
-            self.assertEquals(str(warns[0].message), "ColumnDataSource's columns must be of the same length")
+            self.assertEquals(str(warns[0].message), "ColumnDataSource's columns must be of the same length. Current lengths: ('a', 2), ('b', 3)")
 
         ds = ColumnDataSource(data=dict(a=[10, 11]))
         with warnings.catch_warnings(record=True) as warns:
             ds.data["b"] = [20, 21, 22]
             self.assertEquals(len(warns), 1)
-            self.assertEquals(str(warns[0].message), "ColumnDataSource's columns must be of the same length")
+            self.assertEquals(str(warns[0].message), "ColumnDataSource's columns must be of the same length. Current lengths: ('a', 2), ('b', 3)")
 
         ds = ColumnDataSource(data=dict(a=[10, 11], b=[20, 21]))
         with warnings.catch_warnings(record=True) as warns:
             ds.data.update(dict(a=[10, 11, 12]))
             self.assertEquals(len(warns), 1)
-            self.assertEquals(str(warns[0].message), "ColumnDataSource's columns must be of the same length")
+            self.assertEquals(str(warns[0].message), "ColumnDataSource's columns must be of the same length. Current lengths: ('a', 3), ('b', 2)")
 
     def test_set_data_from_json_list(self):
         ds = ColumnDataSource()