Repository: bokeh/bokeh

Issue:
Make notebook display extendible by external libraries
#### Description of expected behavior and the observed behavior
I'd like to embed bokeh app into Apache Zeppelin just as embed bokeh app in Jupyter




Discussion:

Resolving patch:
diff --git a/bokeh/io.py b/bokeh/io.py
--- a/bokeh/io.py
+++ b/bokeh/io.py
@@ -18,6 +18,7 @@
 import logging
 logger = logging.getLogger(__name__)
 
+from functools import partial
 import io
 import json
 import os
@@ -48,10 +49,111 @@
 
 _state = State()
 
+_notebook_hooks = {}
+
 #-----------------------------------------------------------------------------
 # Local utilities
 #-----------------------------------------------------------------------------
 
+def install_notebook_hook(notebook_type, load, show_doc, show_app, overwrite=False):
+    ''' Install a new notebook display hook.
+
+    Bokeh comes with support for Jupyter notebooks built-in. However, there are
+    other kinds of notebooks in use by different communities. This function
+    provides a mechanism for other projects to instruct Bokeh how to display
+    content in other notebooks.
+
+    This function is primarily of use to developers wishing to integrate Bokeh
+    with new notebook types.
+
+    Args:
+        notebook_type (str) :
+            A name for the notebook type, e.e. ``'Jupyter'`` or ``'Zeppelin'``
+
+            If the name has previously been installed, a ``RuntimeError`` will
+            be raised, unless ``overwrite=True``
+
+        load (callable) :
+            A function for loading BokehJS in a notebook type. The function
+            will be called with the following arguments:
+
+            .. code-block:: python
+
+                load(
+                    resources,   # A Resources object for how to load BokehJS
+                    verbose,     # Whether to display verbose loading banner
+                    hide_banner, # Whether to hide the output banner entirely
+                    load_timeout # Time after which to report a load fail error
+                )
+
+        show_doc (callable) :
+            A function for displaying Bokeh standalone documents in the
+            notebook type. This function will be called with the following
+            arguments:
+
+            .. code-block:: python
+
+                show_doc(
+                    obj,            # the Bokeh object to display
+                    state,          # current bokeh.io "state"
+                    notebook_handle # whether a notebook handle was requested
+                )
+
+        show_app (callable) :
+            A function for displaying Bokeh applications in the notebook
+            type. This function will be called with the following arguments:
+
+            .. code-block:: python
+
+                show_app(
+                    app,         # the Bokeh Application to display
+                    state,       # current bokeh.io "state"
+                    notebook_url # URL to the current active notebook page
+                )
+
+        overwrite (bool, optional) :
+            Whether to allow an existing hook to be overwritten by a new
+            definition (default: False)
+
+    Returns:
+        None
+
+    Raises:
+        RuntimeError
+            If ``notebook_type`` is already installed and ``overwrite=False``
+
+    '''
+    if notebook_type in _notebook_hooks and not overwrite:
+        raise RuntimeError("hook for notebook type %r already exists" % notebook_type)
+    _notebook_hooks[notebook_type] = dict(load=load, doc=show_doc, app=show_app)
+
+def _run_notebook_hook(notebook_type, action, *args, **kw):
+    ''' Run an installed notebook hook with supplied arguments.
+
+    Args:
+        noteboook_type (str) :
+            Name of an existing installed notebook hook
+
+        actions (str) :
+            Name of the hook action to execute, ``'doc'`` or ``'app'``
+
+    All other arguments and keyword arguments are passed to the hook action
+    exactly as supplied.
+
+    Returns:
+        Result of the hook action, as-is
+
+    Raises:
+        RunetimeError
+            If the hook or specific action is not installed
+
+    '''
+    if notebook_type not in _notebook_hooks:
+        raise RuntimeError("no display hook installed for notebook type %r" % notebook_type)
+    if _notebook_hooks[notebook_type][action] is None:
+        raise RuntimeError("notebook hook for %r did not install %r action" % notebook_type, action)
+    return _notebook_hooks[notebook_type][action](*args, **kw)
+
 #-----------------------------------------------------------------------------
 # Classes and functions
 #-----------------------------------------------------------------------------
@@ -139,8 +241,8 @@ def output_file(filename, title="Bokeh Plot", mode="cdn", root_dir=None):
     )
 
 def output_notebook(resources=None, verbose=False, hide_banner=False, load_timeout=5000, notebook_type='jupyter'):
-    ''' Configure the default output state to generate output in
-    Jupyter/Zeppelin notebook cells when :func:`show` is called.
+    ''' Configure the default output state to generate output in notebook cells
+    when :func:`show` is called.
 
     Args:
         resources (Resource, optional) :
