Repository: bokeh/bokeh

Issue:
Add `args` parameter to `FuncTickFormatter` similiar to `CustomJS`



Discussion:

Resolving patch:
diff --git a/bokeh/models/formatters.py b/bokeh/models/formatters.py
--- a/bokeh/models/formatters.py
+++ b/bokeh/models/formatters.py
@@ -225,26 +225,41 @@ def from_py_func(cls, func):
         pyscript = import_required('flexx.pyscript',
                                    'To use Python functions for CustomJS, you need Flexx ' +
                                    '("conda install -c bokeh flexx" or "pip install flexx")')
+        argspec = inspect.getargspec(func)
 
-        arg = inspect.getargspec(func)[0]
-        if len(arg) != 1:
-            raise ValueError("Function `func` can have only one argument, but %d were supplied." % len(arg))
+        default_names = argspec.args
+        default_values = argspec.defaults or []
 
-        # Set the transpiled functions as `formatter` so that we can call it
-        code = pyscript.py2js(func, 'formatter')
-        # We wrap the transpiled function into an anonymous function with a single
-        # arg that matches that of func.
-        wrapped_code = "function (%s) {%sreturn formatter(%s)};" % (arg[0], code, arg[0])
+        if len(default_names) - len(default_values) != 0:
+            raise ValueError("Function `func` may only contain keyword arguments.")
 
-        return cls(code=wrapped_code)
+        if default_values and not any([isinstance(value, Model) for value in default_values]):
+            raise ValueError("Default value must be a plot object.")
+
+        func_kwargs = dict(zip(default_names, default_values))
+
+        # Wrap the code attr in a function named `formatter` and call it
+        # with arguments that match the `args` attr
+        code = pyscript.py2js(func, 'formatter') + 'formatter(%s);\n' % ', '.join(default_names)
+
+        return cls(code=code, args=func_kwargs)
 
     @classmethod
     def from_coffeescript(cls, code, args={}):
-        compiled = nodejs_compile(code, lang="coffeescript", file="???")
+        wrapped_code = "formatter = () -> %s" % (code,)
+
+        compiled = nodejs_compile(wrapped_code, lang="coffeescript", file="???")
         if "error" in compiled:
             raise CompilationError(compiled.error)
         else:
