Repository: bokeh/bokeh

Issue:
Add a warning for : in categorical strings
See #516



Discussion:

Resolving patch:
diff --git a/bokeh/models/plots.py b/bokeh/models/plots.py
--- a/bokeh/models/plots.py
+++ b/bokeh/models/plots.py
@@ -11,12 +11,13 @@
 from ..properties import Bool, Int, String, Color, Enum, Auto, Instance, Either, List, Dict, Include
 from ..query import find
 from ..util.string import nice_join
-from ..validation.warnings import MISSING_RENDERERS, NO_GLYPH_RENDERERS, EMPTY_LAYOUT
+from ..validation.warnings import (MISSING_RENDERERS, NO_GLYPH_RENDERERS,
+    EMPTY_LAYOUT, MALFORMED_CATEGORY_LABEL)
 from ..validation.errors import REQUIRED_RANGE
 from .. import validation
 
 from .glyphs import Glyph
-from .ranges import Range, Range1d
+from .ranges import Range, Range1d, FactorRange
 from .renderers import Renderer, GlyphRenderer
 from .sources import DataSource, ColumnDataSource
 from .tools import Tool, ToolEvents
@@ -262,6 +263,28 @@ def _check_no_glyph_renderers(self):
         if len(self.select(GlyphRenderer)) == 0:
             return str(self)
 
+    @validation.warning(MALFORMED_CATEGORY_LABEL)
+    def _check_colon_in_category_label(self):
+        if not self.x_range: return
+        if not self.y_range: return
+
+        broken = []
+
+        for range_name in ['x_range', 'y_range']:
+            category_range = getattr(self, range_name)
+            if not isinstance(category_range, FactorRange): continue
+
+            for value in category_range.factors:
+                if not isinstance(value, string_types): break
+                if ':' in value:
+                    broken.append((range_name, value))
+                    break
+
+        if broken:
+            field_msg = ' '.join('[range:%s] [first_value: %s]' % (field, value)
+                                 for field, value in broken)
+            return '%s [renderer: %s]' % (field_msg, self)
+
     x_range = Instance(Range, help="""
     The (default) data range of the horizontal dimension of the plot.
     """)
diff --git a/bokeh/models/renderers.py b/bokeh/models/renderers.py
--- a/bokeh/models/renderers.py
+++ b/bokeh/models/renderers.py
@@ -4,16 +4,24 @@
 """
 from __future__ import absolute_import
 
+import logging
+
+from six import string_types
+
 from ..plot_object import PlotObject
 from ..properties import Int, String, Enum, Instance, List, Dict, Tuple, Include
 from ..mixins import LineProps, TextProps
 from ..enums import Units, Orientation, RenderLevel
 from ..validation.errors import BAD_COLUMN_NAME, MISSING_GLYPH, NO_SOURCE_FOR_GLYPH
+from ..validation.warnings import MALFORMED_CATEGORY_LABEL
 from .. import validation
 
 from .sources import DataSource
 from .glyphs import Glyph
 
+logger = logging.getLogger(__name__)
+
+
 class Renderer(PlotObject):
     """ A base class for renderer types. ``Renderer`` is not
     generally useful to instantiate on its own.
@@ -45,6 +53,34 @@ def _check_bad_column_name(self):
         if missing:
             return "%s [renderer: %s]" % (", ".join(sorted(missing)), self)
 
+    @validation.warning(MALFORMED_CATEGORY_LABEL)
+    def _check_colon_in_category_label(self):
+        if not self.glyph: return
+        if not self.data_source: return
+        vm = self.glyph.vm_serialize()
+        labels = (label for label in ['x', 'y']
+                  if label in vm and 'field' in vm[label])
+
+        broken = []
+
+        for label in labels:
+            try:
+                for value in self.data_source.data[vm[label]['field']]:
+                    if not isinstance(value, string_types): break
+                    if ':' in value:
+                        broken.append((vm[label]['field'], value))
+                        break
+            except KeyError:
+                logging.info(
+                    'Can\'t check category labels for %s data source',
+                    self.data_source
+                )
+
+        if broken:
+            field_msg = ' '.join('[field:%s] [first_value: %s]' % (field, value)
+                                 for field, value in broken)
+            return '%s [renderer: %s]' % (field_msg, self)
+
     data_source = Instance(DataSource, help="""
     Local data source to use when rendering glyphs on the plot.
     """)