@@ -157,6 +259,7 @@ def output_notebook(resources=None, verbose=False, hide_banner=False, load_timeo
 
         notebook_type (string, optional):
             Notebook type (default: jupyter)
+
     Returns:
         None
 
@@ -283,27 +386,24 @@ def show(obj, browser=None, new="tab", notebook_handle=False, notebook_url="loca
     # This ugliness is to prevent importing bokeh.application (which would bring
     # in Tornado) just in order to show a non-server object
     if getattr(obj, '_is_a_bokeh_application_class', False):
-        return _show_notebook_app_with_state(obj, _state, "/", notebook_url)
+        return _run_notebook_hook(_state.notebook_type, 'app', obj, _state, notebook_url)
 
     if obj not in _state.document.roots:
         _state.document.add_root(obj)
     return _show_with_state(obj, _state, browser, new, notebook_handle=notebook_handle)
 
-def _show_notebook_app_with_state(app, state, app_path, notebook_url):
-    if state.notebook_type == 'zeppelin':
-        raise ValueError("Zeppelin doesn't support show bokeh app.")
-
+def _show_jupyter_app_with_state(app, state, notebook_url):
     logging.basicConfig()
     from tornado.ioloop import IOLoop
     from .server.server import Server
     loop = IOLoop.current()
-    server = Server({app_path: app}, io_loop=loop, port=0,  allow_websocket_origin=[notebook_url])
+    server = Server({"/": app}, io_loop=loop, port=0,  allow_websocket_origin=[notebook_url])
 
     server_id = uuid.uuid4().hex
     _state.uuid_to_server[server_id] = server
 
     server.start()
-    url = 'http://%s:%d%s' % (notebook_url.split(':')[0], server.port, app_path)
+    url = 'http://%s:%d%s' % (notebook_url.split(':')[0], server.port, "/")
     script = server_document(url)
 
     publish_display_data({EXEC_MIME_TYPE: {"div": script}}, metadata={EXEC_MIME_TYPE: {"server_id": server_id}})
@@ -315,10 +415,7 @@ def _show_with_state(obj, state, browser, new, notebook_handle=False):
     shown = False
 
     if state.notebook:
-        if state.notebook_type == 'jupyter':
-            comms_handle = _show_jupyter_with_state(obj, state, notebook_handle)
-        else:
-            comms_handle = _show_zeppelin_with_state(obj, state, notebook_handle)
+        _show_notebook_doc_with_state(obj, state, notebook_handle)
         shown = True
 
     if state.file or not shown:
@@ -326,11 +423,15 @@ def _show_with_state(obj, state, browser, new, notebook_handle=False):
 
     return comms_handle
 
+# Note: this function mostly exists so it can be mocked in tests
+def _show_notebook_doc_with_state(obj, state, notebook_handle):
+    _run_notebook_hook(state.notebook_type, 'doc', obj, state, notebook_handle)
+
 def _show_file_with_state(obj, state, new, controller):
     filename = save(obj, state=state)
     controller.open("file://" + filename, new=_new_param[new])
 
-def _show_jupyter_with_state(obj, state, notebook_handle):
+def _show_jupyter_doc_with_state(obj, state, notebook_handle):
     comms_target = make_id() if notebook_handle else None
     (script, div) = notebook_content(obj, comms_target)
     publish_display_data({EXEC_MIME_TYPE: {"script": script, "div": div}}, metadata={EXEC_MIME_TYPE: {"id": obj._id}})
@@ -340,7 +441,8 @@ def _show_jupyter_with_state(obj, state, notebook_handle):
         state.last_comms_handle = handle
         return handle
 
-def _show_zeppelin_with_state(obj, state, notebook_handle):
+# TODO (bev) This should eventually be removed, but install a basic built-in hook for docs or now
+def _show_zeppelin_doc_with_state(obj, state, notebook_handle):
     if notebook_handle:
         raise ValueError("Zeppelin doesn't support notebook_handle.")
     print("%html " + notebook_div(obj))
@@ -778,3 +880,9 @@ def export_svgs(obj, filename=None, height=None, width=None, webdriver=None):
         filenames.append(filename)
 
     return filenames
+
+
+install_notebook_hook('jupyter', partial(load_notebook, notebook_type='jupyter'), _show_jupyter_doc_with_state, _show_jupyter_app_with_state)
+
+# TODO (bev) These should eventually be removed, but install a basic built-in hook for docs or now
+install_notebook_hook('zeppelin', partial(load_notebook, notebook_type='zeppelin'), _show_zeppelin_doc_with_state, None)
diff --git a/bokeh/util/notebook.py b/bokeh/util/notebook.py
--- a/bokeh/util/notebook.py
+++ b/bokeh/util/notebook.py
@@ -12,6 +12,7 @@
 
 _notebook_loaded = None
 
+# TODO (bev) notebook_type and zeppelin bits should be removed after external zeppelin hook available
 def load_notebook(resources=None, verbose=False, hide_banner=False, load_timeout=5000, notebook_type='jupyter'):
     ''' Prepare the IPython notebook for displaying Bokeh plots.
 
@@ -40,7 +41,7 @@ def load_notebook(resources=None, verbose=False, hide_banner=False, load_timeout
 
     '''
     nb_html, nb_js = _load_notebook_html(resources, verbose, hide_banner, load_timeout)
-    lab_html, lab_js = _load_notebook_html(resources, verbose, hide_banner, load_timeout, register_mime=False)
+    lab_html, lab_js = _load_notebook_html(resources, verbose, hide_banner, load_timeout, register_mimetype=False)
     if notebook_type=='jupyter':
         publish_display_data({'text/html': nb_html + _wrap_in_script_tag(nb_js),
                               LOAD_MIME_TYPE: {"script": lab_js, "div": lab_html}})
@@ -52,12 +53,13 @@ def load_notebook(resources=None, verbose=False, hide_banner=False, load_timeout
 document.getElementById("%s").textContent = "BokehJS is loading...";
 """
 
+# TODO (bev) This will eventually go away
 def _publish_zeppelin_data(html, js):
     print('%html ' + html)
     print('%html ' + '<script type="text/javascript">' + js + "</script>")
 
 def _load_notebook_html(resources=None, verbose=False, hide_banner=False,
-                        load_timeout=5000, register_mime=True):
+                        load_timeout=5000, register_mimetype=True):
     global _notebook_loaded
 
     from .. import __version__
@@ -105,7 +107,7 @@ def _load_notebook_html(resources=None, verbose=False, hide_banner=False,
         css_raw  = resources.css_raw_str,
         force    = True,
         timeout  = load_timeout,
-        register_mime = register_mime
+        register_mimetype = register_mimetype
     )
 
     return html, js


Test patch:
diff --git a/bokeh/tests/test_io.py b/bokeh/tests/test_io.py
--- a/bokeh/tests/test_io.py
+++ b/bokeh/tests/test_io.py
@@ -16,6 +16,8 @@
 import unittest
 
 import bokeh.io as io
+
+from bokeh.application.application import Application
 from bokeh.resources import Resources
 from bokeh.models.plots import Plot
 from bokeh.models import Range1d
@@ -83,23 +85,6 @@ def test_args(self, mock_load_notebook):
         self._check_func_called(io._state.output_notebook, ('jupyter',), {})
         self._check_func_called(mock_load_notebook, load_jupyter_args, {})
 
-class TestOutputZeppelin(DefaultStateTester):
-
-    @patch('bokeh.io.load_notebook')
-    def test_args(self, mock_load_notebook):
-        load_zeppelin_args = (Resources(), True, True, 1000, 'zeppelin')
-        io.output_notebook(*load_zeppelin_args)
-        self._check_func_called(io._state.output_notebook, ('zeppelin',), {})
-        self._check_func_called(mock_load_notebook, load_zeppelin_args, {})
-
-    def test_zeppelin_with_notebook_handle(self):
-        load_zeppelin_args = (Resources(), True, True, 1000, 'zeppelin')
-        io.output_notebook(*load_zeppelin_args)
-        with pytest.raises(Exception) as ex:
-            p = Plot()
-            io.show(p, notebook_handle=True)
-        assert "Zeppelin doesn't support notebook_handle." == str(ex.value)
-
 class TestSave(DefaultStateTester):
     pass
 
@@ -187,6 +172,15 @@ def test_explicit_args(self, mock__show_with_state):
         io.show("obj", **default_kwargs)
         self._check_func_called(mock__show_with_state, ("obj", io._state, "browser", "new"), {'notebook_handle': True})
 
+    @patch('bokeh.io._run_notebook_hook')
+    def test_show_with_app(self, mock__run_notebook_hook):
+        app = Application()
+        io.show(app, notebook_url="baz")
+        io._state.notebook_type == "foo"
+        self.assertTrue(mock__run_notebook_hook.called)
+        self.assertEqual(mock__run_notebook_hook.call_args[0][0], io._state.notebook_type)
+        self.assertEqual(mock__run_notebook_hook.call_args[0][1:], ("app", app, io._state, "baz"))
+        self.assertEqual(mock__run_notebook_hook.call_args[1], {})
 
 @patch('bokeh.io._show_with_state')
 def test_show_adds_obj_to_document_if_not_already_there(m):
@@ -208,29 +202,29 @@ def test_show_doesnt_duplicate_if_already_there(m):
 
 class Test_ShowWithState(DefaultStateTester):
 
-    @patch('bokeh.io._show_jupyter_with_state')
+    @patch('bokeh.io._show_notebook_doc_with_state')
     @patch('bokeh.io._show_file_with_state')
     @patch('bokeh.util.browser.get_browser_controller')
     def test_notebook(self, mock_get_browser_controller,
-            mock__show_file_with_state, mock__show_jupyter_with_state):
+            mock__show_file_with_state, mock__show_notebook_doc_with_state):
         mock_get_browser_controller.return_value = "controller"
         s = io.State()
         s.output_notebook()
         io._show_with_state("obj", s, "browser", "new")
-        self._check_func_called(mock__show_jupyter_with_state, ("obj", s, False), {})
+        self._check_func_called(mock__show_notebook_doc_with_state, ("obj", s, False), {})
         self.assertFalse(mock__show_file_with_state.called)
 
         s.output_file("foo.html")
         io._show_with_state("obj", s, "browser", "new")
-        self._check_func_called(mock__show_jupyter_with_state, ("obj", s, False), {})
+        self._check_func_called(mock__show_notebook_doc_with_state, ("obj", s, False), {})
         self._check_func_called(mock__show_file_with_state, ("obj", s, "new", "controller"), {})
 
     @patch('bokeh.io.get_comms')
-    @patch('bokeh.io._show_jupyter_with_state')
+    @patch('bokeh.io._show_notebook_doc_with_state')
     @patch('bokeh.io._show_file_with_state')
     @patch('bokeh.util.browser.get_browser_controller')
     def test_no_notebook(self, mock_get_browser_controller,
-            mock__show_file_with_state, mock__show_jupyter_with_state,
+            mock__show_file_with_state, mock__show_notebook_doc_with_state,
             mock_get_comms):
         mock_get_browser_controller.return_value = "controller"
         mock_get_comms.return_value = "comms"
@@ -238,7 +232,7 @@ def test_no_notebook(self, mock_get_browser_controller,
 
         s.output_file("foo.html")
         io._show_with_state("obj", s, "browser", "new")
-        self.assertFalse(mock__show_jupyter_with_state.called)
+        self.assertFalse(mock__show_notebook_doc_with_state.called)
         self._check_func_called(mock__show_file_with_state, ("obj", s, "new", "controller"), {})
 
 class Test_ShowFileWithState(DefaultStateTester):
@@ -273,7 +267,7 @@ class Obj(object):
             _id = None
 
         io._nb_loaded = True
-        io._show_jupyter_with_state(Obj(), s, True)
+        io._show_jupyter_doc_with_state(Obj(), s, True)
         io._nb_loaded = False
 
         expected_args = ({'application/vnd.bokehjs_exec.v0+json': {"script": "notebook_script", "div": "notebook_div"}},)
@@ -315,6 +309,25 @@ def test__save_layout_html_resets_plot_dims():
     assert layout.plot_height == initial_height
     assert layout.plot_width == initial_width
 
+def test__notebook_hooks():
+    assert "jupyter" in io._notebook_hooks
+    # TODO (bev) can be improved after load_notebook is simplified
+    assert "load" in io._notebook_hooks["jupyter"]
+    assert io._notebook_hooks["jupyter"]['doc'] == io._show_jupyter_doc_with_state
+    assert io._notebook_hooks["jupyter"]['app'] == io._show_jupyter_app_with_state
+
+def test_install_notebook_hook():
+    io.install_notebook_hook("foo", "load", "doc", "app")
+    assert io._notebook_hooks["foo"]['load'] == "load"
+    assert io._notebook_hooks["foo"]['doc'] == "doc"
+    assert io._notebook_hooks["foo"]['app'] == "app"
+    with pytest.raises(RuntimeError):
+        io.install_notebook_hook("foo", "load2", "doc2", "app2")
+    io.install_notebook_hook("foo", "load2", "doc2", "app2", overwrite=True)
+    assert io._notebook_hooks["foo"]['load'] == "load2"
+    assert io._notebook_hooks["foo"]['doc'] == "doc2"
+    assert io._notebook_hooks["foo"]['app'] == "app2"
+
 @pytest.mark.unit
 @pytest.mark.selenium
 def test__get_screenshot_as_png():