Repository: plotly/plotly.py

Issue:
offline `plot` command - fixes https://github.com/plotly/plotly.py/pull/378
Description: Write Plotly graphs to an HTML file from Python.

Fixes - fixes #378 



Discussion:

Resolving patch:
diff --git a/plotly/offline/__init__.py b/plotly/offline/__init__.py
--- a/plotly/offline/__init__.py
+++ b/plotly/offline/__init__.py
@@ -6,5 +6,6 @@
 from . offline import (
     download_plotlyjs,
     init_notebook_mode,
-    iplot
+    iplot,
+    plot
 )
diff --git a/plotly/offline/offline.py b/plotly/offline/offline.py
--- a/plotly/offline/offline.py
+++ b/plotly/offline/offline.py
@@ -10,6 +10,7 @@
 import uuid
 import warnings
 from pkg_resources import resource_string
+import webbrowser
 
 import plotly
 from plotly import tools, utils
@@ -57,6 +58,72 @@ def init_notebook_mode():
                  '</script>'))
 
 
+def _plot_html(figure_or_data, show_link, link_text,
+               validate, default_width, default_height):
+
+    figure = tools.return_figure_from_figure_or_data(figure_or_data, validate)
+
+    width = figure.get('layout', {}).get('width', default_width)
+    height = figure.get('layout', {}).get('height', default_height)
+
+    try:
+        float(width)
+    except (ValueError, TypeError):
+        pass
+    else:
+        width = str(width) + 'px'
+
+    try:
+        float(width)
+    except (ValueError, TypeError):
+        pass
+    else:
+        width = str(width) + 'px'
+
+    plotdivid = uuid.uuid4()
+    jdata = json.dumps(figure.get('data', []), cls=utils.PlotlyJSONEncoder)
+    jlayout = json.dumps(figure.get('layout', {}), cls=utils.PlotlyJSONEncoder)
+
+    config = {}
+    config['showLink'] = show_link
+    config['linkText'] = link_text
+    jconfig = json.dumps(config)
+
+    # TODO: The get_config 'source of truth' should
+    # really be somewhere other than plotly.plotly
+    plotly_platform_url = plotly.plotly.get_config().get('plotly_domain',
+                                                         'https://plot.ly')
+    if (plotly_platform_url != 'https://plot.ly' and
+            link_text == 'Export to plot.ly'):
+
+        link_domain = plotly_platform_url\
+            .replace('https://', '')\
+            .replace('http://', '')
+        link_text = link_text.replace('plot.ly', link_domain)
+
+    script = 'Plotly.newPlot("{id}", {data}, {layout}, {config})'.format(
+        id=plotdivid,
+        data=jdata,
+        layout=jlayout,
+        config=jconfig)
+
+    plotly_html_div = (
+        ''
+        '<div id="{id}" style="height: {height}; width: {width};" '
+        'class="plotly-graph-div">'
+        '</div>'
+        '<script type="text/javascript">'
+        'window.PLOTLYENV=window.PLOTLYENV || {{}};'
+        'window.PLOTLYENV.BASE_URL="' + plotly_platform_url + '";'
+        '{script}'
+        '</script>'
+        '').format(
+        id=plotdivid, script=script,
+        height=height, width=width)
+
+    return plotly_html_div, plotdivid, width, height
+
+
 def iplot(figure_or_data, show_link=True, link_text='Export to plot.ly',
           validate=True):
     """
@@ -103,76 +170,131 @@ def iplot(figure_or_data, show_link=True, link_text='Export to plot.ly',
         raise ImportError('`iplot` can only run inside an IPython Notebook.')
 
     from IPython.display import HTML, display
-    figure = tools.return_figure_from_figure_or_data(figure_or_data, validate)
 
-    width = figure.get('layout', {}).get('width', '100%')
-    height = figure.get('layout', {}).get('height', 525)
-    try:
-        float(width)
-    except (ValueError, TypeError):
-        pass
-    else:
-        width = str(width) + 'px'
+    plot_html, plotdivid, width, height = _plot_html(
+        figure_or_data, show_link, link_text, validate,
+        '100%', 525)
 
-    try:
-        float(width)
-    except (ValueError, TypeError):
-        pass
-    else:
-        width = str(width) + 'px'
+    display(HTML(plot_html))
 
-    plotdivid = uuid.uuid4()
-    jdata = json.dumps(figure.get('data', []), cls=utils.PlotlyJSONEncoder)
-    jlayout = json.dumps(figure.get('layout', {}), cls=utils.PlotlyJSONEncoder)
 
-    config = {}
-    config['showLink'] = show_link
-    config['linkText'] = link_text
-    jconfig = json.dumps(config)
+def plot(figure_or_data,
+         show_link=True, link_text='Export to plot.ly',
+         validate=True, output_type='file',
+         include_plotlyjs=True,
+         filename='temp-plot.html',
+         auto_open=True):
+    """ Create a plotly graph locally as an HTML document or string.
 
