Repository: bokeh/bokeh

Issue:
Warn about version mismatches
I found myself chasing ghosts in Bokeh this afternoon when I accidentally inlined the wrong JS version into our web application.  Perhaps Bokeh could try and detect when there are incompatible versions across the Python/JS interface?



Discussion:
I believe embedded JSON now includes a version, so this ought to be straightforward to check/warn about now.

A few helpful notes. 

The BokehJS version is available as `Bokeh.version` For reference is it built into `bokeh.js` here: https://github.com/bokeh/bokeh/blob/master/bokehjs/src/coffee/main.coffee The version number itself is automatically updated by our build/release machinery. 

The version of python used to generate the JSON  is inserted here: https://github.com/bokeh/bokeh/blob/master/bokeh/document.py#L778-L785 

Right now, the versions should always be in sync. There might come a time when the project become more "independent" and we have to specify compatibility more explicitly, but for now it suffices to just warn loudly if thee version differ. 

As for where to make the comparison, the `Document.from_json` method is probably a single point that could always work:

https://github.com/bokeh/bokeh/blob/master/bokehjs/src/coffee/document.coffee#L508

Right now it ignores the version passed in `json` but you would pull it out. I think you could compare it to `Bokeh.version` but accessing `Bokeh` like that may not be ideal. @mattpap is there a better way currently to get ahold of the version _inside_ BokehJS code? (There is a sprint contributor interested in submitting a fix for this issue)

An idea: make a `version.coffee` at the top level that exports version info so that it can be required/used like any other module inside BokehJS, and then `main.coffee` would require `version.coffee` in order to set `Bokeh.version`. This seems good but the release update script would also have to be modified (made simpler probably, can just overwrite entire `version.coffee` file)

FYI here is the script that updates the version: 

https://github.com/bokeh/bokeh/blob/master/scripts/version_update.py#L68 

So if you made a `version.coffee` that was just:

```
version = '0.11.1`