-            return cls(code=compiled.code) # TODO: args=args
+            wrapped_compiled_code = "%s\nreturn formatter()" % (compiled.code,)
+            return cls(code=wrapped_compiled_code, args=args)
+
+    args = Dict(String, Instance(Model), help="""
+    A mapping of names to Bokeh plot objects. These objects are made
+    available to the formatter code snippet as the values of named
+    parameters to the callback.
+    """)
 
     code = String(default="", help="""
     An anonymous JavaScript function expression to reformat a


Test patch:
diff --git a/bokeh/models/tests/test_formatters.py b/bokeh/models/tests/test_formatters.py
--- a/bokeh/models/tests/test_formatters.py
+++ b/bokeh/models/tests/test_formatters.py
@@ -1,22 +1,73 @@
-from unittest import skipIf
+import pytest
 
-try:
-    from flexx import pyscript
-    is_flexx = True
-except ImportError as e:
-    is_flexx = False
+from bokeh.models import FuncTickFormatter, Slider
 
-from bokeh.models import FuncTickFormatter
+flexx = pytest.importorskip("flexx")
 
-@skipIf(not is_flexx, "flexx not installed")
-def test_functickformatter_from_py_func():
+def test_functickformatter_from_py_func_no_args():
 
-    def convert_to_minutes(seconds):
-        return seconds * 60
+    def convert_to_minutes():
+        return tick * 60 # noqa
 
     formatter = FuncTickFormatter.from_py_func(convert_to_minutes)
-    js_code = pyscript.py2js(convert_to_minutes, 'formatter')
+    js_code = flexx.pyscript.py2js(convert_to_minutes, 'formatter')
 
     function_wrapper = formatter.code.replace(js_code, '')
 
-    assert function_wrapper == "function (seconds) {return formatter(seconds)};"
+    assert function_wrapper == "formatter();\n"
+
+def test_functickformatter_from_py_func_with_args():
+
+    slider = Slider()
+
+    def convert_to_minutes(x=slider):
+        return tick * 60 # noqa
+
+    formatter = FuncTickFormatter.from_py_func(convert_to_minutes)
+    js_code = flexx.pyscript.py2js(convert_to_minutes, 'formatter')
+
+    function_wrapper = formatter.code.replace(js_code, '')
+
+    assert function_wrapper == "formatter(x);\n"
+    assert formatter.args['x'] is slider
+
+def test_functickformatter_bad_pyfunc_formats():
+    def has_positional_arg(x):
+        return None
+    with pytest.raises(ValueError):
+        FuncTickFormatter.from_py_func(has_positional_arg)
+
+    def has_positional_arg_with_kwargs(y, x=5):
+        return None
+    with pytest.raises(ValueError):
+        FuncTickFormatter.from_py_func(has_positional_arg_with_kwargs)
+
+    def has_non_Model_keyword_argument(x=10):
+        return None
+    with pytest.raises(ValueError):
+        FuncTickFormatter.from_py_func(has_non_Model_keyword_argument)
+
+def test_functickformatter_from_coffeescript_no_arg():
+    coffee_code = """
+    square = (x) -> x * x
+    return square(tick)
+    """
+
+    js_code = "\n  var square;\n  square = function(x) {\n    return x * x;\n  };\n  return square(tick);\n"
+
+    formatter = FuncTickFormatter.from_coffeescript(code=coffee_code)
+    function_wrapper = formatter.code.replace(js_code, "")
+
+    assert function_wrapper == "var formatter;\n\nformatter = function() {};\n\nreturn formatter()"
+    assert formatter.args == {}
+
+def test_functickformatter_from_coffeescript_with_args():
+    coffee_code = "return slider.get('value') + tick"
+    js_code = "\n  return slider.get('value') + tick;\n"
+
+    slider = Slider()
+    formatter = FuncTickFormatter.from_coffeescript(code=coffee_code, args={"slider": slider})
+
+    function_wrapper = formatter.code.replace(js_code, "")
+    assert function_wrapper == "var formatter;\n\nformatter = function() {};\n\nreturn formatter()"
+    assert formatter.args == {"slider": slider}
diff --git a/bokehjs/test/models/formatters/func_tick_formatter.coffee b/bokehjs/test/models/formatters/func_tick_formatter.coffee
--- a/bokehjs/test/models/formatters/func_tick_formatter.coffee
+++ b/bokehjs/test/models/formatters/func_tick_formatter.coffee
@@ -1,20 +1,49 @@
 {expect} = require "chai"
 utils = require "../../utils"
 
-formatter = utils.require "models/formatters/func_tick_formatter"
+FuncTickFormatter = utils.require("models/formatters/func_tick_formatter").Model
+Range1d = utils.require("models/ranges/range1d").Model
 
 describe "func_tick_formatter module", ->
 
-  it "should format numerical ticks appropriately", ->
-    obj = new formatter.Model
-      code: "function (x) {return x*10};"
+  describe "FuncTickFormatter._make_func method", ->
+    formatter = new FuncTickFormatter({code: "return 10"})
+    it "should return a Function", ->
+      expect(formatter._make_func()).to.be.an.instanceof(Function)
 
-    labels = obj.doFormat([-10, -0.1, 0, 0.1, 10])
-    expect(labels).to.deep.equal([-100, -1.0, 0, 1, 100])
+    it "should have code property as function body", ->
+      func = new Function("tick", "require", "return 10")
+      expect(formatter._make_func().toString()).to.be.equal(func.toString())
 
-  it "should format categorical ticks appropriately", ->
-    obj = new formatter.Model
-      code: "function (y) {return y + '_lat'};"
+    it "should have values as function args", ->
+      rng = new Range1d()
+      formatter.args = {foo: rng.ref()}
+      func = new Function("tick", "foo", "require", "return 10")
+      expect(formatter._make_func().toString()).to.be.equal(func.toString())
 
-    labels = obj.doFormat(["a", "b", "c", "d", "e"])
-    expect(labels).to.deep.equal(["a_lat", "b_lat", "c_lat", "d_lat", "e_lat"])
+  describe "doFormat method", ->
+    it "should format numerical ticks appropriately", ->
+      formatter = new FuncTickFormatter({code: "return tick * 10"})
+      labels = formatter.doFormat([-10, -0.1, 0, 0.1, 10])
+      expect(labels).to.deep.equal([-100, -1.0, 0, 1, 100])
+
+    it "should format categorical ticks appropriately", ->
+      formatter = new FuncTickFormatter({code: "return tick + '_lat'"})
+      labels = formatter.doFormat(["a", "b", "c", "d", "e"])
+      expect(labels).to.deep.equal(["a_lat", "b_lat", "c_lat", "d_lat", "e_lat"])
+
+    it "should support imports using require", ->
+      formatter = new FuncTickFormatter({
+        code: "var _ = require('underscore'); return _.max([1,2,3])"
+      })
+      labels = formatter.doFormat([0, 0, 0])
+      expect(labels).to.be.deep.equal([3,3,3])
+
+    it "should handle args appropriately", ->
+      rng = new Range1d({start: 5, end: 10})
+      formatter = new FuncTickFormatter({
+        code: "return foo.start + foo.end + tick"
+        args: {foo: rng}
+      })
+      labels = formatter.doFormat([-10, -0.1, 0, 0.1, 10])
+      expect(labels).to.deep.equal([5, 14.9, 15, 15.1, 25])