Repository: bokeh/bokeh

Issue:
Improve document changed events chain (MultipleDispatching)
Idea is to move logics away from session/document. Idea proposed is using [multiple dispathing](http://python-3-patterns-idioms-test.readthedocs.org/en/latest/MultipleDispatching.html). Something along the lines of: (untested!!)

``` Python
class DocumentChangedEvent(object):
    def dispatch(self, receiver):
        if hasattr(receiver, '_document_changed'):
          receiver._document_changed(self)

class SessionCallbackAdded(DocumentChangedEvent):
    def dispatch(self, receiver):
        super(SessionCallbackAdded, self).dispatch(receiver)
        if hasattr(receiver, '_session_callback_added'):
          receiver._session_callback_added(self)
```

Receiver would have to attach like this I guess:

``` Python
document.on_change(lambda event: event.dispatch(self))
```

That could maybe be a convenience method on Document, like:

``` Python
class Document:
  def on_change_dispatch_to(self, receiver):
     self.on_change(lambda event: event.dispatch(receiver))
```

Original discussion: #3119



Discussion:
Would it help to leverage this library: https://github.com/mrocklin/multipledispatch?

Its also used in blaze. 

> Would it help to leverage this library: https://github.com/mrocklin/multipledispatch?

@datnamer thanks for the suggestion. In this specific case I'd tend to avoid yet another dependency since out MD needs are not that hard/long to tackle. other @bokeh/core have strong opinions about this?

Was thinking a bit more about this:

> Receiver would have to attach like this I guess:
> 
> ```
> document.on_change(lambda event: event.dispatch(self))
> ```
> 
> That could maybe be a convenience method on Document, like:
> 
> ```
> class Document:
>   def on_change_dispatch_to(self, receiver):
>      self.on_change(lambda event: event.dispatch(receiver))
> ```

and we'd end up having the problem of lambda capturing the original event dispatcher. So this https://github.com/bokeh/bokeh/blob/master/bokeh/document.py#L376 would not work anymore. We can surely deal with it but I don't have an option that makes me really happy...  For instance, having a `dict` of callbacks instead of `list` would let us track the original callback and do the job but, again, feel that using lambda is a bit of a hack. Maybe in this context we should talk more about `receivers` then `callbacks`? (but also in this case I don't think it's a perfect match..).

@bokeh/core , specially @havocp do you have thoughts/suggestions?


Resolving patch:
diff --git a/bokeh/client/session.py b/bokeh/client/session.py
--- a/bokeh/client/session.py
+++ b/bokeh/client/session.py
@@ -10,7 +10,7 @@
 from .connection import ClientConnection
 
 from bokeh.resources import DEFAULT_SERVER_WEBSOCKET_URL, DEFAULT_SERVER_HTTP_URL, server_url_for_websocket_url
-from bokeh.document import Document, SessionCallbackAdded, SessionCallbackRemoved, PeriodicCallback, TimeoutCallback
+from bokeh.document import Document, PeriodicCallback, TimeoutCallback
 import uuid
 
 DEFAULT_SESSION_ID = "default"
@@ -157,7 +157,7 @@ def __init__(self, session_id=DEFAULT_SESSION_ID, io_loop=None, url=DEFAULT_SERV
 
     def _attach_document(self, document):
         self._document = document
-        self._document.on_change(self._document_changed)
+        self._document.on_change_dispatch_to(self)
 
         for cb in self._document.session_callbacks:
             self._add_periodic_callback(cb)
@@ -316,33 +316,14 @@ def force_roundtrip(self):
     def _notify_disconnected(self):
         '''Called by the ClientConnection we are using to notify us of disconnect'''
         if self._document is not None:
-            self._document.remove_on_change(self._document_changed)
+            self._document.remove_on_change(self)
+
+    def _document_patched(self, event):
 
-    def _document_changed(self, event):
         if self._current_patch is not None and self._current_patch.should_suppress_on_change(event):
             log.debug("Not sending notification back to server for a change it requested")
             return
 
-        if isinstance(event, SessionCallbackAdded):
-            if isinstance(event.callback, PeriodicCallback):
-                self._add_periodic_callback(event.callback)
-            elif isinstance(event.callback, TimeoutCallback):
-                self._add_timeout_callback(event.callback)
-            else:
-                raise ValueError("Expected callback of type PeriodicCallback or TimeoutCallback, got: %s" % event.callback)
-
-            return
-
-        elif isinstance(event, SessionCallbackRemoved):
-            if isinstance(event.callback, PeriodicCallback):
-                self._remove_periodic_callback(event.callback)
-            elif isinstance(event.callback, TimeoutCallback):
-                self._remove_timeout_callback(event.callback)
-            else:
-                raise ValueError("Expected callback of type PeriodicCallback or TimeoutCallback, got: %s" % event.callback)
-
-            return
-
         # TODO (havocp): our "change sync" protocol is flawed
         # because if both sides change the same attribute at the
         # same time, they will each end up with the state of the
@@ -355,3 +336,19 @@ def _handle_patch(self, message):
             message.apply_to_document(self.document)
         finally:
             self._current_patch = None
+
+    def _session_callback_added(self, event):
+        if isinstance(event.callback, PeriodicCallback):
+            self._add_periodic_callback(event.callback)
+        elif isinstance(event.callback, TimeoutCallback):
+            self._add_timeout_callback(event.callback)
+        else:
+            raise ValueError("Expected callback of type PeriodicCallback or TimeoutCallback, got: %s" % event.callback)
+
+    def _session_callback_removed(self, event):
+        if isinstance(event.callback, PeriodicCallback):
+            self._remove_periodic_callback(event.callback)
+        elif isinstance(event.callback, TimeoutCallback):
+            self._remove_timeout_callback(event.callback)
+        else:
+            raise ValueError("Expected callback of type PeriodicCallback or TimeoutCallback, got: %s" % event.callback)
diff --git a/bokeh/document.py b/bokeh/document.py
--- a/bokeh/document.py
+++ b/bokeh/document.py
@@ -27,7 +27,20 @@ class DocumentChangedEvent(object):
     def __init__(self, document):
         self.document = document
 
-class ModelChangedEvent(DocumentChangedEvent):
+    def dispatch(self, receiver):
+        if hasattr(receiver, '_document_changed'):
+            receiver._document_changed(self)
+
+class DocumentPatchedEvent(DocumentChangedEvent):
+    def __init__(self, document):
+        self.document = document
+
+    def dispatch(self, receiver):
+        super(DocumentPatchedEvent, self).dispatch(receiver)
+        if hasattr(receiver, '_document_patched'):
+            receiver._document_patched(self)
+
+class ModelChangedEvent(DocumentPatchedEvent):
     def __init__(self, document, model, attr, old, new):
         super(ModelChangedEvent, self).__init__(document)
         self.model = model
@@ -35,17 +48,22 @@ def __init__(self, document, model, attr, old, new):
         self.old = old
         self.new = new
 
-class TitleChangedEvent(DocumentChangedEvent):
+    def dispatch(self, receiver):
+        super(ModelChangedEvent, self).dispatch(receiver)
+        if hasattr(receiver, '_document_model_changed'):
+            receiver._document_patched(self)
+
+class TitleChangedEvent(DocumentPatchedEvent):
     def __init__(self, document, title):
         super(TitleChangedEvent, self).__init__(document)
         self.title = title
 
-class RootAddedEvent(DocumentChangedEvent):
+class RootAddedEvent(DocumentPatchedEvent):
     def __init__(self, document, model):
         super(RootAddedEvent, self).__init__(document)
         self.model = model
 
-class RootRemovedEvent(DocumentChangedEvent):
+class RootRemovedEvent(DocumentPatchedEvent):
     def __init__(self, document, model):
         super(RootRemovedEvent, self).__init__(document)
         self.model = model
@@ -55,11 +73,21 @@ def __init__(self, document, callback):
         super(SessionCallbackAdded, self).__init__(document)
         self.callback = callback
 
+    def dispatch(self, receiver):
+        super(SessionCallbackAdded, self).dispatch(receiver)
+        if hasattr(receiver, '_session_callback_added'):
+            receiver._session_callback_added(self)
+
 class SessionCallbackRemoved(DocumentChangedEvent):
     def __init__(self, document, callback):
         super(SessionCallbackRemoved, self).__init__(document)
         self.callback = callback
 
+    def dispatch(self, receiver):
+        super(SessionCallbackRemoved, self).dispatch(receiver)
+        if hasattr(receiver, '_session_callback_removed'):
+            receiver._session_callback_removed(self)
+
 class SessionCallback(object):
     def __init__(self, document, callback, id=None):
         if id is None:
@@ -166,7 +194,7 @@ def __init__(self, **kwargs):
         self._all_models_freeze_count = 0
         self._all_models = dict()
         self._all_models_by_name = _MultiValuedDict()
-        self._callbacks = []
+        self._callbacks = {}
         self._session_callbacks = {}
 
     def clear(self):
@@ -378,7 +406,11 @@ def on_change(self, *callbacks):
 
             _check_callback(callback, ('event',))
 
-            self._callbacks.append(callback)
+            self._callbacks[callback] = callback
+
+    def on_change_dispatch_to(self, receiver):
+        if not receiver in self._callbacks:
+            self._callbacks[receiver] = lambda event: event.dispatch(receiver)
 
     def remove_on_change(self, *callbacks):
         ''' Remove a callback added earlier with on_change()
@@ -387,7 +419,7 @@ def remove_on_change(self, *callbacks):
 
         '''
         for callback in callbacks:
-            self._callbacks.remove(callback)
+            del self._callbacks[callback]
 
     def _with_self_as_curdoc(self, f):
         from bokeh.io import set_curdoc, curdoc
@@ -408,7 +440,7 @@ def invoke():
 
     def _trigger_on_change(self, event):
         def invoke_callbacks():
-            for cb in self._callbacks:
+            for cb in self._callbacks.values():
                 cb(event)
         self._with_self_as_curdoc(invoke_callbacks)
 
diff --git a/bokeh/server/session.py b/bokeh/server/session.py
--- a/bokeh/server/session.py
+++ b/bokeh/server/session.py
@@ -7,7 +7,7 @@
 log = logging.getLogger(__name__)
 
 from tornado import gen, locks
-from bokeh.document import SessionCallbackAdded, SessionCallbackRemoved, PeriodicCallback, TimeoutCallback
+from bokeh.document import PeriodicCallback, TimeoutCallback
 
 import time
 
@@ -99,7 +99,7 @@ def __init__(self, session_id, document, io_loop=None):
         self._lock = locks.Lock()
         self._current_patch = None
         self._current_patch_connection = None
-        self._document.on_change(self._document_changed)
+        self._document.on_change_dispatch_to(self)
         self._callbacks = {}
         self._pending_writes = None
 
@@ -181,30 +181,10 @@ def _remove_timeout_callback(self, callback):
         cb = self._callbacks.pop(callback.id)
         self._loop.remove_timeout(cb)
 
-    def _document_changed(self, event):
+    def _document_patched(self, event):
         may_suppress = self._current_patch is not None and \
                        self._current_patch.should_suppress_on_change(event)
 
-        if isinstance(event, SessionCallbackAdded):
-            if isinstance(event.callback, PeriodicCallback):
-                self._add_periodic_callback(event.callback)
-            elif isinstance(event.callback, TimeoutCallback):
-                self._add_timeout_callback(event.callback)
-            else:
-                raise ValueError("Expected callback of type PeriodicCallback or TimeoutCallback, got: %s" % event.callback)
-
-            return
-
-        elif isinstance(event, SessionCallbackRemoved):
-            if isinstance(event.callback, PeriodicCallback):
-                self._remove_periodic_callback(event.callback)
-            elif isinstance(event.callback, TimeoutCallback):
-                self._remove_timeout_callback(event.callback)
-            else:
-                raise ValueError("Expected callback of type PeriodicCallback or TimeoutCallback, got: %s" % event.callback)
-
-            return
-
         if self._pending_writes is None:
             raise RuntimeError("_pending_writes should be non-None when we have a document lock, and we should have the lock when the document changes")
 
@@ -223,6 +203,23 @@ def _handle_pull(self, message, connection):
         log.debug("Sending pull-doc-reply from session %r", self.id)
         return connection.protocol.create('PULL-DOC-REPLY', message.header['msgid'], self.document)
 
+    def _session_callback_added(self, event):
+        if isinstance(event.callback, PeriodicCallback):
+            self._add_periodic_callback(event.callback)
+        elif isinstance(event.callback, TimeoutCallback):
+            self._add_timeout_callback(event.callback)
+        else:
+            raise ValueError("Expected callback of type PeriodicCallback or TimeoutCallback, got: %s" % event.callback)
+
+    def _session_callback_removed(self, event):
+        if isinstance(event.callback, PeriodicCallback):
+            self._remove_periodic_callback(event.callback)
+        elif isinstance(event.callback, TimeoutCallback):
+            self._remove_timeout_callback(event.callback)
+        else:
+            raise ValueError("Expected callback of type PeriodicCallback or TimeoutCallback, got: %s" % event.callback)
+
+
     @classmethod
     def pull(cls, message, connection):
         ''' Handle a PULL-DOC, return a Future with work to be scheduled. '''


Test patch: