Repository: bokeh/bokeh

Issue:
Adding a FuncFormatter for ticks based on PyScript
Further to the discussion in https://github.com/bokeh/bokeh/issues/1668 on more general ticking support I was hoping to start discussion around a more flexible solution by allowing simple Python functions to be transpiled with PyScript. This would allow for more complex ticking behavior to be expressed without having to implement custom models in most cases. Also pyscript is already used for CustomJS so it doesn't add another dependency.

In our case we just want to format a simple second counter in minutes without converting these relative time values to faked datetimes. This formatter can be expressed as a simple Python function:

``` python
def formatter(seconds):
    m, s = divmod(seconds, 60)
    s = ('%d' % s) if s > 9 else ('0%s' % s)
    return "%d:%s" % (m, s)
```

which I've had to modify only slightly to transpile to Javascript correctly. @canavandl suggested implementing a CustomModelFactory, that will take a Python function and create a model from the transpiled function, would be doable. I was hoping this would be general enough to be included as part of bokeh.

Of course the formatter could also accept any JS function so it's not completely reliant on pyscript.



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
@@ -4,11 +4,16 @@
 """
 from __future__ import absolute_import
 
+from types import FunctionType
+import inspect
+
 from .tickers import Ticker
 from ..model import Model
 from ..core.properties import abstract
-from ..core.properties import Bool, Int, String, Enum, Auto, List, Dict, Either, Instance
-from ..core.enums import DatetimeUnits, RoundingFunction, NumeralLanguage
+from ..core.properties import (Bool, Int, String, Enum, Auto, List, Dict,
+    Either, Instance)
+from ..core.enums import DatetimeUnits, RoundingFunction, NumeralLanguage, ScriptingLanguage
+from ..util.dependencies import import_required
 
 @abstract
 class TickFormatter(Model):
@@ -200,6 +205,66 @@ class CategoricalTickFormatter(TickFormatter):
     """
     pass
 
+class FuncTickFormatter(TickFormatter):
+    """ Display tick values that are formatted by a user-defined function.
+
+    """
+
+    @classmethod
+    def from_py_func(cls, func):
+        """ Create a FuncTickFormatter instance from a Python function. The
+        function is translated to Javascript using PyScript.
+
+        .. warning::
+            The function can have only a single positional argument and return
+            a single value.
+        """
+        if not isinstance(func, FunctionType):
+            raise ValueError('CustomJS.from_py_func needs function object.')
+        pyscript = import_required('flexx.pyscript',
+                                   'To use Python functions for CustomJS, you need Flexx ' +
+                                   '("conda install -c bokeh flexx" or "pip install flexx")')
+
+        arg = inspect.getargspec(func)[0]
+        if len(arg) != 1:
+            raise ValueError("Function `func` can have only one argument, but %d were supplied." % len(arg))
+
+        # 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])
+
+        return cls(code=wrapped_code, lang='javascript')
+
+    code = String(default="", help="""
+    An anonymous JavaScript or CoffeeScript function expression to reformat a
+    single tick to the desired format.
+
+    Example:
+
+        .. code-block:: python
+
+            code = '''
+            function (tick) {
+                // Convert from minutes to seconds and add units str
+                return tick * 60 + " seconds"
+            };
+            '''
+
+    .. warning::
+        The function can have only a single positional argument and return
+        a single value.
+
+    """)
+
+    lang = Enum(ScriptingLanguage, default="javascript", help="""
+    The implementation scripting language of the snippet. This can be either
+    raw JavaScript or CoffeeScript. In CoffeeScript's case, the snippet will
+    be compiled at runtime (in a web browser), so you don't need to have
+    node.js/io.js, etc. installed.
+    """)
+
 DEFAULT_DATETIME_FORMATS = lambda : {
     'microseconds': ['%fus'],
     'milliseconds': ['%3Nms', '%S.%3Ns'],


Test patch:
diff --git a/bokeh/models/tests/test_formatters.py b/bokeh/models/tests/test_formatters.py
new file mode 100644
--- /dev/null
+++ b/bokeh/models/tests/test_formatters.py
@@ -0,0 +1,24 @@
+from pytest import raises
+from unittest import skipIf
+
+try:
+    from flexx import pyscript
+    is_flexx = True
+except ImportError as e:
+    is_flexx = False
+
+from bokeh.models import FuncTickFormatter
+
+@skipIf(not is_flexx, "flexx not installed")
+def test_functickformatter_from_py_func():
+
+    def convert_to_minutes(seconds):
+        return seconds * 60
+
+    formatter = FuncTickFormatter.from_py_func(convert_to_minutes)
+    js_code = pyscript.py2js(convert_to_minutes, 'formatter')
+
+    function_wrapper = formatter.code.replace(js_code, '')
+
+    assert function_wrapper == "function (seconds) {return formatter(seconds)};"
+    assert formatter.lang == "javascript"
diff --git a/bokehjs/test/models/formatters/func_tick_formatter.coffee b/bokehjs/test/models/formatters/func_tick_formatter.coffee
new file mode 100644
--- /dev/null
+++ b/bokehjs/test/models/formatters/func_tick_formatter.coffee
@@ -0,0 +1,28 @@
+{expect} = require "chai"
+utils = require "../../utils"
+
+formatter = utils.require "models/formatters/func_tick_formatter"
+
+describe "func_tick_formatter module", ->
+
+  it "should format numerical ticks appropriately", ->
+    obj = new formatter.Model
+      code: "function (x) {return x*10};"
+
+    labels = obj.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", ->
+    obj = new formatter.Model
+      code: "function (y) {return y + '_lat'};"
+
+    labels = obj.doFormat(["a", "b", "c", "d", "e"])
+    expect(labels).to.deep.equal(["a_lat", "b_lat", "c_lat", "d_lat", "e_lat"])
+
+  it "should compile coffeescript to javascript correctly", ->
+    obj = new formatter.Model
+      code: "(tick) -> return tick*10"
+      lang: "coffeescript"
+
+    labels = obj.doFormat([-10, -0.1, 0, 0.1, 10])
+    expect(labels).to.deep.equal([-100, -1.0, 0, 1, 100])
diff --git a/bokehjs/test/models/formatters/index.coffee b/bokehjs/test/models/formatters/index.coffee
--- a/bokehjs/test/models/formatters/index.coffee
+++ b/bokehjs/test/models/formatters/index.coffee
@@ -1 +1,2 @@
 require "./numeral_tick_formatter"
+require "./func_tick_formatter"