Repository: bokeh/bokeh

Issue:
CustomJSTransform
Adding new transforms is easy, but people should be able to self serve something like this:

``` python
from bokeh.models import CustomJSTransform

transform = CustomJSTransform.from_coffeescript(
    compute="""return 2*x""",
    v_compute="""
        vals = []
        for x in xs:
            vals.push(2*x)
        return vals
    """
)

.....

p.circle(x='x', y='y', size={field: 'x', transform: transform})
```

Just like https://github.com/bokeh/bokeh/pull/4659, user would _know_ that single `x` is passed to compute and array `xs` is passed to v_compute. User would specify a return value and would also be able to pass in args - like for CustomJS.



Discussion:
ping @hafen

This seems low-effort / high value so scheduling it for `0.12.5`
I am interested in working on the issue. How do I start?
HI @souravsingh it's going to be very similar to the `CustomJS` callback. I'd first suggest studying that, and it's JS implementation as well:

https://github.com/bokeh/bokeh/blob/master/bokeh/models/callbacks.py
https://github.com/bokeh/bokeh/blob/master/bokehjs/src/coffee/models/callbacks/customjs.coffee

It's probably the case that alot of that code can be re-used. 

Then the first step would be to define what the python class that will represent a `CustomJSTransform` should look like. It will be a subclass of `Transform` with some extra properties to represent the JS code for the transform.
Can we do this with pyscript also?

