Repository: bokeh/bokeh

Issue:
 Should issue a validation warning when the range values are not unique
When `x_range` and/or `y_range` contain duplicates `rect`s are offset in unexpected ways. This should trigger a warning as users may expect that `x_range` and `y_range` should have the same length as there are point in the grid (eg. as is the case in the les_mis example in the gallery) 

cf https://gist.github.com/rcompton/67bb614b86297c970451 and https://groups.google.com/a/continuum.io/forum/#!msg/bokeh/rvFcJV5_WQ8/jlm13N5qCAAJ and http://stackoverflow.com/questions/33936852/python-bokeh-offset-with-rect-plotting/34213135#34213135 and http://stackoverflow.com/questions/24179776/issues-with-correlation-graphs-in-bokeh/24208660#24208660



Discussion:

Resolving patch:
diff --git a/bokeh/core/validation/errors.py b/bokeh/core/validation/errors.py
--- a/bokeh/core/validation/errors.py
+++ b/bokeh/core/validation/errors.py
@@ -65,31 +65,35 @@
 1018 *(NO_RANGE_TOOL_RANGES)*
     The RangeTool must have at least one of x_range or y_range configured
 
+1019 *(DUPLICATE_FACTORS)*
+    FactorRange must specicy a unique list of categorical factors for an axis.
+
 9999 *(EXT)*
     Indicates that a custom error check has failed.
 
 '''
 
 codes = {
-    1001: ("BAD_COLUMN_NAME",        "Glyph refers to nonexistent column name"),
-    1002: ("MISSING_GLYPH",          "Glyph renderer has no glyph set"),
-    1003: ("NO_SOURCE_FOR_GLYPH",    "Glyph renderer has no data source"),
-    1004: ("REQUIRED_RANGE",         "A required Range object is missing"),
-    1005: ("MISSING_GOOGLE_API_KEY", "Google now requires API keys for all Google Maps usage"),
+    1001: ("BAD_COLUMN_NAME",                                    "Glyph refers to nonexistent column name"),
+    1002: ("MISSING_GLYPH",                                      "Glyph renderer has no glyph set"),
+    1003: ("NO_SOURCE_FOR_GLYPH",                                "Glyph renderer has no data source"),
+    1004: ("REQUIRED_RANGE",                                     "A required Range object is missing"),
+    1005: ("MISSING_GOOGLE_API_KEY",                             "Google now requires API keys for all Google Maps usage"),
     1006: ("NON_MATCHING_DATA_SOURCES_ON_LEGEND_ITEM_RENDERERS", "LegendItem.label is a field, but renderer data sources don't match"),
-    1007: ("MISSING_MERCATOR_DIMENSION", "Mercator Tickers and Formatters must have their dimension property set to 'lat' or 'lon'"),
-    1008: ("REQUIRED_SCALE",         "A required Scale object is missing"),
-    1009: ("INCOMPATIBLE_SCALE_AND_RANGE", "A Scale is incompatible with one or more ranges on the same plot dimension"),
-    1010: ("CDSVIEW_SOURCE_DOESNT_MATCH", "CDSView used by Glyph renderer must have a source that matches the Glyph renderer's data source"),
-    1011: ("MALFORMED_GRAPH_SOURCE", "The GraphSource is incorrectly configured"),
-    1012: ("INCOMPATIBLE_MAP_RANGE_TYPE", "Map plots can only support Range1d types, not data ranges"),
-    1013: ("INCOMPATIBLE_POINT_DRAW_RENDERER", "PointDrawTool renderers may only reference XYGlyph models."),
-    1014: ("INCOMPATIBLE_BOX_EDIT_RENDERER", "BoxEditTool renderers may only reference Rect glyph models"),
-    1015: ("INCOMPATIBLE_POLY_DRAW_RENDERER", "PolyDrawTool renderers may only reference MultiLine and Patches glyph models."),
-    1016: ("INCOMPATIBLE_POLY_EDIT_RENDERER", "PolyEditTool renderers may only reference MultiLine and Patches glyph models"),
-    1017: ("INCOMPATIBLE_POLY_EDIT_VERTEX_RENDERER", "PolyEditTool vertex_renderer may only reference XYGlyph models"),
-    1018: ("NO_RANGE_TOOL_RANGES", "RangeTool must have at least one of x_range or y_range configured"),
-    9999: ("EXT",                    "Custom extension reports error"),
+    1007: ("MISSING_MERCATOR_DIMENSION",                         "Mercator Tickers and Formatters must have their dimension property set to 'lat' or 'lon'"),
+    1008: ("REQUIRED_SCALE",                                     "A required Scale object is missing"),
+    1009: ("INCOMPATIBLE_SCALE_AND_RANGE",                       "A Scale is incompatible with one or more ranges on the same plot dimension"),
+    1010: ("CDSVIEW_SOURCE_DOESNT_MATCH",                        "CDSView used by Glyph renderer must have a source that matches the Glyph renderer's data source"),
+    1011: ("MALFORMED_GRAPH_SOURCE",                             "The GraphSource is incorrectly configured"),
+    1012: ("INCOMPATIBLE_MAP_RANGE_TYPE",                        "Map plots can only support Range1d types, not data ranges"),
+    1013: ("INCOMPATIBLE_POINT_DRAW_RENDERER",                   "PointDrawTool renderers may only reference XYGlyph models."),
+    1014: ("INCOMPATIBLE_BOX_EDIT_RENDERER",                     "BoxEditTool renderers may only reference Rect glyph models"),
+    1015: ("INCOMPATIBLE_POLY_DRAW_RENDERER",                    "PolyDrawTool renderers may only reference MultiLine and Patches glyph models."),
+    1016: ("INCOMPATIBLE_POLY_EDIT_RENDERER",                    "PolyEditTool renderers may only reference MultiLine and Patches glyph models"),
+    1017: ("INCOMPATIBLE_POLY_EDIT_VERTEX_RENDERER",             "PolyEditTool vertex_renderer may only reference XYGlyph models"),
+    1018: ("NO_RANGE_TOOL_RANGES",                               "RangeTool must have at least one of x_range or y_range configured"),
+    1019: ("DUPLICATE_FACTORS",                                  "FactorRange must specicy a unique list of categorical factors for an axis"),
+    9999: ("EXT",                                                "Custom extension reports error"),
 }
 
 for code in codes:
diff --git a/bokeh/core/validation/warnings.py b/bokeh/core/validation/warnings.py
--- a/bokeh/core/validation/warnings.py
+++ b/bokeh/core/validation/warnings.py
@@ -16,10 +16,10 @@
 '''
 
 codes = {
-    1000: ("MISSING_RENDERERS",           "Plot has no renderers"),
-    1002: ("EMPTY_LAYOUT",                "Layout has no children"),
-    1004: ("BOTH_CHILD_AND_ROOT",         "Models should not be a document root if they are in a layout box"),
-    9999: ("EXT",                         "Custom extension reports warning"),
+    1000: ("MISSING_RENDERERS",   "Plot has no renderers"),
+    1002: ("EMPTY_LAYOUT",        "Layout has no children"),
+    1004: ("BOTH_CHILD_AND_ROOT", "Models should not be a document root if they are in a layout box"),
+    9999: ("EXT",                 "Custom extension reports warning"),
 }
 
 for code in codes:
