Repository: bokeh/bokeh

Issue:
document validation check needed for mis-matched y_range_name
Hello,

In the example below based on the twin axes example, if a user mis-spells the y_range_name there is no check on the python side for a y_range that matches that name existing at glyph creation or document creation (`show(p)`). Rather an error is reported in JS of `Cannot read property 'v_synthetic' of undefined`.


On Bokeh 0.12.10, Python 2.7, Win64, Chrome (Version 62.0.3202.94 (Official Build) (64-bit)).

```
from numpy import pi, arange, sin, linspace

from bokeh.plotting import output_file, figure, show
from bokeh.models import LinearAxis, Range1d

x = arange(-2*pi, 2*pi, 0.1)
y = sin(x)
y2 = linspace(0, 100, len(y))

output_file("twin_axis.html")

p = figure(x_range=(-6.5, 6.5), y_range=(-1.1, 1.1))

p.circle(x, y, color="red")

p.extra_y_ranges = {"foo": Range1d(start=0, end=100)}
p.circle(x, y2, color="blue", y_range_name="oof")
p.add_layout(LinearAxis(y_range_name="foo"), 'left')

show(p)
```

![image](https://user-images.githubusercontent.com/22305345/34007950-b2f1d86e-e0d1-11e7-97e4-25d039212421.png)

```
Uncaught TypeError: Cannot read property 'v_synthetic' of undefined
    at e.r.GlyphView.r.set_data (bokeh-0.12.10.min.js:27)
    at e.r.GlyphRendererView.e.set_data (bokeh-0.12.10.min.js:31)
    at e.r.GlyphRendererView.e.initialize (bokeh-0.12.10.min.js:31)
    at e.t (bokeh-0.12.10.min.js:22)
    at e [as constructor] (bokeh-0.12.10.min.js:1)
    at e [as constructor] (bokeh-0.12.10.min.js:31)
    at new e (bokeh-0.12.10.min.js:31)
    at Object.r.build_views (bokeh-0.12.10.min.js:1)
    at e.r.PlotCanvasView.e.build_levels (bokeh-0.12.10.min.js:30)
    at e.r.PlotCanvasView.e.initialize (bokeh-0.12.10.min.js:30)
```


Discussion:
This is a great idea, there's just a great many places that would need checking:

```
/models/annotations.py:
  414 :     x_range_name = String('default', help="""
  419 :     y_range_name = String('default', help="""
  477 :     x_range_name = String('default', help="""
  482 :     y_range_name = String('default', help="""
  538 :     x_range_name = String('default', help="""
  543 :     y_range_name = String('default', help="""
  657 :     x_range_name = String('default', help="""
  662 :     y_range_name = String('default', help="""
  758 :     x_range_name = String('default', help="""
  763 :     y_range_name = String('default', help="""
  791 :     x_range_name = String('default', help="""
  796 :     y_range_name = String('default', help="""
  830 :     x_range_name = String('default', help="""
  835 :     y_range_name = String('default', help="""
  862 :     x_range_name = String('default', help="""
  867 :     y_range_name = String('default', help="""
 1029 :     x_range_name = String('default', help="""
 1034 :     y_range_name = String('default', help="""
./models/axes.py:
   27 :     x_range_name = String('default', help="""
   33 :     y_range_name = String('default', help="""
./models/grids.py:
   33 :     x_range_name = String('default', help="""
   39 :     y_range_name = String('default', help="""
./models/renderers.py:
   56 :     x_range_name = String('default', help="""
   62 :     y_range_name = String('default', help="""
  122 :     x_range_name = String('default', help="""
  128 :     y_range_name = String('default', help="""
  193 :     x_range_name = String('default', help="""
  199 :     y_range_name = String('default', help="""
```
So we will have to find some way to do this with an economy of code, perhaps a mixin would work

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
@@ -66,7 +66,10 @@
     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.
+    FactorRange must specify a unique list of categorical factors for an axis.
+
+1020 *(BAD_EXTRA_RANGE_NAME)*
+    An extra range name is configued with a name that does not correspond to any range.
 
 9999 *(EXT)*
     Indicates that a custom error check has failed.
@@ -86,13 +89,14 @@
     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."),
+    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."),
+    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"),
+    1020: ("BAD_EXTRA_RANGE_NAME",                               "An extra range name is configued with a name that does not correspond to any range"),
     9999: ("EXT",                                                "Custom extension reports error"),
 }
 
diff --git a/bokeh/models/plots.py b/bokeh/models/plots.py
--- a/bokeh/models/plots.py
+++ b/bokeh/models/plots.py
@@ -10,7 +10,7 @@
 from ..core.property_mixins import LineProps, FillProps
 from ..core.query import find
 from ..core.validation import error, warning
