Repository: bokeh/bokeh

Issue:
Add "png" Bokeh command
ref PR #6189 

Add "png" bokeh command so that ``bokeh png example.py`` will create/save a png of the document in the example.py script.


Discussion:

Resolving patch:
diff --git a/bokeh/command/subcommands/png.py b/bokeh/command/subcommands/png.py
new file mode 100644
--- /dev/null
+++ b/bokeh/command/subcommands/png.py
@@ -0,0 +1,95 @@
+'''
+To generate a standalone PNG file for a Bokeh application from a single
+Python script, pass the script name to ``bokeh png`` on the command
+line:
+
+.. code-block:: sh
+
+    bokeh png app_script.py
+
+The generated PNG will be saved in the current working directory with
+the name ``app_script.png``.
+
+It is also possible to run the same commmand with jupyter notebooks:
+
+.. code-block:: sh
+
+    bokeh png app_notebook.ipynb
+
+This will generate an PNG file named ``app_notebook.png`` just like
+with a python script.
+
+Applications can also be created from directories. The directory should
+contain a ``main.py`` (and any other helper modules that are required) as
+well as any additional assets (e.g., theme files). Pass the directory name
+to ``bokeh png`` to generate the PNG:
+
+.. code-block:: sh
+
+    bokeh png app_dir
+
+It is possible to generate PNG files for multiple applications at once:
+
+.. code-block:: sh
+
+    bokeh png app_script.py app_dir
+
+.. warning::
+    Applications that use ``on_change`` callbacks require using the Bokeh
+    server to execute the callback code. Though the application may render,
+    the callbacks will not function. See :ref:`userguide_cli_serve` for
+    more information on using ``bokeh serve``.
+
+For all cases, it's required to explicitly add a Bokeh layout to
+``bokeh.io.curdoc`` for it to appear in the output.
+
+'''
+from __future__ import absolute_import
+
+import io
+import os
+import sys
+
+import selenium.webdriver as webdriver
+
+from bokeh.io import _get_screenshot_as_png
+from .file_output import FileOutputSubcommand
+
+
+class PNG(FileOutputSubcommand):
+    ''' Subcommand to output applications as standalone PNG files.
+
+    '''
+
+    name = "png"
+
+    extension = "png"
+
+    help = "Create standalone PNG files for one or more applications"
+
+    args = (
+
+        FileOutputSubcommand.files_arg("PNG"),
+
+    ) + FileOutputSubcommand.other_args()
+
+    def invoke(self, args):
+        self.driver = webdriver.PhantomJS(service_log_path=os.path.devnull)
+        super(PNG, self).invoke(args)
+        self.driver.quit()
+
+    def write_file(self, args, filename, doc):
+        contents = self.file_contents(args, doc)
+        if filename == '-':
+            sys.stdout.buffer.write(contents)
+        else:
+            with io.open(filename, "w+b") as f:
+                f.write(contents)
+        self.after_write_file(args, filename, doc)
+
+    def file_contents(self, args, doc):
+        image = _get_screenshot_as_png(doc, self.driver)
+        buf = io.BytesIO()
+        image.save(buf, "png")
+        buf.seek(0)
+        return buf.read()
diff --git a/bokeh/command/subcommands/svg.py b/bokeh/command/subcommands/svg.py
new file mode 100644
--- /dev/null
+++ b/bokeh/command/subcommands/svg.py
@@ -0,0 +1,99 @@
+'''
+To generate standalone SVGs for a Bokeh application from a single
+Python script, pass the script name to ``bokeh svg`` on the command
+line:
+
+.. code-block:: sh
+
+    bokeh svg app_script.py
+
+The generated SVGs will be saved in the current working directory with
+the name ``app_script.svg``. If there are multiple SVGs within an application,
+the subsequent ones will be named ``app_script_1.svg``, ``app_script_2.svg``,
+etc.
+
+It is also possible to run the same commmand with jupyter notebooks:
+
+.. code-block:: sh
+
+    bokeh svg app_notebook.ipynb
+
+This will generate SVG files named ``app_notebook_{n}.svg`` just like
+with a python script.
+
+Applications can also be created from directories. The directory should
+contain a ``main.py`` (and any other helper modules that are required) as
+well as any additional assets (e.g., theme files). Pass the directory name
+to ``bokeh svg`` to generate the SVG:
+
+.. code-block:: sh
+
+    bokeh svg app_dir
+
+It is possible to generate SVG files for multiple applications at once:
+
+.. code-block:: sh
+
+    bokeh svg app_script.py app_dir
+
+.. warning::
+    Applications that use ``on_change`` callbacks require using the Bokeh
+    server to execute the callback code. Though the application may render,
+    the callbacks will not function. See :ref:`userguide_cli_serve` for
+    more information on using ``bokeh serve``.
+
+For all cases, it's required to explicitly add a Bokeh layout to
+``bokeh.io.curdoc`` for it to appear in the output.
+
+'''
+from __future__ import absolute_import
+
+import io
+import os
+
+import selenium.webdriver as webdriver
+
+from bokeh.io import _get_svgs
+from bokeh.util.string import decode_utf8
+from .file_output import FileOutputSubcommand
+
+
+class SVG(FileOutputSubcommand):
+    ''' Subcommand to output applications as standalone PNG files.
+
+    '''
+
+    name = "svg"
+
+    extension = "svg"
+
+    help = "Create standalone SVG files for one or more applications"
+
+    args = (
+
+        FileOutputSubcommand.files_arg("SVG"),
+
+    ) + FileOutputSubcommand.other_args()
+
+    def invoke(self, args):
+        self.driver = webdriver.PhantomJS(service_log_path=os.path.devnull)
+        super(SVG, self).invoke(args)
+        self.driver.quit()
+
+    def write_file(self, args, filename, doc):
+        contents = self.file_contents(args, doc)
+        for i, svg in enumerate(contents):
+            if filename == '-':
+                print(decode_utf8(svg))
+            else:
+                if i == 0:
+                    filename = filename
+                else:
+                    idx = filename.find(".svg")
+                    filename = filename[:idx] + "_{}".format(i) + filename[idx:]
+                with io.open(filename, "w", encoding="utf-8") as f:
+                    f.write(decode_utf8(svg))
+            self.after_write_file(args, filename, doc)
+
+    def file_contents(self, args, doc):
+        return _get_svgs(doc, self.driver)
diff --git a/bokeh/io.py b/bokeh/io.py
--- a/bokeh/io.py
+++ b/bokeh/io.py
@@ -606,7 +606,7 @@ def _crop_image(image, left=0, top=0, right=0, bottom=0, **kwargs):
 
     return cropped_image
 
-def _get_screenshot_as_png(obj):
+def _get_screenshot_as_png(obj, driver):
     webdriver = import_required('selenium.webdriver',
                                 'To use bokeh.io.export_png you need selenium ' +
                                 '("conda install -c bokeh selenium" or "pip install selenium")')
@@ -620,20 +620,25 @@ def _get_screenshot_as_png(obj):
     html_path = tempfile.NamedTemporaryFile(suffix=".html").name
     save(obj, filename=html_path, resources=INLINE, title="")
 
-    driver = webdriver.PhantomJS()
-    driver.get("file:///" + html_path)
+    if driver is None:
+        web_driver = webdriver.PhantomJS(service_log_path=os.path.devnull)
+    else:
+        web_driver = driver
+
+    web_driver.get("file:///" + html_path)
 
     ## resize for PhantomJS compat
-    driver.execute_script("document.body.style.width = '100%';")
+    web_driver.execute_script("document.body.style.width = '100%';")
 
-    _wait_until_render_complete(driver)
+    _wait_until_render_complete(web_driver)
 
-    png = driver.get_screenshot_as_png()
+    png = web_driver.get_screenshot_as_png()
 
     bounding_rect_script = "return document.getElementsByClassName('bk-root')[0].children[0].getBoundingClientRect()"
-    b_rect = driver.execute_script(bounding_rect_script)
+    b_rect = web_driver.execute_script(bounding_rect_script)
 
-    driver.quit()
+    if driver is None: # only quit webdriver if not passed in as arg
+        web_driver.quit()
 
     image = Image.open(io.BytesIO(png))
     cropped_image = _crop_image(image, **b_rect)
@@ -641,13 +646,14 @@ def _get_screenshot_as_png(obj):
     return cropped_image
 
 def export_png(obj, filename=None):
-    ''' Export the LayoutDOM object as a PNG.
+    ''' Export the LayoutDOM object or document as a PNG.
 
     If the filename is not given, it is derived from the script name
     (e.g. ``/foo/myplot.py`` will create ``/foo/myplot.png``)
 
     Args:
-        obj (LayoutDOM object) : a Layout (Row/Column), Plot or Widget object to display
+        obj (LayoutDOM or Document) : a Layout (Row/Column), Plot or Widget
+            object or Document to export.
 
         filename (str, optional) : filename to save document under (default: None)
             If None, infer from the filename.
@@ -663,7 +669,7 @@ def export_png(obj, filename=None):
         Glyphs that are rendered via webgl won't be included in the generated PNG.
 
     '''
-    image = _get_screenshot_as_png(obj)
+    image = _get_screenshot_as_png(obj, None)
 
     if filename is None:
         filename = _detect_filename("png")
@@ -672,20 +678,25 @@ def export_png(obj, filename=None):
 
     return os.path.abspath(filename)
 
-def _get_svgs(obj):
+def _get_svgs(obj, driver):
     webdriver = import_required('selenium.webdriver',
                                 'To use bokeh.io.export_svgs you need selenium ' +
                                 '("conda install -c bokeh selenium" or "pip install selenium")')
     # assert that phantomjs is in path for webdriver
     detect_phantomjs()
 
+    if driver is None:
+        web_driver = webdriver.PhantomJS(service_log_path=os.path.devnull)
+    else:
+        web_driver = driver
+
     html_path = tempfile.NamedTemporaryFile(suffix=".html").name
     save(obj, filename=html_path, resources=INLINE, title="")
 
-    driver = webdriver.PhantomJS()
-    driver.get("file:///" + html_path)
+    web_driver = webdriver.PhantomJS(service_log_path=os.path.devnull)
+    web_driver.get("file:///" + html_path)
 
-    _wait_until_render_complete(driver)
+    _wait_until_render_complete(web_driver)
 
     svg_script = """
     var serialized_svgs = [];
@@ -697,9 +708,10 @@ def _get_svgs(obj):
     return serialized_svgs
     """
 
-    svgs = driver.execute_script(svg_script)
+    svgs = web_driver.execute_script(svg_script)
 
-    driver.quit()
+    if driver is None: # only quit webdriver if not passed in as arg
+        web_driver.quit()
 
     return svgs
 
@@ -725,7 +737,7 @@ def export_svgs(obj, filename=None):
         aspect ratios. It is recommended to use the default ``fixed`` sizing mode.
 
     '''
-    svgs = _get_svgs(obj)
+    svgs = _get_svgs(obj, None)
 
     if len(svgs) == 0:
         logger.warn("No SVG Plots were found.")