-    # TODO: The get_config 'source of truth' should
-    # really be somewhere other than plotly.plotly
-    plotly_platform_url = plotly.plotly.get_config().get('plotly_domain',
-                                                         'https://plot.ly')
-    if (plotly_platform_url != 'https://plot.ly' and
-            link_text == 'Export to plot.ly'):
+    Example:
+    ```
+    from plotly.offline import plot
+    import plotly.graph_objs as go
 
-        link_domain = plotly_platform_url\
-            .replace('https://', '')\
-            .replace('http://', '')
-        link_text = link_text.replace('plot.ly', link_domain)
+    plot([
+        go.Scatter(x=[1, 2, 3], y=[3, 2 6])
+    ], filename='my-graph.html')
+    ```
+    More examples below.
 
-    display(HTML(
-        '<script type="text/javascript">'
-        'window.PLOTLYENV=window.PLOTLYENV || {};'
-        'window.PLOTLYENV.BASE_URL="' + plotly_platform_url + '";'
-        '</script>'
-    ))
-
-    script = '\n'.join([
-        'Plotly.plot("{id}", {data}, {layout}, {config}).then(function() {{',
-        '    $(".{id}.loading").remove();',
-        '}})'
-    ]).format(id=plotdivid,
-              data=jdata,
-              layout=jlayout,
-              config=jconfig)
-
-    display(HTML(''
-                 '<div class="{id} loading" style="color: rgb(50,50,50);">'
-                 'Drawing...</div>'
-                 '<div id="{id}" style="height: {height}; width: {width};" '
-                 'class="plotly-graph-div">'
-                 '</div>'
-                 '<script type="text/javascript">'
-                 '{script}'
-                 '</script>'
-                 ''.format(id=plotdivid, script=script,
-                           height=height, width=width)))
-
-
-def plot():
-    """ Configured to work with localhost Plotly graph viewer
+    figure_or_data -- a plotly.graph_objs.Figure or plotly.graph_objs.Data or
+                      dict or list that describes a Plotly graph.
+                      See https://plot.ly/python/ for examples of
+                      graph descriptions.
+
+    Keyword arguments:
+    show_link (default=True) -- display a link in the bottom-right corner of
+        of the chart that will export the chart to Plotly Cloud or
+        Plotly Enterprise
+    link_text (default='Export to plot.ly') -- the text of export link
+    validate (default=True) -- validate that all of the keys in the figure
+        are valid? omit if your version of plotly.js has become outdated
+        with your version of graph_reference.json or if you need to include
+        extra, unnecessary keys in your figure.
+    output_type ('file' | 'div' - default 'file') -- if 'file', then
+        the graph is saved as a standalone HTML file and `plot`
+        returns None.
+        If 'div', then `plot` returns a string that just contains the
+        HTML <div> that contains the graph and the script to generate the
+        graph.
+        Use 'file' if you want to save and view a single graph at a time
+        in a standalone HTML file.
+        Use 'div' if you are embedding these graphs in an HTML file with
+        other graphs or HTML markup, like a HTML report or an website.
+    include_plotlyjs (default=True) -- If True, include the plotly.js
+        source code in the output file or string.
+        Set as False if your HTML file already contains a copy of the plotly.js
+        library.
+    filename (default='temp-plot.html') -- The local filename to save the
+        outputted chart to. If the filename already exists, it will be
+        overwritten. This argument only applies if `output_type` is 'file'.
+    auto_open (default=True) -- If True, open the saved file in a
+        web browser after saving.
+        This argument only applies if `output_type` is 'file'.
     """