diff --git a/bokeh/models/ranges.py b/bokeh/models/ranges.py
--- a/bokeh/models/ranges.py
+++ b/bokeh/models/ranges.py
@@ -5,10 +5,14 @@
 '''
 from __future__ import absolute_import
 
+from collections import Counter
+
 from ..core.enums import PaddingUnits, StartEnd
 from ..core.has_props import abstract
 from ..core.properties import (Bool, Datetime, Either, Enum, Float, Instance, Int,
                                List, MinMaxBounds, Seq, String, TimeDelta, Tuple)
+from ..core.validation import error
+from ..core.validation.errors import DUPLICATE_FACTORS
 from ..model import Model
 
 from .callbacks import Callback
@@ -400,3 +404,9 @@ def __init__(self, *args, **kwargs):
         elif args:
             kwargs['factors'] = list(args)
         super(FactorRange, self).__init__(**kwargs)
+
+    @error(DUPLICATE_FACTORS)
+    def _check_duplicate_factors(self):
+        dupes = [item for item, count in Counter(self.factors).items() if count > 1]
+        if dupes:
+            return "duplicate factors found: %s" % ', '.join(repr(x) for x in dupes)


Test patch:
diff --git a/bokeh/models/tests/test_ranges.py b/bokeh/models/tests/test_ranges.py
--- a/bokeh/models/tests/test_ranges.py
+++ b/bokeh/models/tests/test_ranges.py
@@ -1,166 +1,169 @@
 from __future__ import absolute_import
 
+import mock
 import pytest
 
+from bokeh.core.validation import check_integrity
 from bokeh.models import Range1d, DataRange1d, FactorRange
 
 from .utils.property_utils import check_properties_existence
 
-
-# ------------------------
-# Range1d test
-# ------------------------
-
-def test_Range1d():
-    r = Range1d()
-    check_properties_existence(r, [
-        "callback",
-        "start",
-        "end",
-        "reset_start",
-        "reset_end",
-        "bounds",
-        "min_interval",
-        "max_interval"],
-    )
-
-
-def test_range1d_init_with_positional_arguments():
-    range1d = Range1d(1, 2)
-    assert range1d.start == 1
-    assert range1d.end == 2
-    assert range1d.bounds is None
-
-
-def test_range1d_init_with_keyword_arguments():
-    range1d = Range1d(start=1, end=2)
-    assert range1d.start == 1
-    assert range1d.end == 2
-    assert range1d.bounds is None
-
-
-def test_range1d_cannot_initialize_with_both_keyword_and_positional_arguments():
-    with pytest.raises(ValueError):
-        Range1d(1, 2, start=1, end=2)
-
-
-def test_range1d_cannot_initialize_with_three_positional_arguments():
-    with pytest.raises(ValueError):
-        Range1d(1, 2, 3)
-
-
-def test_range1d_with_max_bound_smaller_than_min_bounded_raises_valueerror():
-    with pytest.raises(ValueError):
-        Range1d(1, 2, bounds=(1, 0))
-    with pytest.raises(ValueError):
-        Range1d(1, 2, bounds=[1, 0])
-
-
-def test_range1d_bounds_with_text_rejected_as_the_correct_value_error():
-    with pytest.raises(ValueError) as e:
-        Range1d(1, 2, bounds="21")  # The string is indexable, so this may not fail properly
-    assert e.value.args[0].startswith('expected an element of either')
-
-
-def test_range1d_bounds_with_three_item_tuple_raises_valueerror():
-    with pytest.raises(ValueError):
-        Range1d(1, 2, bounds=(0, 1, 2))
-
-
-# ------------------------
-# DataRange1d test
-# ------------------------
-
-def test_DataRange1d():
-    r = DataRange1d()
-    check_properties_existence(r, [
-        "callback",
-        "names",
-        "renderers",
-        "range_padding",
-        "range_padding_units",
-        "flipped",
-        "follow",
-        "follow_interval",
-        "default_span",
-        "start",
-        "end",
-        "bounds",
-        "min_interval",
-        "max_interval"],
-    )
-
-def test_datarange1d_init_with_no_arguments():
-    datarange1d = DataRange1d()
-    assert datarange1d.start is None
-    assert datarange1d.end is None
-    assert datarange1d.bounds is None
-
-
-def test_datarange1d_init_with_follow_sets_bounds_to_none():
-    datarange1d = DataRange1d(follow="start")
-    assert datarange1d.follow == "start"
-    assert datarange1d.bounds is None
-
-
-def test_datarange1d_init_with_bad_bounds():
-    with pytest.raises(ValueError):
-        DataRange1d(1, 2, bounds=(1, 0))
-    with pytest.raises(ValueError):
-        DataRange1d(1, 2, bounds=[1, 0])
-    with pytest.raises(ValueError):
-        Range1d(1, 2, bounds="21")
-
-
-# ------------------------
-# Factor range test
-# ------------------------
-
-def test_FactorRange():
-    r = FactorRange()
-    check_properties_existence(r, [
-        "callback",
-        "factors",
-        "factor_padding",
-        "group_padding",
-        "subgroup_padding",
-        "range_padding",
-        "range_padding_units",
-        "start",
-        "end",
-        "bounds",
-        "min_interval",
-        "max_interval"],
-    )
-
-def test_factorrange_init_defauls():
-    factor_range = FactorRange("a", "b")
-    assert factor_range.factors == ["a", "b"]
-    assert factor_range.range_padding == 0
-    assert factor_range.range_padding_units == "percent"
-    assert factor_range.factor_padding == 0
-    assert factor_range.group_padding == 1.4
-    assert factor_range.subgroup_padding == 0.8
-    assert factor_range.bounds == None
-    assert factor_range.min_interval == None
-    assert factor_range.max_interval == None
-
-def test_factorrange_init_with_positional_arguments():
-    factor_range = FactorRange("a", "b")
-    assert factor_range.factors == ["a", "b"]
-
-    factor_range = FactorRange(["a", "x"], ["b", "y"])
-    assert factor_range.factors == [["a", "x"], ["b", "y"]]
-
-    factor_range = FactorRange(["a", "x", "1'"], ["b", "y", "2"])
-    assert factor_range.factors == [["a", "x", "1'"], ["b", "y", "2"]]
-
-
-def test_factorrange_init_with_keyword_arguments():
-    factor_range = FactorRange(factors=["a", "b", "c", "d", "e"])
-    assert factor_range.factors == ["a", "b", "c", "d", "e"]
-
-
-def test_factorrange_cannot_initialize_with_both_keyword_and_positional_arguments():
-    with pytest.raises(ValueError):
-        Range1d(["a", "b", "c"], factors=["a", "b", "c"])
+class Test_Range1d(object):
+
+    def test_basic(self):
+        r = Range1d()
+        check_properties_existence(r, [
+            "callback",
+            "start",
+            "end",
+            "reset_start",
+            "reset_end",
+            "bounds",
+            "min_interval",
+            "max_interval"],
+        )
+
+    def test_init_with_positional_arguments(self):
+        range1d = Range1d(1, 2)
+        assert range1d.start == 1
+        assert range1d.end == 2
+        assert range1d.bounds is None
+
+    def test_init_with_keyword_arguments(self):
+        range1d = Range1d(start=1, end=2)
+        assert range1d.start == 1
+        assert range1d.end == 2
+        assert range1d.bounds is None
+
+    def test_cannot_initialize_with_both_keyword_and_positional_arguments(self):
+        with pytest.raises(ValueError):
+            Range1d(1, 2, start=1, end=2)
+
+
+    def test_cannot_initialize_with_three_positional_arguments(self):
+        with pytest.raises(ValueError):
+            Range1d(1, 2, 3)
+
+
+    def test_with_max_bound_smaller_than_min_bounded_raises_valueerror(self):
+        with pytest.raises(ValueError):
+            Range1d(1, 2, bounds=(1, 0))
+        with pytest.raises(ValueError):
+            Range1d(1, 2, bounds=[1, 0])
+
+
+    def test_bounds_with_text_rejected_as_the_correct_value_error(self):
+        with pytest.raises(ValueError) as e:
+            Range1d(1, 2, bounds="21")  # The string is indexable, so this may not fail properly
+        assert e.value.args[0].startswith('expected an element of either')
+
+
+    def test_bounds_with_three_item_tuple_raises_valueerror(self):
+        with pytest.raises(ValueError):
+            Range1d(1, 2, bounds=(0, 1, 2))
+
+class Test_DataRange1d(object):
+
+    def test_basic(self):
+        r = DataRange1d()
+        check_properties_existence(r, [
+            "callback",
+            "names",
+            "renderers",
+            "range_padding",
+            "range_padding_units",
+            "flipped",
+            "follow",
+            "follow_interval",
+            "default_span",
+            "start",
+            "end",
+            "bounds",
+            "min_interval",
+            "max_interval"],
+        )
+
+    def test_init_with_no_arguments(self):
+        datarange1d = DataRange1d()
+        assert datarange1d.start is None
+        assert datarange1d.end is None
+        assert datarange1d.bounds is None
+
+    def test_init_with_follow_sets_bounds_to_none(self):
+        datarange1d = DataRange1d(follow="start")
+        assert datarange1d.follow == "start"
+        assert datarange1d.bounds is None
+
+    def test_init_with_bad_bounds(self):
+        with pytest.raises(ValueError):
+            DataRange1d(1, 2, bounds=(1, 0))
+        with pytest.raises(ValueError):
+            DataRange1d(1, 2, bounds=[1, 0])
+        with pytest.raises(ValueError):
+            Range1d(1, 2, bounds="21")
+
+
+class Test_FactorRange(object):
+
+    def test_basic(self):
+        r = FactorRange()
+        check_properties_existence(r, [
+            "callback",
+            "factors",
+            "factor_padding",
+            "group_padding",
+            "subgroup_padding",
+            "range_padding",
+            "range_padding_units",
+            "start",
+            "end",
+            "bounds",
+            "min_interval",
+            "max_interval"],
+        )
+
+    def test_init_defauls(self):
+        factor_range = FactorRange("a", "b")
+        assert factor_range.factors == ["a", "b"]
+        assert factor_range.range_padding == 0
+        assert factor_range.range_padding_units == "percent"
+        assert factor_range.factor_padding == 0
+        assert factor_range.group_padding == 1.4
+        assert factor_range.subgroup_padding == 0.8
+        assert factor_range.bounds == None
+        assert factor_range.min_interval == None
+        assert factor_range.max_interval == None
+
+    def test_init_with_positional_arguments(self):
+        factor_range = FactorRange("a", "b")
+        assert factor_range.factors == ["a", "b"]
+
+        factor_range = FactorRange(["a", "x"], ["b", "y"])
+        assert factor_range.factors == [["a", "x"], ["b", "y"]]
+
+        factor_range = FactorRange(["a", "x", "1'"], ["b", "y", "2"])
+        assert factor_range.factors == [["a", "x", "1'"], ["b", "y", "2"]]
+
+    def test_init_with_keyword_arguments(self):
+        factor_range = FactorRange(factors=["a", "b", "c", "d", "e"])
+        assert factor_range.factors == ["a", "b", "c", "d", "e"]
+
+    def test_cannot_initialize_with_both_keyword_and_positional_arguments(self):
+        with pytest.raises(ValueError):
+            FactorRange(["a", "b", "c"], factors=["a", "b", "c"])
+
+    def test_duplicate_factors_raises_validation_error(self):
+        r = FactorRange("foo", "bar", "foo")
+        with mock.patch('bokeh.core.validation.check.logger') as mock_logger:
+            check_integrity([r])
+        assert mock_logger.error.call_count == 1
+
+        r = FactorRange(factors=[("foo", "a"), ("foo", "b"),  ("foo", "a")])
+        with mock.patch('bokeh.core.validation.check.logger') as mock_logger:
+            check_integrity([r])
+        assert mock_logger.error.call_count == 1
+
+        r = FactorRange(factors=[("foo", "a", "1"), ("foo", "a", "2"),  ("foo", "a", "1")])
+        with mock.patch('bokeh.core.validation.check.logger') as mock_logger:
+            check_integrity([r])
+        assert mock_logger.error.call_count == 1