Repository: bokeh/bokeh

Issue:
Themes aren't applied to document when using `save`
Themes aren't being applied to a document when using `bokeh.io.save`. Themes seem to work when using `bokeh.io.show`.

Toggle the save/show lines in the below code example to reproduce.

```python
import colorsys
import yaml

from bokeh.layouts import column, row, widgetbox
from bokeh.models import ColumnDataSource, HoverTool, CustomJS, Slider
from bokeh.plotting import figure, output_file, show, curdoc, save
from bokeh.themes import Theme

# for plot 2: create colour spectrum of resolution N and brightness I, return as list of decimal RGB value tuples
def generate_color_range(N, I):
    HSV_tuples = [ (x*1.0/N, 0.5, I) for x in range(N) ]
    RGB_tuples = map(lambda x: colorsys.hsv_to_rgb(*x), HSV_tuples)
    for_conversion = []
    for RGB_tuple in RGB_tuples:
        for_conversion.append((int(RGB_tuple[0]*255), int(RGB_tuple[1]*255), int(RGB_tuple[2]*255)))
    hex_colors = [ rgb_to_hex(RGB_tuple) for RGB_tuple in for_conversion ]
    return hex_colors, for_conversion

# convert RGB tuple to hexadecimal code
def rgb_to_hex(rgb):
    return '#%02x%02x%02x' % rgb

# convert hexadecimal to RGB tuple
def hex_to_dec(hex):
    red = ''.join(hex.strip('#')[0:2])
    green = ''.join(hex.strip('#')[2:4])
    blue = ''.join(hex.strip('#')[4:6])
    return (int(red, 16), int(green, 16), int(blue,16))

# plot 1: create a color block with RGB values adjusted with sliders

# initialise a white block for the first plot
hex_color = rgb_to_hex((255, 255, 255))

# initialise the text color as black. This will be switched to white if the block color gets dark enough
text_color = '#000000'

# create a data source to enable refreshing of fill & text color
source = ColumnDataSource(data=dict(color=[hex_color], text_color=[text_color]))

# create first plot, as a rect() glyph and centered text label, with fill and text color taken from source
p1 = figure(x_range=(-8, 8), y_range=(-4, 4),
            plot_width=600, plot_height=300,
            title='move sliders to change', tools='')

p1.rect(0, 0, width=18, height=10, fill_color='color',
        line_color = 'black', source=source)

p1.text(0, 0, text='color', text_color='text_color',
        alpha=0.6667, text_font_size='36pt', text_baseline='middle',
        text_align='center', source=source)

# the callback function to update the color of the block and associated label text
# NOTE: the JS functions for converting RGB to hex are taken from the excellent answer
# by Tim Down at http://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb
callback = CustomJS(args=dict(source=source), code="""
    function componentToHex(c) {
        var hex = c.toString(16);
        return hex.length == 1 ? "0" + hex : hex;
    }
    function rgbToHex(r, g, b) {
        return "#" + componentToHex(r) + componentToHex(g) + componentToHex(b);
    }
    var data = source.data;
    color = data['color'];
    text_color = data['text_color'];
    var R = red_slider.value;
    var G = green_slider.value;
    var B = blue_slider.value;
    color[0] = rgbToHex(R, G, B);
    text_color[0] = '#ffffff';
    if ((R > 127) || (G > 127) || (B > 127)) {
        text_color[0] = '#000000';
    }
    source.trigger('change');
""")

# create slider tool objects with a callback to control the RGB levels for first plot
SLIDER_ARGS = dict(start=0, end=255, value=255, step=1, callback=callback)

red_slider = Slider(title="R", **SLIDER_ARGS)
callback.args['red_slider'] = red_slider

green_slider = Slider(title="G", **SLIDER_ARGS)
callback.args['green_slider'] = green_slider

blue_slider = Slider(title="B", **SLIDER_ARGS)
callback.args['blue_slider'] = blue_slider

# plot 2: create a color spectrum with a hover-over tool to inspect hex codes

brightness = 0.8 # change to have brighter/darker colors
crx = list(range(1,1001)) # the resolution is 1000 colors
cry = [ 5 for i in range(len(crx)) ]
crcolor, crRGBs = generate_color_range(1000,brightness) # produce spectrum

# make data source object to allow information to be displayed by hover tool
crsource = ColumnDataSource(data=dict(x=crx, y=cry, crcolor=crcolor, RGBs=crRGBs))

# create second plot
p2 = figure(x_range=(0,1000), y_range=(0,10),
            plot_width=600, plot_height=150,
            tools='hover', title='hover over color')

color_range1 = p2.rect(x='x', y='y', width=1, height=10,
                       color='crcolor', source=crsource)

# set up hover tool to show color hex code and sample swatch
p2.select_one(HoverTool).tooltips = [
    ('color', '$color[hex, rgb, swatch]:crcolor'),
    ('RGB levels', '@RGBs')
]

# theme everything for a cleaner look
curdoc().theme = Theme(json=yaml.load("""
attrs:
    Plot:
        toolbar_location: null
    Grid:
        grid_line_color: null
    Axis:
        axis_line_color: null
        major_label_text_color: null
        major_tick_line_color: null
        minor_tick_line_color: null
"""))

layout = row(
    widgetbox(red_slider, green_slider, blue_slider),
    column(p1, p2)
)

output_file("color_sliders.html", title="color_sliders.py example")

## toggle this to see document being applied
# show(layout)
save(layout)
```


Discussion:
Part of the issue is that `bokeh.io.save` excepts the `obj` arg to be:        
```obj (Document or model object) : a plot object to save```

While bokeh expects `bokeh.io.show` to be:
```obj (LayoutDOM object) : a Layout (Row/Column), Plot or Widget object to display```

I would expect the `save` and `show` method to accept identical values. What's the reason for the mismatch? Is one "right"?
I doubt anyone is doing `save` on a document. I'd be OK with both accepting `LayoutDOM` but we'd need a migration note. 

Resolving patch:
diff --git a/bokeh/command/subcommands/html.py b/bokeh/command/subcommands/html.py
--- a/bokeh/command/subcommands/html.py
+++ b/bokeh/command/subcommands/html.py
@@ -54,7 +54,7 @@
 from __future__ import absolute_import
 
 from bokeh.resources import Resources
-from bokeh.embed import standalone_html_page_for_models
+from bokeh.embed import file_html
 
 from .file_output import FileOutputSubcommand
 
@@ -88,4 +88,4 @@ def after_write_file(self, args, filename, doc):
 
     def file_contents(self, args, doc):
         resources = Resources(mode="cdn", root_dir=None)
-        return standalone_html_page_for_models(doc, resources=resources, title=None)
+        return file_html(doc, resources)
diff --git a/bokeh/embed.py b/bokeh/embed.py
--- a/bokeh/embed.py
+++ b/bokeh/embed.py
@@ -28,6 +28,7 @@
 from .document import Document, DEFAULT_TITLE
 from .model import Model
 from .resources import BaseResources, _SessionCoordinates, EMPTY
+from .util.deprecation import deprecated
 from .util.string import encode_utf8
 from .util.serialization import make_id
 