Resolving patch:
diff --git a/bokeh/models/transforms.py b/bokeh/models/transforms.py
--- a/bokeh/models/transforms.py
+++ b/bokeh/models/transforms.py
@@ -3,10 +3,18 @@
 '''
 from __future__ import absolute_import
 
+import inspect
+from textwrap import dedent
+from types import FunctionType
+
 from ..core.enums import StepMode, JitterRandomDistribution
 from ..core.has_props import abstract
-from ..core.properties import Bool, Either, Enum, Float, Instance, Seq, String
+from ..core.properties import (
+    Bool, Either, Enum, Float, Instance, Seq, String, Dict
+)
 from ..model import Model
+from ..util.compiler import nodejs_compile, CompilationError
+from ..util.dependencies import import_required
 
 from .sources import ColumnarDataSource
 
@@ -29,6 +37,173 @@ class Transform(Model):
     pass
 
 
+class CustomJSTransform(Transform):
+    ''' Apply a custom defined transform to data.
+
+    '''
+
+    @classmethod
+    def from_py_func(cls, func, v_func):
+        ''' Create a CustomJSTransform instance from a pair of Python
+        functions. The function is translated to JavaScript using PyScript.
+
+        The python functions must have no positional arguments. It's
+        possible to pass Bokeh models (e.g. a ColumnDataSource) as keyword
+        arguments to the functions.
+
+        The ``func`` function namespace will contain the variable ``x`` (the
+        untransformed value) at render time. The ``v_func`` function namespace
+        will contain the variable ``xs`` (the untransformed vector) at render
+        time.
+
+        .. warning::
+            The vectorized function, ``v_func``, must return an array of the
+            same length as the input ``xs`` array.
+
+        Example:
+
+        .. code-block:: python
+
+            def transform():
+                from flexx.pyscript.stubs import Math
+                return Math.cos(x)
+
+            def v_transform():
+                from flexx.pyscript.stubs import Math
+                return [Math.cos(x) for x in xs]
+
+            customjs_transform = CustomJSTransform.from_py_func(transform, v_transform)
+
+        Args:
+            func (function) : a scalar function to transform a single ``x`` value
+
+            v_func (function) : a vectorized function to transform a vector ``xs``
+
+        Returns:
+            CustomJSTransform
+
+        '''
+        if not isinstance(func, FunctionType) or not isinstance(v_func, FunctionType):
+            raise ValueError('CustomJSTransform.from_py_func only accepts function objects.')
+
+        pyscript = import_required(
+            'flexx.pyscript',
+            dedent("""\
+                To use Python functions for CustomJSTransform, you need Flexx
+                '("conda install -c bokeh flexx" or "pip install flexx")""")
+            )
+
+        def pyscript_compile(func):
+            argspec = inspect.getargspec(func)
+
+            default_names = argspec.args
+            default_values = argspec.defaults or []
+
+            if len(default_names) - len(default_values) != 0:
+                raise ValueError("Function may only contain keyword arguments.")
+
+            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, 'transformer') + 'return transformer(%s);\n' % ', '.join(default_names)
+            return code, func_kwargs
+
+        jsfunc, func_kwargs = pyscript_compile(func)
+        v_jsfunc, v_func_kwargs = pyscript_compile(v_func)
+
+        # Have to merge the function arguments
+        func_kwargs.update(v_func_kwargs)
+
+        return cls(func=jsfunc, v_func=v_jsfunc, args=func_kwargs)
+
+    @classmethod
+    def from_coffeescript(cls, func, v_func, args={}):
+        ''' Create a CustomJSTransform instance from a pair of CoffeeScript
+        snippets. The function bodies are translated to JavaScript functions
+        using node and therefore require return statements.
+
+        The ``func`` snippet namespace will contain the variable ``x`` (the
+        untransformed value) at render time. The ``v_func`` snippet namespace
+        will contain the variable ``xs`` (the untransformed vector) at render
+        time.
+
+        Example:
+
+        .. code-block:: coffeescript
+
+            func = "return Math.cos(x)"
+            v_func = "return [Math.cos(x) for x in xs]"
+
+            transform = CustomJSTransform.from_coffeescript(func, v_func)
+
+        Args:
+            func (str) : a coffeescript snippet to transform a single ``x`` value
+
+            v_func (str) : a coffeescript snippet function to transform a vector ``xs``
+
+        Returns:
+            CustomJSTransform
+
+        '''
+        compiled = nodejs_compile(func, lang="coffeescript", file="???")
+        if "error" in compiled:
+            raise CompilationError(compiled.error)
+
+        v_compiled = nodejs_compile(v_func, lang="coffeescript", file="???")
+        if "error" in v_compiled:
+            raise CompilationError(v_compiled.error)
+
+        return cls(func=compiled.code, v_func=v_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 callback code snippet as the values of named
+    parameters to the callback.
+    """)
+
+    func = String(default="", help="""
+    A snippet of JavaScript code to transform a single value. The variable
+    ``x`` will contain the untransformed value and can be expected to be
+    present in the function namespace at render time. The snippet will be
+    into the body of a function and therefore requires a return statement.
+
+    Example:
+
+        .. code-block:: javascript
+
+            func = '''
+            return Math.floor(x) + 0.5
+            '''
+    """)
+
+    v_func = String(default="", help="""
+    A snippet of JavaScript code to transform an array of values. The variable
+    ``xs`` will contain the untransformed array and can be expected to be
+    present in the function namespace at render time. The snippet will be
+    into the body of a function and therefore requires a return statement.
+
+    Example:
+
+        .. code-block:: javascript
+
+            v_func = '''
+            new_xs = new Array(xs.length)
+            for(i = 0; i < xs.length; i++) {
+                new_xs[i] = xs[i] + 0.5
+            }
+            return new_xs
+            '''
+
+    .. warning::
+        The vectorized function, ``v_func``, must return an array of the
+        same length as the input ``xs`` array.
+    """)
+
+
 class Jitter(Transform):
     ''' Apply either a uniform or normally sampled random jitter to data.
 