Test patch:
diff --git a/bokeh/command/subcommands/tests/__init__.py b/bokeh/command/subcommands/tests/__init__.py
--- a/bokeh/command/subcommands/tests/__init__.py
+++ b/bokeh/command/subcommands/tests/__init__.py
@@ -7,3 +7,19 @@
 p1 = figure()
 p1.scatter(x,y, color="#FF00FF")
 """
+
+basic_svg_scatter_script = """
+import numpy as np
+from bokeh.plotting import figure
+from bokeh.layouts import Row
+from bokeh.io import curdoc
+N = 5
+x = np.linspace(0, 4*np.pi, N)
+y = np.sin(x)
+p1 = figure(output_backend="svg")
+p1.scatter(x,y, color="#FF00FF")
+p2 = figure(output_backend="svg")
+p2.scatter(x,y, color="#00FF00")
+doc = curdoc()
+doc.add_root(Row(p1, p2))
+"""
diff --git a/bokeh/command/subcommands/tests/test_png.py b/bokeh/command/subcommands/tests/test_png.py
new file mode 100644
--- /dev/null
+++ b/bokeh/command/subcommands/tests/test_png.py
@@ -0,0 +1,107 @@
+from __future__ import absolute_import
+
+import argparse
+import pytest
+import os
+import sys
+
+is_python2 = sys.version_info[0] == 2
+
+import bokeh.command.subcommands.png as scpng
+from bokeh.command.bootstrap import main
+from bokeh.util.testing import TmpDir, WorkingDir, with_directory_contents
+
+from . import basic_scatter_script
+
+def test_create():
+    import argparse
+    from bokeh.command.subcommand import Subcommand
+
+    obj = scpng.PNG(parser=argparse.ArgumentParser())
+    assert isinstance(obj, Subcommand)
+
+def test_name():
+    assert scpng.PNG.name == "png"
+
+def test_help():
+    assert scpng.PNG.help == "Create standalone PNG files for one or more applications"
+
+def test_args():
+    assert scpng.PNG.args == (
+
+        ('files', dict(
+            metavar='DIRECTORY-OR-SCRIPT',
+            nargs='+',
+            help="The app directories or scripts to generate PNG for",
+            default=None,
+        )),
+
+        (('-o', '--output'), dict(
+            metavar='FILENAME',
+            action='append',
+            type=str,
+            help="Name of the output file or - for standard output."
+        )),
+
+        ('--args', dict(
+            metavar='COMMAND-LINE-ARGS',
+            nargs=argparse.REMAINDER,
+            help="Any command line arguments remaining are passed on to the application handler",
+        )),
+
+    )
+
+def test_no_script(capsys):
+    with (TmpDir(prefix="bokeh-png-no-script")) as dirname:
+        with WorkingDir(dirname):
+            with pytest.raises(SystemExit):
+                main(["bokeh", "png"])
+        out, err = capsys.readouterr()
+        if is_python2:
+            too_few = "too few arguments"
+        else:
+            too_few = "the following arguments are required: DIRECTORY-OR-SCRIPT"
+        assert err == """usage: bokeh png [-h] [-o FILENAME] [--args ...]
+                 DIRECTORY-OR-SCRIPT [DIRECTORY-OR-SCRIPT ...]
+bokeh png: error: %s
+""" % (too_few)
+        assert out == ""
+
+def test_basic_script(capsys):
+    def run(dirname):
+        with WorkingDir(dirname):
+            main(["bokeh", "png", "scatter.py"])
+        out, err = capsys.readouterr()
+        assert err == ""
+        assert out == ""
+
+        assert set(["scatter.png", "scatter.py"]) == set(os.listdir(dirname))
+
+    with_directory_contents({ 'scatter.py' : basic_scatter_script },
+                            run)
+
+def test_basic_script_with_output_after(capsys):
+    def run(dirname):
+        with WorkingDir(dirname):
+            main(["bokeh", "png", "scatter.py", "--output", "foo.png"])
+        out, err = capsys.readouterr()
+        assert err == ""
+        assert out == ""
+
+        assert set(["foo.png", "scatter.py"]) == set(os.listdir(dirname))
+
+    with_directory_contents({ 'scatter.py' : basic_scatter_script },
+                            run)
+
+def test_basic_script_with_output_before(capsys):
+    def run(dirname):
+        with WorkingDir(dirname):
+            main(["bokeh", "png", "--output", "foo.png", "scatter.py"])
+        out, err = capsys.readouterr()
+        assert err == ""
+        assert out == ""
+
+        assert set(["foo.png", "scatter.py"]) == set(os.listdir(dirname))
+
+    with_directory_contents({ 'scatter.py' : basic_scatter_script },
+                            run)
diff --git a/bokeh/command/subcommands/tests/test_svg.py b/bokeh/command/subcommands/tests/test_svg.py
new file mode 100644
--- /dev/null
+++ b/bokeh/command/subcommands/tests/test_svg.py
@@ -0,0 +1,107 @@
+from __future__ import absolute_import
+
+import argparse
+import pytest
+import os
+import sys
+
+is_python2 = sys.version_info[0] == 2
+
+import bokeh.command.subcommands.svg as scsvg
+from bokeh.command.bootstrap import main
+from bokeh.util.testing import TmpDir, WorkingDir, with_directory_contents
+
+from . import basic_svg_scatter_script
+
+def test_create():
+    import argparse
+    from bokeh.command.subcommand import Subcommand
+
+    obj = scsvg.SVG(parser=argparse.ArgumentParser())
+    assert isinstance(obj, Subcommand)
+
+def test_name():
+    assert scsvg.SVG.name == "svg"
+
+def test_help():
+    assert scsvg.SVG.help == "Create standalone SVG files for one or more applications"
+
+def test_args():
+    assert scsvg.SVG.args == (
+
+        ('files', dict(
+            metavar='DIRECTORY-OR-SCRIPT',
+            nargs='+',
+            help="The app directories or scripts to generate SVG for",
+            default=None,
+        )),
+
+        (('-o', '--output'), dict(
+            metavar='FILENAME',
+            action='append',
+            type=str,
+            help="Name of the output file or - for standard output."
+        )),
+
+        ('--args', dict(
+            metavar='COMMAND-LINE-ARGS',
+            nargs=argparse.REMAINDER,
+            help="Any command line arguments remaining are passed on to the application handler",
+        )),
+
+    )
+
+def test_no_script(capsys):
+    with (TmpDir(prefix="bokeh-svg-no-script")) as dirname:
+        with WorkingDir(dirname):
+            with pytest.raises(SystemExit):
+                main(["bokeh", "svg"])
+        out, err = capsys.readouterr()
+        if is_python2:
+            too_few = "too few arguments"
+        else:
+            too_few = "the following arguments are required: DIRECTORY-OR-SCRIPT"
+        assert err == """usage: bokeh svg [-h] [-o FILENAME] [--args ...]
+                 DIRECTORY-OR-SCRIPT [DIRECTORY-OR-SCRIPT ...]
+bokeh svg: error: %s
+""" % (too_few)
+        assert out == ""
+
+def test_basic_script(capsys):
+    def run(dirname):
+        with WorkingDir(dirname):
+            main(["bokeh", "svg", "scatter.py"])
+        out, err = capsys.readouterr()
+        assert err == ""
+        assert out == ""
+
+        assert set(["scatter.svg", "scatter_1.svg", "scatter.py"]) == set(os.listdir(dirname))
+
+    with_directory_contents({ 'scatter.py' : basic_svg_scatter_script },
+                            run)
+
+def test_basic_script_with_output_after(capsys):
+    def run(dirname):
+        with WorkingDir(dirname):
+            main(["bokeh", "svg", "scatter.py", "--output", "foo.svg"])
+        out, err = capsys.readouterr()
+        assert err == ""
+        assert out == ""
+
+        assert set(["foo.svg", "foo_1.svg", "scatter.py"]) == set(os.listdir(dirname))
+
+    with_directory_contents({ 'scatter.py' : basic_svg_scatter_script },
+                            run)
+
+def test_basic_script_with_output_before(capsys):
+    def run(dirname):
+        with WorkingDir(dirname):
+            main(["bokeh", "svg", "--output", "foo.svg", "scatter.py"])
+        out, err = capsys.readouterr()
+        assert err == ""
+        assert out == ""
+
+        assert set(["foo.svg", "foo_1.svg", "scatter.py"]) == set(os.listdir(dirname))
+
+    with_directory_contents({ 'scatter.py' : basic_svg_scatter_script },
+                            run)
diff --git a/bokeh/command/tests/test_bootstrap.py b/bokeh/command/tests/test_bootstrap.py
--- a/bokeh/command/tests/test_bootstrap.py
+++ b/bokeh/command/tests/test_bootstrap.py
@@ -11,7 +11,7 @@ def test_no_subcommand(capsys):
     with pytest.raises(SystemExit):
         main(["bokeh"])
     out, err = capsys.readouterr()
-    assert err == "ERROR: Must specify subcommand, one of: html, info, json, sampledata, secret, serve or static\n"
+    assert err == "ERROR: Must specify subcommand, one of: html, info, json, png, sampledata, secret, serve, static or svg\n"
     assert out == ""
 
 def _assert_version_output(capsys):
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
@@ -8,8 +8,11 @@
 
 from __future__ import absolute_import
 from mock import patch, Mock, PropertyMock
+import os
+
 from PIL import Image
 import pytest
+import selenium.webdriver as webdriver
 import unittest
 
 import bokeh.io as io
@@ -308,7 +311,24 @@ def test__get_screenshot_as_png():
                   outline_line_color=None, background_fill_color=None,
                   border_fill_color=None)
 
-    png = io._get_screenshot_as_png(layout)
+    png = io._get_screenshot_as_png(layout, None)
+    assert png.size == (20, 20)
+    # a 20x20px image of transparent pixels
+    assert png.tobytes() == ("\x00"*1600).encode()
+
+def test__get_screenshot_as_png_with_driver():
+    layout = Plot(x_range=Range1d(), y_range=Range1d(),
+                  plot_height=20, plot_width=20, toolbar_location=None,
+                  outline_line_color=None, background_fill_color=None,
+                  border_fill_color=None)
+
+    driver = webdriver.PhantomJS(service_log_path=os.path.devnull)
+
+    png = io._get_screenshot_as_png(layout, driver)
+
+    # Have to manually clean up the driver session
+    driver.quit()
+
     assert png.size == (20, 20)
     # a 20x20px image of transparent pixels
     assert png.tobytes() == ("\x00"*1600).encode()
@@ -317,7 +337,7 @@ def test__get_svgs_no_svg_present():
     layout = Plot(x_range=Range1d(), y_range=Range1d(),
               plot_height=20, plot_width=20, toolbar_location=None)
 
-    svgs = io._get_svgs(layout)
+    svgs = io._get_svgs(layout, None)
     assert svgs == []
 
 def test__get_svgs_with_svg_present():
@@ -326,7 +346,24 @@ def test__get_svgs_with_svg_present():
                   outline_line_color=None, border_fill_color=None,
                   background_fill_color=None, output_backend="svg")
 
-    svgs = io._get_svgs(layout)
+    svgs = io._get_svgs(layout, None)
+    assert svgs[0] == ('<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" '
+                       'width="20" height="20" style="width: 20px; height: 20px;"><defs/><g><g/><g transform="scale(1,1) '
+                       'translate(0.5,0.5)"><rect fill="#FFFFFF" stroke="none" x="0" y="0" width="20" height="20"/><g/><g/><g/><g/></g></g></svg>')
+
+def test__get_svgs_with_svg_present_with_driver():
+    layout = Plot(x_range=Range1d(), y_range=Range1d(),
+                  plot_height=20, plot_width=20, toolbar_location=None,
+                  outline_line_color=None, border_fill_color=None,
+                  background_fill_color=None, output_backend="svg")
+
+    driver = webdriver.PhantomJS(service_log_path=os.path.devnull)
+
+    svgs = io._get_svgs(layout, driver)
+
+    # Have to manually clean up the driver session
+    driver.quit()
+
     assert svgs[0] == ('<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" '
                        'width="20" height="20" style="width: 20px; height: 20px;"><defs/><g><g/><g transform="scale(1,1) '
                        'translate(0.5,0.5)"><rect fill="#FFFFFF" stroke="none" x="0" y="0" width="20" height="20"/><g/><g/><g/><g/></g></g></svg>')