Repository: bokeh/bokeh

Issue:
Non-deterministic ordering of CSS resources for external resource loading.
I was just using the new external resource loading feature of bokeh. I have a series of three css files. With css the ordering is important. It seems that the order that they're actually put into the document changes on each run.

ping @canavandl - is this likely or do you need more info?



Discussion:
Here's the issue:

https://github.com/bokeh/bokeh/blob/master/bokeh/resources.py#L293

We're deduplicating resources by casting to a set (which is unordered). We should either reassess the advisability of deduplicating (it's a big "magical", which isn't great) and decide to remove it or write a filter fxn.

Also JS is a problem too.


Resolving patch:
diff --git a/bokeh/resources.py b/bokeh/resources.py
--- a/bokeh/resources.py
+++ b/bokeh/resources.py
@@ -281,16 +281,18 @@ def _collect_external_resources(self, resource_attr):
 
         external_resources = []
 
-        for cls in Model.model_class_reverse_map.values():
+        for _, cls in sorted(Model.model_class_reverse_map.items(), key=lambda arg: arg[0]):
             external = getattr(cls, resource_attr, None)
 
             if isinstance(external, string_types):
-                external_resources.append(external)
+                if external not in external_resources:
+                    external_resources.append(external)
             elif isinstance(external, list):
-                external_resources.extend(external)
+                for e in external:
+                    if e not in external_resources:
+                        external_resources.append(e)
 
-        # Return only unique external resources
-        return list(set(external_resources))
+        return external_resources
 
 
     def _cdn_urls(self):


Test patch:
diff --git a/bokeh/tests/test_resources.py b/bokeh/tests/test_resources.py
--- a/bokeh/tests/test_resources.py
+++ b/bokeh/tests/test_resources.py
@@ -234,9 +234,13 @@ class CustomModel1(Model):
         __css__ = "external_css_1"
 
     class CustomModel2(Model):
-        __javascript__ =["external_js_2", "external_js_3"]
+        __javascript__ = ["external_js_2", "external_js_3"]
         __css__ = ["external_css_2", "external_css_3"]
 
+    class CustomModel3(Model):
+        __javascript__ = ["external_js_1", "external_js_3"]
+        __css__ = ["external_css_1", "external_css_2"]
+
     r = resources.Resources()
 
     assert "external_js_1" in r.js_files
@@ -246,3 +250,26 @@ class CustomModel2(Model):
     assert "external_js_3" in r.js_files
     assert "external_css_2" in r.css_files
     assert "external_css_3" in r.css_files
+
+    # Deduplication should keep the first instance of every file
+    assert r.css_files.count("external_css_1") == 1
+    assert r.css_files.count("external_css_2") == 1
+    assert r.js_files.count("external_js_3") == 1
+    assert r.js_files.count("external_js_1") == 1
+
+
+def test_external_js_and_css_resource_ordering():
+    class ZClass(Model):
+        __javascript__ = "z_class"
+
+    class AClass(Model):
+        __javascript__ = "a_class"
+
+    r = resources.Resources()
+
+    # a_class is before z_class because they're sorted alphabetically
+    assert r.js_files.index("a_class") < r.js_files.index("z_class")
+
+    # The files should be in the order defined by the lists in CustomModel2 and CustomModel3
+    assert r.css_files.index("external_css_3") > r.css_files.index("external_css_2")
+    assert r.js_files.index("external_js_3") > r.js_files.index("external_js_2")