module.exports = version
```

Then the version update script is just changed to update `version.coffee` instead of `main.coffee` and `main.coffee` it updated to something like:

```
Bokeh.version = require("version")
```

And then at the top of `document.coffee` the version should also be available as

```
js_version = require("version")
```

Then in `Document.from_json` you could compare that `js_version` to the `json['version']` that has the python version. 

@mattpap comments welcome


Resolving patch:
diff --git a/scripts/version_update.py b/scripts/version_update.py
--- a/scripts/version_update.py
+++ b/scripts/version_update.py
@@ -42,7 +42,7 @@ def version_update(new_ver, file_array):
     if early_ver:
         print("Version number changed from %s to %s in \n%s" % (early_ver, new_ver, replaced))
 
-def version_add(old_ver, file_array):
+def version_add(new_ver, file_array):
     """Add last version number in an array of files
     with a user-supplied last version number"""
     for ver_file in file_array:
@@ -52,11 +52,11 @@ def version_add(old_ver, file_array):
                 if "ALL_VERSIONS" in line:
                     all_vers = flines[i]
                     begin, end = all_vers.split("[")
-                    all_vers = begin + "['{}', ".format(old_ver) + end
+                    all_vers = begin + "['{}', ".format(new_ver) + end
                     flines[i] = all_vers
         with open(ver_file, "w") as f:
             f.writelines(flines)
-        print("Version number {old_ver} added in {ver_file}".format(old_ver=old_ver, ver_file=ver_file))
+        print("Version number {new_ver} added in {ver_file}".format(new_ver=new_ver, ver_file=ver_file))
 
 if __name__ == '__main__':
     if not len(sys.argv) == 3:
@@ -65,7 +65,7 @@ def version_add(old_ver, file_array):
 
     os.chdir('../')
 
-    files_to_update = ['bokehjs/src/coffee/main.coffee', 'bokehjs/package.json']
+    files_to_update = ['bokehjs/src/coffee/version.coffee', 'bokehjs/package.json']
     files_to_add = ['sphinx/source/conf.py']
     updated_version = sys.argv[1]
     last_version = sys.argv[2]
@@ -74,4 +74,4 @@ def version_add(old_ver, file_array):
         sys.exit(1)
 
     version_update(updated_version, files_to_update)
-    version_add(last_version, files_to_add)
+    version_add(updated_version, files_to_add)
diff --git a/sphinx/source/conf.py b/sphinx/source/conf.py
--- a/sphinx/source/conf.py
+++ b/sphinx/source/conf.py
@@ -166,7 +166,7 @@
         ('Releases', 'releases/%s' % version),
         ('Developer Guide', 'dev_guide'),
     ),
-    'ALL_VERSIONS': ['0.12.0', '0.11.1', '0.11.0', '0.10.0', '0.9.3', '0.8.2'],
+    'ALL_VERSIONS': ['0.12.0', '0.11.0', '0.10.0', '0.9.3', '0.8.2'],
     'css_server': os.environ.get('BOKEH_DOCS_CSS_SERVER', 'bokehplots.com'),
 }
 


Test patch:
diff --git a/bokehjs/test/document.coffee b/bokehjs/test/document.coffee
--- a/bokehjs/test/document.coffee
+++ b/bokehjs/test/document.coffee
@@ -2,11 +2,14 @@ _ = require "underscore"
 {expect} = require "chai"
 utils = require "./utils"
 sinon = require "sinon"
+{ stdoutTrap, stderrTrap } = require 'logtrap'
 
 {Document, ModelChangedEvent, TitleChangedEvent, RootAddedEvent, RootRemovedEvent, DEFAULT_TITLE} = utils.require "document"
 {GE, Strength, Variable}  = utils.require "core/layout/solver"
+js_version = utils.require "version"
 {Models} = utils.require "base"
 Model = utils.require "model"
+logging = utils.require "core/logging"
 p = utils.require "core/properties"
 
 class AnotherModel extends Model
@@ -531,6 +534,25 @@ describe "Document", ->
     # does not reset title
     expect(d.title()).to.equal 'Foo'
 
+  it "checks for versions matching", ->
+    d = new Document()
+    expect(d.roots().length).to.equal 0
+    root1 = new SomeModel()
+    d.add_root(root1)
+    expect(d.roots().length).to.equal 1
+    d.set_title("Foo")
+
+    logging.set_log_level("warn")
+    json = d.to_json_string()
+    parsed = JSON.parse(json)
+    parsed['version'] = "0.0.1"
+    out = stderrTrap -> Document.from_json_string(JSON.stringify(parsed))
+    expect(out).to.be.equal "Bokeh: JS/Python version mismatch\nBokeh: Library versions: JS (#{js_version})  /  Python (0.0.1)\n"
+
+    parsed['version'] = "#{js_version}-foo"
+    out = stdoutTrap -> Document.from_json_string(JSON.stringify(parsed))
+    expect(out).to.be.equal ""
+
   it "can serialize with one model in it", ->
     d = new Document()
     expect(d.roots().length).to.equal 0
@@ -540,7 +562,9 @@ describe "Document", ->
     d.set_title("Foo")
 
     json = d.to_json_string()
-    copy = Document.from_json_string(json)
+    parsed = JSON.parse(json)
+    parsed['version'] = js_version
+    copy = Document.from_json_string(JSON.stringify(parsed))
 
     expect(copy.roots().length).to.equal 1
     expect(copy.roots()[0]).to.be.an.instanceof(SomeModel)
@@ -556,7 +580,8 @@ describe "Document", ->
 
     json = d.to_json_string(include_defaults=false)
     parsed = JSON.parse(json)
-    copy = Document.from_json_string(json)
+    parsed['version'] = js_version
+    copy = Document.from_json_string(JSON.stringify(parsed))
 
     expect(copy.roots().length).to.equal 1
     expect(copy.roots()[0]).to.be.an.instanceof(SomeModel)
@@ -682,7 +707,9 @@ describe "Document", ->
     d.add_root(root1)
 
     json = d.to_json_string()
-    copy = Document.from_json_string(json)
+    parsed = JSON.parse(json)
+    parsed['version'] = js_version
+    copy = Document.from_json_string(JSON.stringify(parsed))
 
     root1_copy = copy.get_model_by_id(root1.id)
 
@@ -707,7 +734,9 @@ describe "Document", ->
     d.add_root(root1)
 
     json = d.to_json_string()
-    copy = Document.from_json_string(json)
+    parsed = JSON.parse(json)
+    parsed['version'] = js_version
+    copy = Document.from_json_string(JSON.stringify(parsed))
 
     patch = Document._compute_patch_since_json(JSON.parse(json), copy)
 
@@ -740,11 +769,13 @@ describe "Document", ->
 
     d.add_root(root1)
 
-    json = d.to_json_string()
     # in computing this, we will construct a
     # ComplicatedModelWithConstructTimeChanges which will set
     # stuff in initialize(), overwriting serialized_values above.
-    copy = Document.from_json_string(json)
+    json = d.to_json_string()
+    parsed = JSON.parse(json)
+    parsed['version'] = js_version
+    copy = Document.from_json_string(JSON.stringify(parsed))
 
     patch = Document._compute_patch_since_json(JSON.parse(json), copy)
 
diff --git a/bokehjs/test/models/callbacks/customjs.coffee b/bokehjs/test/models/callbacks/customjs.coffee
--- a/bokehjs/test/models/callbacks/customjs.coffee
+++ b/bokehjs/test/models/callbacks/customjs.coffee
@@ -4,6 +4,7 @@ utils = require "../../utils"
 CustomJS = utils.require("models/callbacks/customjs").Model
 Range1d = utils.require("models/ranges/range1d").Model
 {Document} = utils.require "document"
+js_version = utils.require "version"
 
 describe "customjs module", ->
 
@@ -35,7 +36,9 @@ describe "customjs module", ->
       d = new Document()
       d.add_root(r)
       json = d.to_json_string()
-      copy = Document.from_json_string(json)
+      parsed = JSON.parse(json)
+      parsed['version'] = js_version
+      copy = Document.from_json_string(JSON.stringify(parsed))
       r_copy = copy.get_model_by_id(r.id)
       rng_copy = copy.get_model_by_id(rng.id)
       expect(r.get('values')).to.be.deep.equal [rng]