Repository: bokeh/bokeh

Issue:
Non-unit specs accept still allow `units` field
At some point `x` and `y` properties' types were changed to `NumberSpec` which is `data` units only. However, you can still set `screen` units. Obviously nothing will happen in bokehjs and no errors or warnings will be shown anywhere. This results in `iris_splom` example being broken, because text is displayed in `data` units, very far from actual data.



Discussion:
btw. How I'm supposed to put text on screen in `screen` units? Set `x`, `y` to 0 and configure `x_offset` and `y_offset` instead? @bryevdv, this is migration guide you want to leave off after 1.0.

btw. `NumberSpec` for coordinates doesn't feel right when combined with categorical data.

@mattpap At some point there was a large PR and a long discussion. TLDR: efficient spatial indexing with glyphs that could positioned in screen coordinates, or worse, a mix of screen and data coordinates was impossibly broken and unworkable. But that's OK, because it was realized that if you want to position things with screen coordinates, it's almost certainly the case that what you actually want is an annotation of some sort. So, the plan is for things like the text on iris splom to become proper annotation objects. @canavandl is working on adding new annotation types (h/v spans, text, math text, etc.) right now. 

You will have to be more specific about how are you setting the units to screen, and provide actual code. When I set  `x_units = "screen"`, it is caught by the general machinery that triggers on unknown properties:

```
In [2]: from bokeh.plotting import output_file, figure, show

In [3]: p = figure()

In [4]: p.circle(x=[1,2,3], x_units="screen", y=10)
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-4-a82adcf41efd> in <module>()
----> 1 p.circle(x=[1,2,3], x_units="screen", y=10)

/Users/bryan/work/bokeh/bokeh/_glyph_functions.py in func(plot, *args, **kwargs)
     35 
     36         # create the default and nonselection glyphs
---> 37         glyph = _make_glyph(glyphclass, kwargs, glyph_ca)
     38         nsglyph = _make_glyph(glyphclass, kwargs, nsglyph_ca)
     39 

/Users/bryan/work/bokeh/bokeh/plotting_helpers.py in _make_glyph(glyphclass, kws, extra)
    142         kws = kws.copy()
    143         kws.update(extra)
--> 144         return glyphclass(**kws)
    145 
    146 def _update_legend(plot, legend_name, glyph_renderer):

/Users/bryan/work/bokeh/bokeh/plot_object.py in __init__(self, **kwargs)
     88 
     89         if not block_events:
---> 90             super(PlotObject, self).__init__(**kwargs)
     91             self.setup_events()
     92         else:

/Users/bryan/work/bokeh/bokeh/properties.py in __init__(self, **properties)
    355 
    356         for name, value in properties.items():
--> 357             setattr(self, name, value)
    358 
    359     def __setattr__(self, name, value):

/Users/bryan/work/bokeh/bokeh/properties.py in __setattr__(self, name, value)
    369 
    370             raise AttributeError("unexpected attribute '%s' to %s, %s attributes are %s" %
--> 371                 (name, self.__class__.__name__, text, nice_join(matches)))
    372 
    373     def clone(self):

AttributeError: unexpected attribute 'x_units' to Circle, similar attributes are radius_units
```

So I am not really certain what you are referring to when you say it still accepts screen units.

I agree "NumberSpec" feels weird for categorical data and that the "Spec" hierarchy can be tightened up more.  

Guessing you mean this?

```
In [10]: p.circle(x=dict(value=10, units="screen"), y=10)
Out[10]: <bokeh.plotting.Figure at 0x108a04240>
```

Sure enough, we can probably expand our validation to check the values for correctness. That would be a welcome addition, but a few comments:
- If you had provided code from the start it would have saved me 15 minutes trying to guess what you meant
- I would be very surprised if the number of people trying to set the units this way is greater than a handful


Resolving patch:
diff --git a/bokeh/core/properties.py b/bokeh/core/properties.py
--- a/bokeh/core/properties.py
+++ b/bokeh/core/properties.py
@@ -1432,21 +1432,21 @@ class SomeModel(Model):
             color = ColorSpec(help="docs for color") # defaults to None
 
     '''
-    def __init__(self, typ, default, help=None):
+    def __init__(self, key_type, value_type, default, help=None):
         super(DataSpec, self).__init__(
             String,
             Dict(
-                String,
+                key_type,
                 Either(
                     String,
                     Instance('bokeh.models.transforms.Transform'),
                     Instance('bokeh.models.mappers.ColorMapper'),
-                    typ)),
-            typ,
+                    value_type)),
+            value_type,
             default=default,
             help=help
         )
-        self._type = self._validate_type_param(typ)
+        self._type = self._validate_type_param(value_type)
 
     # TODO (bev) add stricter validation on keys
 
@@ -1489,6 +1489,8 @@ def to_serializable(self, obj, name, val):
     def _sphinx_type(self):
         return self._sphinx_prop_link()
 
+_FieldValueTransform = Enum("field", "value", "transform")
+
 class NumberSpec(DataSpec):
     ''' A |DataSpec| property that accepts numeric fixed values.
 
