Repository: bokeh/bokeh

Issue:
export_png does not clean up temporary files before exit 
**ALL software version info (bokeh, python, notebook, OS, browser, any other relevant packages)**

'vanilla' Python installation.
No Anaconda, no virtualenv.
Packages installed with pip (9.0.1)

Python (3.5.0)

bokeh (0.12.13)

DateTime (4.2)
Jinja2 (2.10)
Pillow (5.0.0)
PyYAML (3.12)
requests (2.18.4)
selenium (3.8.1)
six (1.11.0)
tornado (4.5.3)

PhantomJS (2.1.1) - only version that will work on my platform.

PuppyLinux 5.6.1 (32-bit, Ubuntu "Precise", kernel 3.2.44)

**Description of expected behaviour and the observed behaviour**

_Expected:_

export_png creates a temporary .html file in the following code fragment, and I would expect either to be able to call export_png with a parameter that allows one to save or delete that temporary file after it is used, or deletes the temporary file by default. 

(I don't know enough bokeh to know whether the temporary file might be re-used after export_png has been called and completed.)

```
def save_layout_html(obj, resources=INLINE, **kwargs):
    '''

    '''
    resize = False
    if kwargs.get('height') is not None or kwargs.get('width') is not None:
        # Defer this import, it is expensive
        from ..models.plots import Plot
        if not isinstance(obj, Plot):
            warn("Export method called with height or width kwargs on a non-Plot layout. The size values will be ignored.")
        else:
            resize = True
            old_height = obj.plot_height
            old_width = obj.plot_width
            obj.plot_height = kwargs.get('height', old_height)
            obj.plot_width = kwargs.get('width', old_width)

    html_path = NamedTemporaryFile(suffix=".html").name
    save(obj, filename=html_path, resources=resources, title="")

    if resize:
        obj.plot_height = old_height
        obj.plot_width = old_width

    return html_path
```
This fn is called from
```
 def get_screenshot_as_png(obj, driver=None, **kwargs):
  '''

    '''
    webdriver = import_required('selenium.webdriver',
                                'To use bokeh.io.export_png you need selenium ' +
                                '("conda install -c bokeh selenium" or "pip install selenium")')

    Image = import_required('PIL.Image',
                            'To use bokeh.io.export_png you need pillow ' +
                            '("conda install pillow" or "pip install pillow")')
    # assert that phantomjs is in path for webdriver
    detect_phantomjs()

    html_path = save_layout_html(obj, **kwargs)

    web_driver = driver if driver is not None else webdriver.PhantomJS(service_log_path=devnull)

    web_driver.get("file:///" + html_path)
    web_driver.maximize_window()

    ## resize for PhantomJS compat
    web_driver.execute_script("document.body.style.width = '100%';")

    wait_until_render_complete(web_driver)

    png = web_driver.get_screenshot_as_png()

    b_rect = web_driver.execute_script(_BOUNDING_RECT_SCRIPT)

    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)

    return cropped_image
```
_Observed:_

Each time export_png is called, it creates a new uniquely-named file in my /tmp directory. 
As my application (a Twitter 'bot' that replies to enquiries and includes 2x bar charts as png Tweet attachments) runs 24/7, the number or tempfiles grows incrementally. Each file (in this use case) is ~667Kb. Unless manually deleted, there appears no limit to the number of files that might be found.

I only noticed this shortcoming when looking for reasons why I intermittently get an (OSError [Errno 9] Bad file descriptor) when export_png is called. 

It's easy enough to add a line of code in either my app or to customise my copy of export_png to remove the temporary file afterwards, but it feels like a shortcoming in the module as well and thought it worth flagging up.

(Though bokeh in truth is overkill for my needs in this use instance and I would prefer something much lighter)


**Complete, minimal, self-contained example code that reproduces the issue:**

```
from bokeh.plotting import figure
from bokeh.io import export_png

p = figure(plot_width=400, plot_height=400)
p.vbar(x=[1, 2, 3], width=0.5, bottom=0,
       top=[1.2, 2.5, 3.7], color="firebrick")
       
export_png(p, filename="plot.png")
```
**Stack traceback and/or browser JavaScript console output**

None

**Screenshots or screencasts of the bug in action**

![capture30828](https://user-images.githubusercontent.com/31665276/36342802-a0d8e69c-13fb-11e8-8f06-b0e8ea3444d7.png)



Discussion:

Resolving patch:
diff --git a/bokeh/command/subcommands/png.py b/bokeh/command/subcommands/png.py
--- a/bokeh/command/subcommands/png.py
+++ b/bokeh/command/subcommands/png.py
@@ -45,7 +45,7 @@
 import sys
 import warnings
 
-from ...io.export import get_screenshot_as_png
+from ...io.export import get_screenshot_as_png, terminate_web_driver
 from ...models.plots import Plot
 from .file_output import FileOutputSubcommand
 
@@ -89,7 +89,7 @@ def invoke(self, args):
         import selenium.webdriver as webdriver
         self.driver = webdriver.PhantomJS(service_log_path=os.path.devnull)
         super(PNG, self).invoke(args)
-        self.driver.quit()
+        terminate_web_driver(self.driver)
 
     def write_file(self, args, filename, doc):
         '''
diff --git a/bokeh/command/subcommands/svg.py b/bokeh/command/subcommands/svg.py
--- a/bokeh/command/subcommands/svg.py
+++ b/bokeh/command/subcommands/svg.py
@@ -46,7 +46,7 @@
 import os
 import warnings
 
-from ...io.export import get_svgs
+from ...io.export import get_svgs, terminate_web_driver
 from ...models.plots import Plot
 from ...util.string import decode_utf8
 from .file_output import FileOutputSubcommand
@@ -91,7 +91,7 @@ def invoke(self, args):
         import selenium.webdriver as webdriver
         self.driver = webdriver.PhantomJS(service_log_path=os.path.devnull)
         super(SVG, self).invoke(args)
-        self.driver.quit()
+        terminate_web_driver(self.driver)
 
     def write_file(self, args, filename, doc):
         '''
diff --git a/bokeh/io/export.py b/bokeh/io/export.py
--- a/bokeh/io/export.py
+++ b/bokeh/io/export.py
@@ -25,6 +25,7 @@
 
 # Standard library imports
 import io
+import signal
 from os.path import abspath, devnull
 from tempfile import NamedTemporaryFile
 from warnings import warn
@@ -160,24 +161,26 @@ def get_screenshot_as_png(obj, driver=None, **kwargs):
                             'To use bokeh.io.export_png you need pillow ' +
                             '("conda install pillow" or "pip install pillow")')
 
-    html_path = save_layout_html(obj, **kwargs)
+    with _tmp_file() as tmp:
+        html_path = tmp.name
 
-    web_driver = driver if driver is not None else _create_default_webdriver()
+        save_layout_html(obj, html_path, **kwargs)
 
-    web_driver.get("file:///" + html_path)
-    web_driver.maximize_window()
+        web_driver = driver if driver is not None else _create_default_webdriver()
+        web_driver.get("file:///" + html_path)
+        web_driver.maximize_window()
 
-    ## resize for PhantomJS compat
-    web_driver.execute_script("document.body.style.width = '100%';")
+        ## resize for PhantomJS compat
+        web_driver.execute_script("document.body.style.width = '100%';")
 
-    wait_until_render_complete(web_driver)
+        wait_until_render_complete(web_driver)
 
-    png = web_driver.get_screenshot_as_png()
+        png = web_driver.get_screenshot_as_png()
 
-    b_rect = web_driver.execute_script(_BOUNDING_RECT_SCRIPT)
+        b_rect = web_driver.execute_script(_BOUNDING_RECT_SCRIPT)
 
-    if driver is None: # only quit webdriver if not passed in as arg
-        web_driver.quit()
+        if driver is None: # only quit webdriver if not passed in as arg
+            terminate_web_driver(web_driver)
 
     image = Image.open(io.BytesIO(png))
     cropped_image = _crop_image(image, **b_rect)
@@ -189,23 +192,25 @@ def get_svgs(obj, driver=None, **kwargs):
     '''
 
     '''
-    html_path = save_layout_html(obj, **kwargs)
+    with _tmp_file() as tmp:
+        html_path = tmp.name
 
-    web_driver = driver if driver is not None else _create_default_webdriver()
-    web_driver.get("file:///" + html_path)
+        save_layout_html(obj, html_path, **kwargs)
 
-    wait_until_render_complete(web_driver)
+        web_driver = driver if driver is not None else _create_default_webdriver()
+        web_driver.get("file:///" + html_path)
 
-    svgs = web_driver.execute_script(_SVG_SCRIPT)
+        wait_until_render_complete(web_driver)
 
-    if driver is None: # only quit webdriver if not passed in as arg
-        web_driver.quit()
+        svgs = web_driver.execute_script(_SVG_SCRIPT)
 
+        if driver is None: # only quit webdriver if not passed in as arg
+            terminate_web_driver(web_driver)
 
     return svgs
 
 @dev((1,0,0))
-def save_layout_html(obj, resources=INLINE, **kwargs):
+def save_layout_html(obj, html_path, resources=INLINE, **kwargs):
     '''
 
     '''
@@ -222,15 +227,12 @@ def save_layout_html(obj, resources=INLINE, **kwargs):
             obj.plot_height = kwargs.get('height', old_height)
             obj.plot_width = kwargs.get('width', old_width)
 
-    html_path = NamedTemporaryFile(suffix=".html").name
     save(obj, filename=html_path, resources=resources, title="")
 
     if resize:
         obj.plot_height = old_height
         obj.plot_width = old_width
 
-    return html_path
-
 @dev((1,0,0))
 def wait_until_render_complete(driver):
     '''
@@ -256,6 +258,11 @@ def is_bokeh_render_complete(driver):
         if len(severe_errors) > 0:
             log.warn("There were severe browser errors that may have affected your export: {}".format(severe_errors))
 
+@dev((1,0,0))
+def terminate_web_driver(driver):
+    driver.service.process.send_signal(signal.SIGTERM)
+    driver.quit()
+
 #-----------------------------------------------------------------------------
 # Private API
 #-----------------------------------------------------------------------------
@@ -303,6 +310,9 @@ def _create_default_webdriver():
     phantomjs_path = detect_phantomjs()
     return webdriver.PhantomJS(executable_path=phantomjs_path, service_log_path=devnull)
 
+def _tmp_file():
+    return NamedTemporaryFile(suffix=".html")
+
 #-----------------------------------------------------------------------------
 # Code
 #-----------------------------------------------------------------------------


Test patch:
diff --git a/bokeh/io/tests/test_export.py b/bokeh/io/tests/test_export.py
--- a/bokeh/io/tests/test_export.py
+++ b/bokeh/io/tests/test_export.py
@@ -22,6 +22,7 @@
 
 # Standard library imports
 from mock import patch
+from tempfile import NamedTemporaryFile
 import os
 
 # External imports
@@ -31,6 +32,7 @@
 # Bokeh imports
 from bokeh.models.plots import Plot
 from bokeh.models.ranges import Range1d
+from bokeh.io.export import terminate_web_driver
 
 # Module under test
 import bokeh.io.export as bie
@@ -52,6 +54,7 @@
         ( 'get_svgs',                   (1, 0, 0) ),
         ( 'save_layout_html',           (1, 0, 0) ),
         ( 'wait_until_render_complete', (1, 0, 0) ),
+        ( 'terminate_web_driver',       (1, 0, 0) ),
 
     ),
 
@@ -97,7 +100,7 @@ def test_get_screenshot_as_png_with_driver():
     png = bie.get_screenshot_as_png(layout, driver=driver)
 
     # Have to manually clean up the driver session
-    driver.quit()
+    terminate_web_driver(driver)
 
     assert png.size == (20, 20)
     # a 20x20px image of transparent pixels
@@ -121,7 +124,7 @@ def test_get_screenshot_as_png_large_plot():
     assert driver.get_window_size() == {'width': 1366, 'height': 768}
 
     # Have to manually clean up the driver session
-    driver.quit()
+    terminate_web_driver(driver)
 
 @pytest.mark.unit
 @pytest.mark.selenium
@@ -159,7 +162,7 @@ def test_get_svgs_with_svg_present_with_driver():
     svgs = bie.get_svgs(layout)
 
     # Have to manually clean up the driver session
-    driver.quit()
+    terminate_web_driver(driver)
 
     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 transform="scale(1,1) '
@@ -172,7 +175,9 @@ def test_save_layout_html_resets_plot_dims():
     layout = Plot(x_range=Range1d(), y_range=Range1d(),
                   plot_height=initial_height, plot_width=initial_width)
 
-    bie.save_layout_html(layout, height=100, width=100)
+    with NamedTemporaryFile() as tmp:
+        bie.save_layout_html(layout, tmp.name, height=100, width=100)
+
     assert layout.plot_height == initial_height
     assert layout.plot_width == initial_width