-    raise NotImplementedError
+    if output_type not in ['div', 'file']:
+        raise ValueError(
+            "`output_type` argument must be 'div' or 'file'. "
+            "You supplied `" + output_type + "``")
+    if not filename.endswith('.html') and output_type == 'file':
+        warnings.warn(
+            "Your filename `" + filename + "` didn't end with .html. "
+            "Adding .html to the end of your file.")
+        filename += '.html'
+
+    plot_html, plotdivid, width, height = _plot_html(
+        figure_or_data, show_link, link_text, validate,
+        '100%', '100%')
+
+    figure = tools.return_figure_from_figure_or_data(figure_or_data, validate)
+
+    resize_script = ''
+    if width == '100%' or height == '100%':
+        resize_script = (
+            ''
+            '<script type="text/javascript">'
+            'window.removeEventListener("resize");'
+            'window.addEventListener("resize", function(){{'
+            'Plotly.Plots.resize(document.getElementById("{id}"));}});'
+            '</script>'
+        ).format(id=plotdivid)
+
+    if output_type == 'file':
+        with open(filename, 'w') as f:
+            if include_plotlyjs:
+                plotly_js_script = ''.join([
+                    '<script type="text/javascript">',
+                    get_plotlyjs(),
+                    '</script>',
+                ])
+            else:
+                plotly_js_script = ''
+
+            f.write(''.join([
+                '<html>',
+                '<head><meta charset="utf-8" /></head>',
+                '<body>',
+                plotly_js_script,
+                plot_html,
+                resize_script,
+                '</body>',
+                '</html>']))
+
+        url = 'file://' + os.path.abspath(filename)
+        if auto_open:
+            webbrowser.open(url)
+
+        return url
 
+    elif output_type == 'div':
+        if include_plotlyjs:
+            return ''.join([
+                '<div>',
+                '<script type="text/javascript">',
+                get_plotlyjs(),
+                '</script>',
+                plot_html,
+                '</div>'
+            ])
+        else:
+            return plot_html
diff --git a/plotly/version.py b/plotly/version.py
--- a/plotly/version.py
+++ b/plotly/version.py
@@ -1 +1 @@
-__version__ = '1.9.3'
+__version__ = '1.9.4'


Test patch:
diff --git a/plotly/tests/test_core/test_offline/test_offline.py b/plotly/tests/test_core/test_offline/test_offline.py
new file mode 100644
--- /dev/null
+++ b/plotly/tests/test_core/test_offline/test_offline.py
@@ -0,0 +1,80 @@
+"""
+test__offline
+
+"""
+from __future__ import absolute_import
+
+from nose.tools import raises
+from unittest import TestCase
+import json
+
+import plotly
+
+fig = {
+    'data': [
+        plotly.graph_objs.Scatter(x=[1, 2, 3], y=[10, 20, 30])
+    ],
+    'layout': plotly.graph_objs.Layout(
+        title='offline plot'
+    )
+}
+
+PLOTLYJS = plotly.offline.offline.get_plotlyjs()
+
+class PlotlyOfflineTestCase(TestCase):
+    def setUp(self):
+        pass
+
+    def _read_html(self, file_url):
+        """ Read and return the HTML contents from a file_url
+        in the form e.g. file:///Users/chriddyp/Repos/plotly.py/plotly-temp.html
+        """
+        with open(file_url.replace('file://', '').replace(' ', '')) as f:
+            return f.read()
+
+    def test_default_plot_generates_expected_html(self):
+        data_json = json.dumps(fig['data'], cls=plotly.utils.PlotlyJSONEncoder)
+        layout_json = json.dumps(
+            fig['layout'],
+            cls=plotly.utils.PlotlyJSONEncoder)
+
+        html = self._read_html(plotly.offline.plot(fig))
+
+        # I don't really want to test the entire script output, so
+        # instead just make sure a few of the parts are in here?
+        self.assertTrue('Plotly.newPlot' in html) # plot command is in there
+        self.assertTrue(data_json in html)        # data is in there
+        self.assertTrue(layout_json in html)      # so is layout
+        self.assertTrue(PLOTLYJS in html)         # and the source code
+        # and it's an <html> doc
+        self.assertTrue(html.startswith('<html>') and html.endswith('</html>'))
+
+    def test_including_plotlyjs(self):
+        html = self._read_html(plotly.offline.plot(fig, include_plotlyjs=False))
+        self.assertTrue(PLOTLYJS not in html)
+
+    def test_div_output(self):
+        html = plotly.offline.plot(fig, output_type='div')
+
+        self.assertTrue('<html>' not in html and '</html>' not in html)
+        self.assertTrue(html.startswith('<div>') and html.endswith('</div>'))
+
+    def test_autoresizing(self):
+        resize_code_strings = [
+            'window.addEventListener("resize", ',
+            'Plotly.Plots.resize('
+        ]
+        # If width or height wasn't specified, then we add a window resizer
+        html = self._read_html(plotly.offline.plot(fig))
+        for resize_code_string in resize_code_strings:
+            self.assertTrue(resize_code_string in html)
+
+        # If width or height was specified, then we don't resize
+        html = plotly.offline.plot({
+            'data': fig['data'],
+            'layout': {
+                'width': 500, 'height': 500
+            }
+        })
+        for resize_code_string in resize_code_strings:
+            self.assertTrue(resize_code_string not in html)