-from ..core.validation.errors import REQUIRED_RANGE, REQUIRED_SCALE, INCOMPATIBLE_SCALE_AND_RANGE
+from ..core.validation.errors import  BAD_EXTRA_RANGE_NAME, REQUIRED_RANGE, REQUIRED_SCALE, INCOMPATIBLE_SCALE_AND_RANGE
 from ..core.validation.warnings import MISSING_RENDERERS
 from ..model import Model
 from ..util.string import nice_join
@@ -381,23 +381,25 @@ def _check_compatible_scale_and_ranges(self):
         y_ranges = list(self.extra_y_ranges.values())
         if self.y_range: y_ranges.append(self.y_range)
 
-        for rng in x_ranges:
-            if isinstance(rng, (DataRange1d, Range1d)) and not isinstance(self.x_scale, (LinearScale, LogScale)):
-                incompatible.append("incompatibility on x-dimension: %s, %s" %(rng, self.x_scale))
-            elif isinstance(rng, FactorRange) and not isinstance(self.x_scale, CategoricalScale):
-                incompatible.append("incompatibility on x-dimension: %s/%s" %(rng, self.x_scale))
-            # special case because CategoricalScale is a subclass of LinearScale, should be removed in future
-            if isinstance(rng, (DataRange1d, Range1d)) and isinstance(self.x_scale, CategoricalScale):
-                incompatible.append("incompatibility on x-dimension: %s, %s" %(rng, self.x_scale))
-
-        for rng in y_ranges:
-            if isinstance(rng, (DataRange1d, Range1d)) and not isinstance(self.y_scale, (LinearScale, LogScale)):
-                incompatible.append("incompatibility on y-dimension: %s/%s" %(rng, self.y_scale))
-            elif isinstance(rng, FactorRange) and not isinstance(self.y_scale, CategoricalScale):
-                incompatible.append("incompatibility on y-dimension: %s/%s" %(rng, self.y_scale))
-            # special case because CategoricalScale is a subclass of LinearScale, should be removed in future
-            if isinstance(rng, (DataRange1d, Range1d)) and isinstance(self.y_scale, CategoricalScale):
-                incompatible.append("incompatibility on y-dimension: %s, %s" %(rng, self.y_scale))
+        if self.x_scale is not None:
+            for rng in x_ranges:
+                if isinstance(rng, (DataRange1d, Range1d)) and not isinstance(self.x_scale, (LinearScale, LogScale)):
+                    incompatible.append("incompatibility on x-dimension: %s, %s" %(rng, self.x_scale))
+                elif isinstance(rng, FactorRange) and not isinstance(self.x_scale, CategoricalScale):
+                    incompatible.append("incompatibility on x-dimension: %s/%s" %(rng, self.x_scale))
+                # special case because CategoricalScale is a subclass of LinearScale, should be removed in future
+                if isinstance(rng, (DataRange1d, Range1d)) and isinstance(self.x_scale, CategoricalScale):
+                    incompatible.append("incompatibility on x-dimension: %s, %s" %(rng, self.x_scale))
+
+        if self.y_scale is not None:
+            for rng in y_ranges:
+                if isinstance(rng, (DataRange1d, Range1d)) and not isinstance(self.y_scale, (LinearScale, LogScale)):
+                    incompatible.append("incompatibility on y-dimension: %s/%s" %(rng, self.y_scale))
+                elif isinstance(rng, FactorRange) and not isinstance(self.y_scale, CategoricalScale):
+                    incompatible.append("incompatibility on y-dimension: %s/%s" %(rng, self.y_scale))
+                # special case because CategoricalScale is a subclass of LinearScale, should be removed in future
+                if isinstance(rng, (DataRange1d, Range1d)) and isinstance(self.y_scale, CategoricalScale):
+                    incompatible.append("incompatibility on y-dimension: %s, %s" %(rng, self.y_scale))
 
         if incompatible:
             return ", ".join(incompatible) + " [%s]" % self
@@ -407,6 +409,23 @@ def _check_missing_renderers(self):
         if len(self.renderers) == 0:
             return str(self)
 
+    @error(BAD_EXTRA_RANGE_NAME)
+    def _check_bad_extra_range_name(self):
+        msg = ""
+        for ref in self.references():
+            prop_names = ref.properties()
+            bad = []
+            if 'x_range_name' in prop_names and 'y_range_name' in prop_names:
+                if ref.x_range_name not in self.extra_x_ranges and ref.x_range_name != "default":
+                    bad.append(('x_range_name', ref.x_range_name))
+                if ref.y_range_name not in self.extra_y_ranges and ref.y_range_name != "default":
+                    bad.append(('y_range_name', ref.y_range_name))
+            if bad:
+                if msg: msg += ", "
+                msg += (", ".join("%s=%r" % (a, b) for (a,b) in bad) + " [%s]" % ref)
+        if msg:
+            return msg
+
     x_range = Instance(Range, help="""
     The (default) data range of the horizontal dimension of the plot.
     """)