diff --git a/bokeh/validation/warnings.py b/bokeh/validation/warnings.py
--- a/bokeh/validation/warnings.py
+++ b/bokeh/validation/warnings.py
@@ -9,16 +9,20 @@
 1002 : *EMPTY_LAYOUT*
     A layout model has no children (will result in a blank layout).
 
+1003 : *MALFORMED_CATEGORY_LABEL*
+    Category labels are malformed (will result in a blank layout).
+
 9999 : *EXT*
     Indicates that a custom warning check has failed.
 
 '''
 
 codes = {
-    1000: ("MISSING_RENDERERS",     "Plot has no renderers"),
-    1001: ("NO_GLYPH_RENDERERS",    "Plot has no glyph renderers"),
-    1002: ("EMPTY_LAYOUT",          "Layout has no children"),
-    9999: ("EXT",                   "Custom extension reports warning"),
+    1000: ("MISSING_RENDERERS",         "Plot has no renderers"),
+    1001: ("NO_GLYPH_RENDERERS",        "Plot has no glyph renderers"),
+    1002: ("EMPTY_LAYOUT",              "Layout has no children"),
+    1003: ("MALFORMED_CATEGORY_LABEL",  "Category labels are malformed"),
+    9999: ("EXT",                       "Custom extension reports warning"),
 }
 
 for code in codes:


Test patch:
diff --git a/bokeh/models/tests/test_renderers.py b/bokeh/models/tests/test_renderers.py
new file mode 100644
--- /dev/null
+++ b/bokeh/models/tests/test_renderers.py
@@ -0,0 +1,74 @@
+from __future__ import absolute_import
+
+import unittest
+
+from bokeh.models.renderers import GlyphRenderer
+from bokeh.plotting import ColumnDataSource, figure
+from bokeh.models.ranges import DataRange1d
+
+
+class TestGlyphRenderer(unittest.TestCase):
+    def test_warning_about_colons_in_column_labels(self):
+        invalid_labels = ['0', '1:0']
+        ds = ColumnDataSource(data={'a': invalid_labels, 'b': invalid_labels})
+        plot = figure()
+        plot.rect('a', 'b', 1, 1, source=ds)
+        renderer = plot.select({'type': GlyphRenderer})[0]
+
+        errors = renderer._check_colon_in_category_label()
+
+        self.assertEqual(errors, [(
+            1003,
+            'MALFORMED_CATEGORY_LABEL',
+            'Category labels are malformed',
+            '[field:a] [first_value: 1:0] [field:b] [first_value: 1:0] '
+            '[renderer: '
+            'GlyphRenderer, ViewModel:GlyphRenderer, ref _id: '
+            '%s]' % renderer._id
+        )])
+
+    def test_warning_about_colons_in_column_labels_for_axis(self):
+        invalid_labels = ['0', '1', '2:0'] 
+        plot = figure(
+            x_range=invalid_labels,
+            y_range=invalid_labels,
+            plot_width=900,
+            plot_height=400,
+        )
+
+        errors = plot._check_colon_in_category_label()
+
+        self.assertEqual(errors, [(
+            1003,
+            'MALFORMED_CATEGORY_LABEL',
+            'Category labels are malformed',
+            '[range:x_range] [first_value: 2:0] '
+            '[range:y_range] [first_value: 2:0] '
+            '[renderer: Figure, ViewModel:Plot, ref _id: '
+            '%s]' % plot._id
+        )])
+
+    def test_validates_colons_only_in_factorial_range(self):
+        plot = figure(
+            x_range=DataRange1d(start=0.0, end=2.2),
+            y_range=['0', '1', '2:0'],
+            plot_width=900,
+            plot_height=400,
+        )
+
+        errors = plot._check_colon_in_category_label()
+
+        self.assertEqual(errors, [(
+            1003,
+            'MALFORMED_CATEGORY_LABEL',
+            'Category labels are malformed',
+            '[range:y_range] [first_value: 2:0] '
+            '[renderer: Figure, ViewModel:Plot, ref _id: '
+            '%s]' % plot._id
+        )])
+
+
+
+
+if __name__ == '__main__':
+    unittest.main()