diff --git a/examples/models/file/customjs_transform.py b/examples/models/file/customjs_transform.py
new file mode 100644
--- /dev/null
+++ b/examples/models/file/customjs_transform.py
@@ -0,0 +1,54 @@
+import numpy as np
+
+from bokeh.document import Document
+from bokeh.embed import file_html
+from bokeh.models import ColumnDataSource, CustomJSTransform
+from bokeh.plotting import figure
+from bokeh.resources import INLINE
+from bokeh.sampledata.stocks import AAPL, GOOG
+from bokeh.util.browser import view
+
+def datetime(x):
+    return np.array(x, dtype=np.datetime64)
+
+plot = figure(x_axis_type="datetime", title="Normalized Stock Closing Prices")
+plot.grid.grid_line_alpha=0.3
+plot.xaxis.axis_label = 'Date'
+plot.yaxis.axis_label = 'Normalized Price'
+
+aapl_source = ColumnDataSource(data=dict(
+    aapl_date=datetime(AAPL['date']),
+    aapl_close=AAPL['adj_close'],
+))
+
+goog_source = ColumnDataSource(data=dict(
+    goog_date=datetime(GOOG['date']),
+    goog_close=GOOG['adj_close'],
+))
+
+v_func = """\
+    var first = xs[0]
+    var norm = new Float64Array(xs.length)
+    for (i = 0; i < xs.length; i++) {
+        norm[i] = xs[i] / first
+    }
+    return norm
+"""
+normalize = CustomJSTransform(func="", v_func=v_func)
+
+plot.line(x='aapl_date', y={'field': 'aapl_close', 'transform': normalize},
+        color='red', legend="Apple", source=aapl_source)
+plot.line(x='goog_date', y={'field': 'goog_close', 'transform': normalize},
+        color='blue', legend="Google", source=goog_source)
+plot.legend.location='top_left'
+
+doc = Document()
+doc.add_root(plot)
+
+if __name__ == "__main__":
+    doc.validate()
+    filename = "customjs_transform.html"
+    with open(filename, "w") as f:
+        f.write(file_html(doc, INLINE, "CustomJSTransform Example"))
+    print("Wrote %s" % filename)
+    view(filename)