Test patch:
diff --git a/bokeh/models/tests/test_plots.py b/bokeh/models/tests/test_plots.py
--- a/bokeh/models/tests/test_plots.py
+++ b/bokeh/models/tests/test_plots.py
@@ -8,9 +8,11 @@
 
 from __future__ import absolute_import
 
+import mock
 from mock import patch
 import pytest
 
+from bokeh.core.validation import check_integrity
 from bokeh.plotting import figure
 from bokeh.models import GlyphRenderer, Label, Plot, LinearAxis
 from bokeh.models.ranges import FactorRange, DataRange1d, Range1d
@@ -75,6 +77,64 @@ def test_arg_and_kwarg(self):
             self._plot.select('foo', type=PanTool)
         assert 'select accepts EITHER a positional argument, OR keyword arguments (not both).' == str(exc.value)
 
+class TestPlotValidation(object):
+
+    def test_missing_renderers(self):
+        p = figure()
+        p.renderers = []
+        with mock.patch('bokeh.core.validation.check.logger') as mock_logger:
+            check_integrity([p])
+        assert mock_logger.warning.call_count == 1
+        assert mock_logger.warning.call_args[0][0].startswith("W-1000 (MISSING_RENDERERS): Plot has no renderers")
+
+    def test_missing_scale(self):
+        p = figure()
+        p.x_scale = None
+        with mock.patch('bokeh.core.validation.check.logger') as mock_logger:
+            check_integrity([p])
+        assert mock_logger.error.call_count == 1
+        assert mock_logger.error.call_args[0][0].startswith("E-1008 (REQUIRED_SCALE): A required Scale object is missing: x_scale")
+
+        p.y_scale = None
+        with mock.patch('bokeh.core.validation.check.logger') as mock_logger:
+            check_integrity([p])
+        assert mock_logger.error.call_count == 1
+        assert mock_logger.error.call_args[0][0].startswith("E-1008 (REQUIRED_SCALE): A required Scale object is missing: x_scale, y_scale")
+
+    def test_missing_range(self):
+        p = figure()
+        p.x_range = None
+        with mock.patch('bokeh.core.validation.check.logger') as mock_logger:
+            check_integrity([p])
+        assert mock_logger.error.call_count == 1
+        assert mock_logger.error.call_args[0][0].startswith("E-1004 (REQUIRED_RANGE): A required Range object is missing: x_range")
+
+        p.y_range = None
+        with mock.patch('bokeh.core.validation.check.logger') as mock_logger:
+            check_integrity([p])
+        assert mock_logger.error.call_count == 1
+        assert mock_logger.error.call_args[0][0].startswith("E-1004 (REQUIRED_RANGE): A required Range object is missing: x_range, y_range")
+
+    def test_bad_extra_range_name(self):
+        p = figure()
+        p.xaxis.x_range_name="junk"
+        with mock.patch('bokeh.core.validation.check.logger') as mock_logger:
+            check_integrity([p])
+        assert mock_logger.error.call_count == 1
+        assert mock_logger.error.call_args[0][0].startswith(
+            "E-1020 (BAD_EXTRA_RANGE_NAME): An extra range name is configued with a name that does not correspond to any range: x_range_name='junk' [LinearAxis"
+        )
+
+        p = figure()
+        p.extra_x_ranges['foo'] = Range1d()
+        p.grid.x_range_name="junk"
+        with mock.patch('bokeh.core.validation.check.logger') as mock_logger:
+            check_integrity([p])
+        assert mock_logger.error.call_count == 1
+        assert mock_logger.error.call_args[0][0].startswith(
+            "E-1020 (BAD_EXTRA_RANGE_NAME): An extra range name is configued with a name that does not correspond to any range: x_range_name='junk' [Grid"
+        )
+        assert mock_logger.error.call_args[0][0].count("Grid") == 2
 
 def test_plot_add_layout_raises_error_if_not_render():
     plot = figure()
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
@@ -618,26 +618,26 @@ def test_data_column_lengths(self):
 
         with warnings.catch_warnings(record=True) as warns:
             ColumnDataSource(data=dict(a=[10, 11], b=[20, 21, 22]))
-            assert len(warns) == 1
-            assert str(warns[0].message) == "ColumnDataSource's columns must be of the same length. Current lengths: ('a', 2), ('b', 3)"
+        assert len(warns) == 1
+        assert 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])
-            assert len(warns) == 1
-            assert str(warns[0].message) == "ColumnDataSource's columns must be of the same length. Current lengths: ('a', 2), ('b', 3)"
+        assert len(warns) == 1
+        assert 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]
-            assert len(warns) == 1
-            assert str(warns[0].message) == "ColumnDataSource's columns must be of the same length. Current lengths: ('a', 2), ('b', 3)"
+        assert len(warns) == 1
+        assert 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]))
-            assert len(warns) == 1
-            assert str(warns[0].message) == "ColumnDataSource's columns must be of the same length. Current lengths: ('a', 3), ('b', 2)"
+        assert len(warns) == 1
+        assert 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()