@@ -52,22 +53,22 @@ def _wrap_in_onload(code):
 """ % dict(code=_indent(code, 4))
 
 @contextmanager
-def _ModelInDocument(models, theme=None):
+def _ModelInDocument(models, apply_theme=None):
     doc = _find_existing_docs(models)
     old_theme = doc.theme
 
-    if theme is FromCurdoc:
+    if apply_theme is FromCurdoc:
         from .io import curdoc; curdoc
         doc.theme = curdoc().theme
-    elif theme is not None:
-        doc.theme = theme
+    elif apply_theme is not None:
+        doc.theme = apply_theme
 
     models_to_dedoc = _add_doc_to_models(doc, models)
 
     yield models
 
     for model in models_to_dedoc:
-        doc.remove_root(model, theme)
+        doc.remove_root(model, apply_theme)
     doc.theme = old_theme
 
 
@@ -83,7 +84,7 @@ def _find_existing_docs(models):
         doc = existing_docs.pop()
     else:
         # conflicting/multiple docs, raise an error
-        msg = ('Multiple items in models conatain documents or are '
+        msg = ('Multiple items in models contain documents or are '
                'themselves documents. (Models must be owned by only a '
                'single document). This may indicate a usage error.')
         raise RuntimeError(msg)
@@ -197,7 +198,7 @@ def components(models, wrap_script=True, wrap_plot_info=True, theme=FromCurdoc):
         models = values
 
     # 2) Append models to one document. Either pre-existing or new and render
-    with _ModelInDocument(models, theme=theme):
+    with _ModelInDocument(models, apply_theme=theme):
         (docs_json, render_items) = _standalone_docs_json_and_render_items(models)
 
     script = _script_for_render_items(docs_json, render_items, websocket_url=None, wrap_script=wrap_script)
@@ -302,7 +303,7 @@ def notebook_div(model, notebook_comms_target=None, theme=FromCurdoc):
     model = _check_one_model(model)
 
     # Append models to one document. Either pre-existing or new and render
-    with _ModelInDocument([model], theme=theme):
+    with _ModelInDocument([model], apply_theme=theme):
         (docs_json, render_items) = _standalone_docs_json_and_render_items([model])
 
     item = render_items[0]
@@ -340,10 +341,8 @@ def file_html(models,
               template_variables={}):
     '''Return an HTML document that embeds Bokeh Model or Document objects.
 
-    The data for the plot is stored directly in the returned HTML.
-
-    This is an alias for standalone_html_page_for_models() which
-    supports customizing the JS/CSS resources independently and
+    The data for the plot is stored directly in the returned HTML, with
+    support for customizing the JS/CSS resources independently and
     customizing the jinja2 template.
 
     Args:
@@ -659,6 +658,7 @@ def standalone_html_page_for_models(models, resources, title):
         UTF-8 encoded HTML
 
     '''
+    deprecated((0, 12, 5), 'bokeh.io.standalone_html_page_for_models', 'bokeh.io.file_html')
     return file_html(models, resources, title)
 
 def server_html_page_for_models(session_id, model_ids, resources, title, websocket_url, template=FILE):
diff --git a/bokeh/io.py b/bokeh/io.py
--- a/bokeh/io.py
+++ b/bokeh/io.py
@@ -29,10 +29,10 @@
 # Bokeh imports
 from .core.state import State
 from .document import Document
-from .embed import notebook_div, standalone_html_page_for_models
-from .models.layouts import LayoutDOM
+from .embed import notebook_div, file_html
 from .layouts import gridplot, GridSpec ; gridplot, GridSpec
 import bokeh.util.browser as browserlib  # full import needed for test mocking to work
+from .util.deprecation import deprecated
 from .util.notebook import load_notebook, publish_display_data, get_comms
 from .util.string import decode_utf8
 from .util.serialization import make_id
@@ -262,7 +262,7 @@ def _show_notebook_with_state(obj, state, notebook_handle):
         state.last_comms_handle = handle
         return handle
 
-def save(obj, filename=None, resources=None, title=None, state=None, validate=True):
+def save(obj, filename=None, resources=None, title=None, state=None, **kwargs):
     ''' Save an HTML file with the data for the current document.
 
     Will fall back to the default output state (or an explicitly provided
@@ -272,11 +272,10 @@ def save(obj, filename=None, resources=None, title=None, state=None, validate=Tr
     ``/foo/myplot.html``)
 
     Args:
-        obj (Document or model object) : a plot object to save
+        obj (LayoutDOM object) : a Layout (Row/Column), Plot or Widget object to display
 
         filename (str, optional) : filename to save document under (default: None)
-            If None, use the default state configuration, otherwise raise a
-            ``RuntimeError``.
+            If None, use the default state configuration.
 
         resources (Resources, optional) : A Resources config to use (default: None)
             If None, use the default state configuration, if there is one.
@@ -286,20 +285,20 @@ def save(obj, filename=None, resources=None, title=None, state=None, validate=Tr
             If None, use the default state title value, if there is one.
             Otherwise, use "Bokeh Plot"
 
-        validate (bool, optional) : True to check integrity of the models
-
     Returns:
         filename (str) : the filename where the HTML file is saved.
 
-    Raises:
-        RuntimeError
-
     '''
+
+    if 'validate' in kwargs:
+        deprecated((0, 12, 5), 'The `validate` keyword argument', 'None', """
+        The keyword argument has been removed and the document will always be validated.""")
+
     if state is None:
         state = _state
 
     filename, resources, title = _get_save_args(state, filename, resources, title)
-    _save_helper(obj, filename, resources, title, validate)
+    _save_helper(obj, filename, resources, title)
     return os.path.abspath(filename)
 
 def _detect_filename(ext):
@@ -353,29 +352,12 @@ def _get_save_args(state, filename, resources, title):
 
     return filename, resources, title
 
-def _save_helper(obj, filename, resources, title, validate):
-    remove_after = False
-    if isinstance(obj, LayoutDOM):
-        if obj.document is None:
-            Document().add_root(obj)
-            remove_after = True
-        doc = obj.document
-    elif isinstance(obj, Document):
-        doc = obj
-    else:
-        raise RuntimeError("Unable to save object of type '%s'" % type(obj))
-
-    if validate:
-        doc.validate()
-
-    html = standalone_html_page_for_models(doc, resources, title)
+def _save_helper(obj, filename, resources, title):
+    html = file_html(obj, resources, title=title)
 
     with io.open(filename, mode="w", encoding="utf-8") as f:
         f.write(decode_utf8(html))
 
-    if remove_after:
-        doc.remove_root(obj)
-
 def push_notebook(document=None, state=None, handle=None):
     ''' Update Bokeh plots in a Jupyter notebook output cells with new data
     or property values.


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
@@ -12,7 +12,6 @@
 
 import bokeh.io as io
 from bokeh.resources import Resources
-from bokeh.document import Document
 from bokeh.models.plots import Plot
 
 class TestDefaultState(unittest.TestCase):
@@ -93,11 +92,6 @@ def test_default_filename(self):
         filename, resources, title = io._get_save_args(io._state, None, "resources", "title")
         self.assertEqual(filename, "filename")
 
-    def test_missing_filename(self):
-        io._state.file = None
-        with self.assertRaises(RuntimeError):
-            io.save("obj", None, "resources", "title")
-
     def test_explicit_resources(self):
         filename, resources, title = io._get_save_args(io._state, "filename", "resources", "title")
         self.assertEqual(resources, "resources")
@@ -142,31 +136,16 @@ def test_missing_title(self, mock_warn):
 class Test_SaveHelper(DefaultStateTester):
 
     @patch('io.open')
-    @patch('bokeh.io.standalone_html_page_for_models')
-    def test_obj_arg_is_document(self, mock_standalone_html_page, mock_io_open):
-        obj = Document()
-        filename, resources, title = io._get_save_args(io._state, "filename", "resources", "title")
-        io._save_helper(obj, filename, resources, title, True)
-
-        self._check_func_called(mock_standalone_html_page,
-                                (obj, resources, title),
-                                {})
-        self._check_func_called(mock_io_open,
-                                (filename,),
-                                {"mode":"w", "encoding":"utf-8"})
-
-    @patch('io.open')
-    @patch('bokeh.io.standalone_html_page_for_models')
-    def test_obj_arg_is_layoutdom(self, mock_standalone_html_page, mock_io_open):
+    @patch('bokeh.io.file_html')
+    def test_save_helper_method(self, mock_file_html, mock_io_open):
         obj = Plot()
-        doc = Document()
-        doc.add_root(obj)
         filename, resources, title = io._get_save_args(io._state, "filename", "resources", "title")
-        io._save_helper(obj, filename, resources, title, True)
 
-        self._check_func_called(mock_standalone_html_page,
-                                (doc, resources, title),
-                                {})
+        io._save_helper(obj, filename, resources, title)
+
+        self._check_func_called(mock_file_html,
+                                (obj, resources),
+                                {"title": "title"})
         self._check_func_called(mock_io_open,
                                 (filename,),
                                 {"mode":"w", "encoding":"utf-8"})