Test patch:
diff --git a/bokeh/models/tests/test_transforms.py b/bokeh/models/tests/test_transforms.py
new file mode 100644
--- /dev/null
+++ b/bokeh/models/tests/test_transforms.py
@@ -0,0 +1,133 @@
+from textwrap import dedent
+
+import pytest
+
+from bokeh.models import CustomJSTransform, Slider
+
+flexx = pytest.importorskip("flexx")
+
+def test_customjstransform_from_py_func_no_args():
+
+    def cosine():
+        from flexx.pyscript import window
+        return window.Math.cos(x) # noqa
+
+    def v_cosine():
+        from flexx.pyscript import window
+        return [window.Math.cos(x) for x in xs] # noqa
+
+    transform = CustomJSTransform.from_py_func(cosine, v_cosine)
+
+    js_code = flexx.pyscript.py2js(cosine, 'transformer')
+    function_wrapper = transform.func.replace(js_code, '')
+    assert function_wrapper == "return transformer();\n"
+
+    v_js_code = flexx.pyscript.py2js(v_cosine, 'transformer')
+    v_function_wrapper = transform.v_func.replace(v_js_code, '')
+    assert v_function_wrapper == "return transformer();\n"
+
+def test_customjstransform_from_py_func_with_args():
+
+    slider = Slider()
+
+    def cosine(foo=slider):
+        from flexx.pyscript import window
+        return window.Math.cos(x) # noqa
+
+    def v_cosine(foo=slider):
+        from flexx.pyscript import window
+        return [window.Math.cos(x) for x in xs] # noqa
+
+    transform = CustomJSTransform.from_py_func(cosine, v_cosine)
+
+    assert transform.args['foo'] is slider
+
+    js_code = flexx.pyscript.py2js(cosine, 'transformer')
+    function_wrapper = transform.func.replace(js_code, '')
+    assert function_wrapper == "return transformer(foo);\n"
+
+    v_js_code = flexx.pyscript.py2js(v_cosine, 'transformer')
+    v_function_wrapper = transform.v_func.replace(v_js_code, '')
+    assert v_function_wrapper == "return transformer(foo);\n"
+
+def test_customjstransform_bad_pyfunc_formats():
+    def foo():
+        pass
+
+    def has_positional_arg(x):
+        return None
+    with pytest.raises(ValueError):
+        CustomJSTransform.from_py_func(has_positional_arg, foo)
+
+    def has_positional_arg_with_kwargs(y, x=5):
+        return None
+    with pytest.raises(ValueError):
+        CustomJSTransform.from_py_func(has_positional_arg_with_kwargs, foo)
+
+    def has_non_Model_keyword_argument(x=10):
+        return None
+    with pytest.raises(ValueError):
+        CustomJSTransform.from_py_func(has_non_Model_keyword_argument, foo)
+
+def test_customjstransform_from_coffeescript_no_arg():
+    code = "return x * x"
+    v_code = "return [x * x for x in xs]"
+
+    transform = CustomJSTransform.from_coffeescript(code, v_code)
+
+    assert transform.func == dedent("""\
+        "use strict";
+        Object.defineProperty(exports, "__esModule", { value: true });
+        return x * x;
+        """)
+
+    assert transform.v_func == dedent("""\
+        "use strict";
+        Object.defineProperty(exports, "__esModule", { value: true });
+        var x;
+        return [
+            (function () {
+                var i, len, results;
+                results = [];
+                for (i = 0, len = xs.length; i < len; i++) {
+                    x = xs[i];
+                    results.push(x * x);
+                }
+                return results;
+            })()
+        ];
+        """)
+
+    assert transform.args == {}
+
+def test_customjstransform_from_coffeescript_with_args():
+    code = "return foo.get('value') // 2 + x"
+    v_code = "return [foo.get('value') // 2 + x for x in xs]"
+
+    slider = Slider()
+    formatter = CustomJSTransform.from_coffeescript(func=code, v_func=v_code, args={"foo": slider})
+
+    assert formatter.func == dedent("""\
+        "use strict";
+        Object.defineProperty(exports, "__esModule", { value: true });
+        return Math.floor(foo.get('value') / 2) + x;
+        """)
+
+    assert formatter.v_func == dedent("""\
+        "use strict";
+        Object.defineProperty(exports, "__esModule", { value: true });
+        var x;
+        return [
+            (function () {
+                var i, len, results;
+                results = [];
+                for (i = 0, len = xs.length; i < len; i++) {
+                    x = xs[i];
+                    results.push(Math.floor(foo.get('value') / 2) + x);
+                }
+                return results;
+            })()
+        ];
+        """)
+
+    assert formatter.args == {"foo": slider}
diff --git a/bokeh/tests/test_transforms.py b/bokeh/tests/test_transforms.py
deleted file mode 100644
--- a/bokeh/tests/test_transforms.py
+++ /dev/null
@@ -1 +0,0 @@
-# Placeholder for tests against the computed transforms
diff --git a/bokehjs/test/models/transforms/customjs_transform.coffee b/bokehjs/test/models/transforms/customjs_transform.coffee
new file mode 100644
--- /dev/null
+++ b/bokehjs/test/models/transforms/customjs_transform.coffee
@@ -0,0 +1,103 @@
+{expect} = require "chai"
+utils = require "../../utils"
+
+{CustomJSTransform} = utils.require("models/transforms/customjs_transform")
+{Range1d} = utils.require("models/ranges/range1d")
+
+describe "customjs_transform module", ->
+
+  describe "default creation", ->
+    r = new CustomJSTransform()
+
+    it "should have empty args", ->
+      expect(r.args).to.be.deep.equal {}
+
+    it "should have empty func property", ->
+      expect(r.func).to.be.equal ""
+
+    it "should have empty v_func property", ->
+      expect(r.v_func).to.be.equal ""
+
+  describe "values property", ->
+
+    it "should return an array", ->
+      r = new CustomJSTransform()
+      expect(r.values).to.be.an.instanceof Array
+
+    it "should contain the args values in order", ->
+      rng1 = Range1d()
+      rng2 = Range1d()
+      r = new CustomJSTransform({args: {foo: rng1, bar: rng2}})
+      expect(r.values).to.be.deep.equal([rng1, rng2])
+
+  describe "scalar_transform property", ->
+
+    it "should return a Function", ->
+      r = new CustomJSTransform()
+      expect(r.scalar_transform).to.be.an.instanceof Function
+
+    it "should have func property as function body", ->
+      r = new CustomJSTransform({func: "return x"})
+      f = new Function("x", "require", "exports", "return x")
+      expect(r.scalar_transform.toString()).to.be.equal(f.toString())
+
+    it "should include args values in order in function signature", ->
+      rng1 = Range1d()
+      rng2 = Range1d()
+      r = new CustomJSTransform({args: {foo: rng1, bar: rng2}, func: "return x"})
+      f = new Function("foo", "bar", "x", "require", "exports", "return x")
+      expect(r.scalar_transform.toString()).to.be.equal(f.toString())
+
+  describe "vector_transform property", ->
+
+    it "should return a Function", ->
+      r = new CustomJSTransform()
+      expect(r.vector_transform).to.be.an.instanceof Function
+
+    it "should have v_func property as function body", ->
+      r = new CustomJSTransform({v_func: "return xs"})
+      f = new Function("xs", "require", "exports", "return xs")
+      expect(r.vector_transform.toString()).to.be.equal(f.toString())
+
+    it "should include args values in order in function signature", ->
+      rng1 = Range1d()
+      rng2 = Range1d()
+      r = new CustomJSTransform({args: {foo: rng1, bar: rng2}, v_func: "return xs"})
+      f = new Function("foo", "bar", "xs", "require", "exports", "return xs")
+      expect(r.vector_transform.toString()).to.be.equal(f.toString())
+
+  describe "compute method", ->
+
+    it "should properly transform a single value", ->
+      r = new CustomJSTransform({func: "return x + 10"})
+      expect(r.compute(5)).to.be.equal(15)
+
+    it "should properly transform a single value using an arg property", ->
+      rng = new Range1d({start: 11, end: 21})
+      r = new CustomJSTransform({args: {foo: rng}, func: "return x + foo.start"})
+      expect(r.compute(5)).to.be.equal(16)
+
+  describe "v_compute method", ->
+
+    it "should properly transform an array of values", ->
+      v_func = """
+      new_xs = new Array(xs.length)
+      for (i = 0; i < xs.length; i++) {
+          new_xs[i] = xs[i] + 10
+      }
+      return new_xs
+      """
+      r = new CustomJSTransform({v_func: v_func})
+      expect(r.v_compute([1,2,3])).to.be.deep.equal(new Array(11,12,13))
+
+    it "should properly transform an array of values using an arg property", ->
+      v_func = """
+      new_xs = new Array(xs.length)
+      for(i = 0; i < xs.length; i++) {
+          new_xs[i] = xs[i] + foo.start
+      }
+      return new_xs
+      """
+      rng = new Range1d({start: 11, end: 21})
+      r = new CustomJSTransform({args: {foo: rng}, v_func: v_func})
+      expect(r.v_compute([1,2,3])).to.be.deep.equal(new Array(12,13,14))
diff --git a/bokehjs/test/models/transforms/index.coffee b/bokehjs/test/models/transforms/index.coffee
--- a/bokehjs/test/models/transforms/index.coffee
+++ b/bokehjs/test/models/transforms/index.coffee
@@ -1,3 +1,4 @@
+require "./customjs_transform"
 require "./linear_interpolator_transform"
 require "./step_interpolator_transform"
 require "./jitter_transform"