@@ -1499,8 +1501,8 @@ class NumberSpec(DataSpec):
         m.location = "foo" # field
 
     '''
-    def __init__(self, default=None, help=None):
-        super(NumberSpec, self).__init__(Float, default=default, help=help)
+    def __init__(self, default=None, help=None, key_type=_FieldValueTransform):
+        super(NumberSpec, self).__init__(key_type, Float, default=default, help=help)
 
 class StringSpec(DataSpec):
     ''' A |DataSpec| property that accepts string fixed values.
@@ -1517,8 +1519,8 @@ class StringSpec(DataSpec):
         m.title = "foo"        # field
 
     '''
-    def __init__(self, default, help=None):
-        super(StringSpec, self).__init__(List(String), default=default, help=help)
+    def __init__(self, default, help=None, key_type=_FieldValueTransform):
+        super(StringSpec, self).__init__(key_type, List(String), default=default, help=help)
 
     def prepare_value(self, cls, name, value):
         if isinstance(value, list):
@@ -1549,21 +1551,23 @@ class FontSizeSpec(DataSpec):
     '''
     _font_size_re = re.compile("^[0-9]+(\.[0-9]+)?(%|em|ex|ch|ic|rem|vw|vh|vi|vb|vmin|vmax|cm|mm|q|in|pc|pt|px)$", re.I)
 
-    def __init__(self, default, help=None):
-        super(FontSizeSpec, self).__init__(List(String), default=default, help=help)
+    def __init__(self, default, help=None, key_type=_FieldValueTransform):
+        super(FontSizeSpec, self).__init__(key_type, List(String), default=default, help=help)
 
     def prepare_value(self, cls, name, value):
         if isinstance(value, string_types) and self._font_size_re.match(value) is not None:
             value = dict(value=value)
         return super(FontSizeSpec, self).prepare_value(cls, name, value)
 
+_FieldValueTransformUnits = Enum("field", "value", "transform", "units")
+
 class UnitsSpec(NumberSpec):
     ''' A |DataSpec| property that accepts numeric fixed values, and also
     provides an associated units property to store units information.
 
     '''
     def __init__(self, default, units_type, units_default, help=None):
-        super(UnitsSpec, self).__init__(default=default, help=help)
+        super(UnitsSpec, self).__init__(default=default, help=help, key_type=_FieldValueTransformUnits)
         self._units_type = self._validate_type_param(units_type)
         # this is a hack because we already constructed units_type
         self._units_type.validate(units_default)
@@ -1703,8 +1707,8 @@ class ColorSpec(DataSpec):
         m.color = field("firebrick")       # field (named "firebrick")
 
     '''
-    def __init__(self, default, help=None):
-        super(ColorSpec, self).__init__(Color, default=default, help=help)
+    def __init__(self, default, help=None, key_type=_FieldValueTransform):
+        super(ColorSpec, self).__init__(key_type, Color, default=default, help=help)
 
     @classmethod
     def isconst(cls, val):


Test patch:
diff --git a/bokeh/core/tests/test_properties.py b/bokeh/core/tests/test_properties.py
--- a/bokeh/core/tests/test_properties.py
+++ b/bokeh/core/tests/test_properties.py
@@ -6,11 +6,14 @@
 import pandas as pd
 from copy import copy
 
+import pytest
+
 from bokeh.core.properties import (field, value,
     NumberSpec, ColorSpec, Bool, Int, Float, Complex, String,
     Regex, Seq, List, Dict, Tuple, Instance, Any, Interval, Either,
-    Enum, Color, DashPattern, Size, Percent, Angle, AngleSpec,
-    DistanceSpec, FontSizeSpec, Override, Include, MinMaxBounds)
+    Enum, Color, DashPattern, Size, Percent, Angle, AngleSpec, StringSpec,
+    DistanceSpec, FontSizeSpec, Override, Include, MinMaxBounds,
+    DataDistanceSpec, ScreenDistanceSpec)
 
 from bokeh.core.has_props import HasProps
 
@@ -1666,3 +1669,25 @@ def test_value_function():
     assert value("foo") == dict(value="foo")
     # TODO (bev) would like this to work I think
     #assert value("foo", transform="junk") == dict(value="foo", transform="junk")
+
+def test_strict_dataspec_key_values():
+    for typ in (NumberSpec, StringSpec, FontSizeSpec, ColorSpec, DataDistanceSpec, ScreenDistanceSpec):
+        class Foo(HasProps):
+            x = typ("x")
+        f = Foo()
+        with pytest.raises(ValueError):
+            f.x = dict(field="foo", units="junk")
+
+def test_strict_unitspec_key_values():
+    class FooUnits(HasProps):
+        x = DistanceSpec("x")
+    f = FooUnits()
+    f.x = dict(field="foo", units="screen")
+    with pytest.raises(ValueError):
+        f.x = dict(field="foo", units="junk", foo="crap")
+    class FooUnits(HasProps):
+        x = AngleSpec("x")
+    f = FooUnits()
+    f.x = dict(field="foo", units="deg")
+    with pytest.raises(ValueError):
+        f.x = dict(field="foo", units="junk", foo="crap")