diff --git a/.circleci/create_conda_optional_env.sh b/.circleci/create_conda_optional_env.sh index ad20809d5fb..8caae9bae72 100755 --- a/.circleci/create_conda_optional_env.sh +++ b/.circleci/create_conda_optional_env.sh @@ -16,7 +16,7 @@ if [ ! -d $HOME/miniconda/envs/circle_optional ]; then # Create environment # PYTHON_VERSION=3.6 $HOME/miniconda/bin/conda create -n circle_optional --yes python=$PYTHON_VERSION \ -requests six pytz retrying psutil pandas decorator pytest mock nose poppler xarray scikit-image +requests six pytz retrying psutil pandas decorator pytest mock nose poppler xarray scikit-image ipython # Install orca into environment $HOME/miniconda/bin/conda install --yes -n circle_optional -c plotly plotly-orca diff --git a/_plotly_future_/__init__.py b/_plotly_future_/__init__.py new file mode 100644 index 00000000000..f3cc263c507 --- /dev/null +++ b/_plotly_future_/__init__.py @@ -0,0 +1,8 @@ +_future_flags = set() + + +def _assert_plotly_not_imported(): + import sys + if 'plotly' in sys.modules: + raise ImportError("""\ +The _plotly_future_ module must be imported before the plotly module""") diff --git a/_plotly_future_/renderer_defaults.py b/_plotly_future_/renderer_defaults.py new file mode 100644 index 00000000000..1ee0e54301b --- /dev/null +++ b/_plotly_future_/renderer_defaults.py @@ -0,0 +1,5 @@ +from __future__ import absolute_import +from _plotly_future_ import _future_flags, _assert_plotly_not_imported + +_assert_plotly_not_imported() +_future_flags.add('renderer_defaults') diff --git a/_plotly_future_/template_defaults.py b/_plotly_future_/template_defaults.py new file mode 100644 index 00000000000..82c0f52ff2d --- /dev/null +++ b/_plotly_future_/template_defaults.py @@ -0,0 +1,5 @@ +from __future__ import absolute_import +from _plotly_future_ import _future_flags, _assert_plotly_not_imported + +_assert_plotly_not_imported() +_future_flags.add('template_defaults') diff --git a/_plotly_future_/v4.py b/_plotly_future_/v4.py new file mode 100644 index 00000000000..fdbad2b012c --- /dev/null +++ b/_plotly_future_/v4.py @@ -0,0 +1,2 @@ +from __future__ import absolute_import +from _plotly_future_ import renderer_defaults, template_defaults diff --git a/plotly/__init__.py b/plotly/__init__.py index 0789f76601b..b9c0170d694 100644 --- a/plotly/__init__.py +++ b/plotly/__init__.py @@ -31,7 +31,13 @@ from plotly import (plotly, dashboard_objs, graph_objs, grid_objs, tools, utils, session, offline, colors, io) from plotly.version import __version__ +from _plotly_future_ import _future_flags from ._version import get_versions __version__ = get_versions()['version'] del get_versions + +# Set default template here to make sure import process is complete +if 'template_defaults' in _future_flags: + # Set _default to skip validation + io.templates._default = 'plotly' diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index 799cd41fc48..b4b78881735 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -392,6 +392,22 @@ def __repr__(self): return repr_str + def _repr_mimebundle_(self, include, exclude, **kwargs): + """ + repr_mimebundle should accept include, exclude and **kwargs + """ + import plotly.io as pio + if pio.renderers.render_on_display: + data = pio.renderers._build_mime_bundle(self.to_dict()) + + if include: + data = {k: v for (k, v) in data.items() if k in include} + if exclude: + data = {k: v for (k, v) in data.items() if k not in exclude} + return data + else: + return None + def update(self, dict1=None, **kwargs): """ Update the properties of the figure with a dict and/or with diff --git a/plotly/io/__init__.py b/plotly/io/__init__.py index a7d9dacd5f4..c0478c70ac0 100644 --- a/plotly/io/__init__.py +++ b/plotly/io/__init__.py @@ -4,3 +4,9 @@ from ._json import to_json, from_json, read_json, write_json from ._templates import templates, to_templated + +from ._renderers import renderers, show + +from . import base_renderers + +from ._html import to_html, write_html diff --git a/plotly/io/_base_renderers.py b/plotly/io/_base_renderers.py new file mode 100644 index 00000000000..5f82d098f49 --- /dev/null +++ b/plotly/io/_base_renderers.py @@ -0,0 +1,571 @@ +import base64 +import json +import webbrowser +import inspect +import os + +import six +from plotly.io import to_json, to_image +from plotly import utils, optional_imports +from plotly.io._orca import ensure_server +from plotly.offline.offline import _get_jconfig, get_plotlyjs + +ipython_display = optional_imports.get_module('IPython.display') +IPython = optional_imports.get_module('IPython') + +try: + from http.server import BaseHTTPRequestHandler, HTTPServer +except ImportError: + # Python 2.7 + from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer + + +class BaseRenderer(object): + """ + Base class for all renderers + """ + def activate(self): + pass + + def __repr__(self): + try: + init_sig = inspect.signature(self.__init__) + init_args = list(init_sig.parameters.keys()) + except AttributeError: + # Python 2.7 + argspec = inspect.getargspec(self.__init__) + init_args = [a for a in argspec.args if a != 'self'] + + return "{cls}({attrs})\n{doc}".format( + cls=self.__class__.__name__, + attrs=", ".join("{}={!r}".format(k, self.__dict__[k]) + for k in init_args), + doc=self.__doc__) + + def __hash__(self): + # Constructor args fully define uniqueness + return hash(repr(self)) + + +class MimetypeRenderer(BaseRenderer): + """ + Base class for all mime type renderers + """ + def to_mimebundle(self, fig_dict): + raise NotImplementedError() + + +class JsonRenderer(MimetypeRenderer): + """ + Renderer to display figures as JSON hierarchies. This renderer is + compatible with JupyterLab and VSCode. + + mime type: 'application/json' + """ + def to_mimebundle(self, fig_dict): + value = json.loads(to_json( + fig_dict, validate=False, remove_uids=False)) + return {'application/json': value} + + +# Plotly mimetype +class PlotlyRenderer(MimetypeRenderer): + """ + Renderer to display figures using the plotly mime type. This renderer is + compatible with JupyterLab (using the @jupyterlab/plotly-extension), + VSCode, and nteract. + + mime type: 'application/vnd.plotly.v1+json' + """ + def __init__(self, config=None): + self.config = dict(config) if config else {} + + def to_mimebundle(self, fig_dict): + config = _get_jconfig(self.config) + if config: + fig_dict['config'] = config + + json_compatible_fig_dict = json.loads( + to_json(fig_dict, validate=False, remove_uids=False)) + + return {'application/vnd.plotly.v1+json': json_compatible_fig_dict} + + +# Static Image +class ImageRenderer(MimetypeRenderer): + """ + Base class for all static image renderers + """ + def __init__(self, + mime_type, + b64_encode=False, + format=None, + width=None, + height=None, + scale=None): + + self.mime_type = mime_type + self.b64_encode = b64_encode + self.format = format + self.width = width + self.height = height + self.scale = scale + + def activate(self): + # Start up orca server to reduce the delay on first render + ensure_server() + + def to_mimebundle(self, fig_dict): + image_bytes = to_image( + fig_dict, + format=self.format, + width=self.width, + height=self.height, + scale=self.scale) + + if self.b64_encode: + image_str = base64.b64encode(image_bytes).decode('utf8') + else: + image_str = image_bytes.decode('utf8') + + return {self.mime_type: image_str} + + +class PngRenderer(ImageRenderer): + """ + Renderer to display figures as static PNG images. This renderer requires + the orca command-line utility and is broadly compatible across IPython + environments (classic Jupyter Notebook, JupyterLab, QtConsole, VSCode, + PyCharm, etc) and nbconvert targets (HTML, PDF, etc.). + + mime type: 'image/png' + """ + def __init__(self, width=None, height=None, scale=None): + super(PngRenderer, self).__init__( + mime_type='image/png', + b64_encode=True, + format='png', + width=width, + height=height, + scale=scale) + + +class SvgRenderer(ImageRenderer): + """ + Renderer to display figures as static SVG images. This renderer requires + the orca command-line utility and is broadly compatible across IPython + environments (classic Jupyter Notebook, JupyterLab, QtConsole, VSCode, + PyCharm, etc) and nbconvert targets (HTML, PDF, etc.). + + mime type: 'image/svg+xml' + """ + def __init__(self, width=None, height=None, scale=None): + super(SvgRenderer, self).__init__( + mime_type='image/svg+xml', + b64_encode=False, + format='svg', + width=width, + height=height, + scale=scale) + + +class JpegRenderer(ImageRenderer): + """ + Renderer to display figures as static JPEG images. This renderer requires + the orca command-line utility and is broadly compatible across IPython + environments (classic Jupyter Notebook, JupyterLab, QtConsole, VSCode, + PyCharm, etc) and nbconvert targets (HTML, PDF, etc.). + + mime type: 'image/jpeg' + """ + def __init__(self, width=None, height=None, scale=None): + super(JpegRenderer, self).__init__( + mime_type='image/jpeg', + b64_encode=True, + format='jpg', + width=width, + height=height, + scale=scale) + + +class PdfRenderer(ImageRenderer): + """ + Renderer to display figures as static PDF images. This renderer requires + the orca command-line utility and is compatible with JupyterLab and the + LaTeX-based nbconvert export to PDF. + + mime type: 'application/pdf' + """ + def __init__(self, width=None, height=None, scale=None): + super(PdfRenderer, self).__init__( + mime_type='application/pdf', + b64_encode=True, + format='pdf', + width=width, + height=height, + scale=scale) + + +# HTML +# Build script to set global PlotlyConfig object. This must execute before +# plotly.js is loaded. +_window_plotly_config = """\ +window.PlotlyConfig = {MathJaxConfig: 'local'};""" + +_mathjax_config = """\ +if (window.MathJax) {MathJax.Hub.Config({SVG: {font: "STIX-Web"}});}""" + + +class HtmlRenderer(MimetypeRenderer): + """ + Base class for all HTML mime type renderers + + mime type: 'text/html' + """ + def __init__(self, + connected=False, + full_html=False, + requirejs=True, + global_init=False, + config=None, + auto_play=False, + post_script=None): + + self.config = dict(config) if config else {} + self.auto_play = auto_play + self.connected = connected + self.global_init = global_init + self.requirejs = requirejs + self.full_html = full_html + self.post_script = post_script + + def activate(self): + if self.global_init: + if not ipython_display: + raise ValueError( + 'The {cls} class requires ipython but it is not installed' + .format(cls=self.__class__.__name__)) + + if not self.requirejs: + raise ValueError( + 'global_init is only supported with requirejs=True') + + if self.connected: + # Connected so we configure requirejs with the plotly CDN + script = """\ + + """.format(win_config=_window_plotly_config, + mathjax_config=_mathjax_config) + + else: + # If not connected then we embed a copy of the plotly.js + # library in the notebook + script = """\ + + """.format(script=get_plotlyjs(), + win_config=_window_plotly_config, + mathjax_config=_mathjax_config) + + ipython_display.display_html(script, raw=True) + + def to_mimebundle(self, fig_dict): + + from plotly.io import to_html + + if self.requirejs: + include_plotlyjs = 'require' + include_mathjax = False + elif self.connected: + include_plotlyjs = 'cdn' + include_mathjax = 'cdn' + else: + include_plotlyjs = True + include_mathjax = 'cdn' + + html = to_html( + fig_dict, + config=self.config, + auto_play=self.auto_play, + include_plotlyjs=include_plotlyjs, + include_mathjax=include_mathjax, + post_script=self.post_script, + full_html=self.full_html) + + return {'text/html': html} + + +class NotebookRenderer(HtmlRenderer): + """ + Renderer to display interactive figures in the classic Jupyter Notebook. + This renderer is also useful for notebooks that will be converted to + HTML using nbconvert/nbviewer as it will produce standalone HTML files + that include interactive figures. + + This renderer automatically performs global notebook initialization when + activated. + + mime type: 'text/html' + """ + def __init__(self, + connected=False, + config=None, + auto_play=False, + post_script=None): + super(NotebookRenderer, self).__init__( + connected=connected, + full_html=False, + requirejs=True, + global_init=True, + config=config, + auto_play=auto_play, + post_script=post_script) + + +class KaggleRenderer(HtmlRenderer): + """ + Renderer to display interactive figures in Kaggle Notebooks. + + Same as NotebookRenderer but with connected=True so that the plotly.js + bundle is loaded from a CDN rather than being embedded in the notebook. + + This renderer is enabled by default when running in a Kaggle notebook. + + mime type: 'text/html' + """ + def __init__(self, config=None, auto_play=False, post_script=None): + super(KaggleRenderer, self).__init__( + connected=True, + full_html=False, + requirejs=True, + global_init=True, + config=config, + auto_play=auto_play, + post_script=post_script) + + +class ColabRenderer(HtmlRenderer): + """ + Renderer to display interactive figures in Google Colab Notebooks. + + This renderer is enabled by default when running in a Colab notebook. + + mime type: 'text/html' + """ + def __init__(self, config=None, auto_play=False, post_script=None): + super(ColabRenderer, self).__init__( + connected=True, + full_html=True, + requirejs=False, + global_init=False, + config=config, + auto_play=auto_play, + post_script=post_script) + + +class IFrameRenderer(MimetypeRenderer): + """ + Renderer to display interactive figures using an IFrame. HTML + representations of Figures are saved to an `iframe_figures/` directory and + iframe HTML elements that reference these files are inserted into the + notebook. + + With this approach, neither plotly.js nor the figure data are embedded in + the notebook, so this is a good choice for notebooks that contain so many + large figures that basic operations (like saving and opening) become + very slow. + + Notebooks using this renderer will display properly when exported to HTML + as long as the `iframe_figures/` directory is placed in the same directory + as the exported html file. + + Note that the HTML files in `iframe_figures/` are numbered according to + the IPython cell execution count and so they will start being overwritten + each time the kernel is restarted. This directory may be deleted whenever + the kernel is restarted and it will be automatically recreated. + + mime type: 'text/html' + """ + def __init__(self, + config=None, + auto_play=False, + post_script=None): + + self.config = config + self.auto_play = auto_play + self.post_script = post_script + + def to_mimebundle(self, fig_dict): + from plotly.io import write_html + + # Make iframe size slightly larger than figure size to avoid + # having iframe have its own scroll bar. + iframe_buffer = 20 + layout = fig_dict.get('layout', {}) + if 'width' in layout: + iframe_width = layout['width'] + iframe_buffer + else: + layout['width'] = 700 + iframe_width = layout['width'] + iframe_buffer + + if 'height' in layout: + iframe_height = layout['height'] + iframe_buffer + else: + layout['height'] = 450 + iframe_height = layout['height'] + iframe_buffer + + # Build filename using ipython cell number + ip = IPython.get_ipython() + cell_number = list(ip.history_manager.get_tail(1))[0][1] + 1 + dirname = 'iframe_figures' + filename = '{dirname}/figure_{cell_number}.html'.format( + dirname=dirname, cell_number=cell_number) + + # Make directory for + os.makedirs(dirname, exist_ok=True) + + write_html(fig_dict, + filename, + config=self.config, + auto_play=self.auto_play, + include_plotlyjs='directory', + include_mathjax='cdn', + auto_open=False, + post_script=self.post_script, + validate=False) + + # Build IFrame + iframe_html = """\ + +""".format(width=iframe_width, height=iframe_height, src=filename) + + return {'text/html': iframe_html} + + +class ExternalRenderer(BaseRenderer): + """ + Base class for external renderers. ExternalRenderer subclasses + do not display figures inline in a notebook environment, but render + figures by some external means (e.g. a separate browser tab). + + Unlike MimetypeRenderer subclasses, ExternalRenderer subclasses are not + invoked when a figure is asked to display itself in the notebook. + Instead, they are invoked when the plotly.io.show function is called + on a figure. + """ + + def render(self, fig): + raise NotImplementedError() + + +def open_html_in_browser(html, using=None, new=0, autoraise=True): + """ + Display html in a web browser without creating a temp file. + + Instantiates a trivial http server and uses the webbrowser module to + open a URL to retrieve html from that server. + + Parameters + ---------- + html: str + HTML string to display + using, new, autoraise: + See docstrings in webbrowser.get and webbrowser.open + """ + if isinstance(html, six.string_types): + html = html.encode('utf8') + + class OneShotRequestHandler(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + + bufferSize = 1024*1024 + for i in range(0, len(html), bufferSize): + self.wfile.write(html[i:i+bufferSize]) + + def log_message(self, format, *args): + # Silence stderr logging + pass + + server = HTTPServer(('127.0.0.1', 0), OneShotRequestHandler) + webbrowser.get(using).open( + 'http://127.0.0.1:%s' % server.server_port, + new=new, + autoraise=autoraise) + + server.handle_request() + + +class BrowserRenderer(ExternalRenderer): + """ + Renderer to display interactive figures in an external web browser. + This renderer will open a new browser window or tab when the + plotly.io.show function is called on a figure. + + This renderer has no ipython/jupyter dependencies and is a good choice + for use in environments that do not support the inline display of + interactive figures. + + mime type: 'text/html' + """ + def __init__(self, + config=None, + auto_play=False, + using=None, + new=0, + autoraise=True, + post_script=None): + + self.config = config + self.auto_play = auto_play + self.using = using + self.new = new + self.autoraise = autoraise + self.post_script = post_script + + def render(self, fig_dict): + renderer = HtmlRenderer( + connected=False, + full_html=True, + requirejs=False, + global_init=False, + config=self.config, + auto_play=self.auto_play, + post_script=self.post_script) + + bundle = renderer.to_mimebundle(fig_dict) + html = bundle['text/html'] + open_html_in_browser(html, self.using, self.new, self.autoraise) diff --git a/plotly/io/_html.py b/plotly/io/_html.py new file mode 100644 index 00000000000..f70d269f7f7 --- /dev/null +++ b/plotly/io/_html.py @@ -0,0 +1,438 @@ +import uuid +import json +import os +import webbrowser + +import six + +from plotly.io._utils import validate_coerce_fig_to_dict +from plotly.offline.offline import _get_jconfig, get_plotlyjs +from plotly import utils + + +# Build script to set global PlotlyConfig object. This must execute before +# plotly.js is loaded. +_window_plotly_config = """\ +""" + +_mathjax_config = """\ +""" + + +def to_html(fig, + config=None, + auto_play=True, + include_plotlyjs=True, + include_mathjax=False, + post_script=None, + full_html=True, + validate=True): + """ + Convert a figure to an HTML string representation. + + Parameters + ---------- + fig: + Figure object or dict representing a figure + config: dict or None (default None) + Plotly.js figure config options + auto_play: bool (default=True) + Whether to automatically start the animation sequence on page load + if the figure contains frames. Has no effect if the figure does not + contain frames. + include_plotlyjs: bool or string (default True) + Specifies how the plotly.js library is included/loaded in the output + div string. + + If True, a script tag containing the plotly.js source code (~3MB) + is included in the output. HTML files generated with this option are + fully self-contained and can be used offline. + + If 'cdn', a script tag that references the plotly.js CDN is included + in the output. HTML files generated with this option are about 3MB + smaller than those generated with include_plotlyjs=True, but they + require an active internet connection in order to load the plotly.js + library. + + If 'directory', a script tag is included that references an external + plotly.min.js bundle that is assumed to reside in the same + directory as the HTML file. + + If 'require', Plotly.js is loaded using require.js. This option + assumes that require.js is globally available and that it has been + globally configured to know how to find Plotly.js as 'plotly'. + This option is not advised when full_html=True as it will result + in a non-functional html file. + + If a string that ends in '.js', a script tag is included that + references the specified path. This approach can be used to point + the resulting HTML file to an alternative CDN or local bundle. + + If False, no script tag referencing plotly.js is included. This is + useful when the resulting div string will be placed inside an HTML + document that already loads plotly.js. This option is not advised + when full_html=True as it will result in a non-functional html file. + include_mathjax: bool or string (default False) + Specifies how the MathJax.js library is included in the output html + div string. MathJax is required in order to display labels + with LaTeX typesetting. + + If False, no script tag referencing MathJax.js will be included in the + output. + + If 'cdn', a script tag that references a MathJax CDN location will be + included in the output. HTML div strings generated with this option + will be able to display LaTeX typesetting as long as internet access + is available. + + If a string that ends in '.js', a script tag is included that + references the specified path. This approach can be used to point the + resulting HTML div string to an alternative CDN. + post_script: str or None (default None) + JavaScript snippet to be included in the resulting div just after + plot creation. The string may include '{plot_id}' placeholders that + will then be replaced by the `id` of the div element that the + plotly.js figure is associated with. One application for this script + is to install custom plotly.js event handlers. + full_html: bool (default True) + If True, produce a string containing a complete HTML document + starting with an tag. If False, produce a string containing + a single
element. + validate: bool (default True) + True if the figure should be validated before being converted to + JSON, False otherwise. + Returns + ------- + str + Representation of figure as an HTML div string + """ + + # ## Validate figure ## + fig_dict = validate_coerce_fig_to_dict(fig, validate) + + # ## Generate div id ## + plotdivid = str(uuid.uuid4()) + + # ## Serialize figure ## + jdata = json.dumps( + fig_dict.get('data', []), + cls=utils.PlotlyJSONEncoder, + sort_keys=True) + jlayout = json.dumps( + fig_dict.get('layout', {}), + cls=utils.PlotlyJSONEncoder, + sort_keys=True) + + if fig_dict.get('frames', None): + jframes = json.dumps( + fig_dict.get('frames', []), cls=utils.PlotlyJSONEncoder) + else: + jframes = None + + # ## Serialize figure config ## + config = _get_jconfig(config) + + # Check whether we should add responsive + layout_dict = fig_dict.get('layout', {}) + if layout_dict.get('width', None) is None: + config.setdefault('responsive', True) + + jconfig = json.dumps(config) + + # ## Get platform URL ## + plotly_platform_url = config.get('plotlyServerURL', 'https://plot.ly') + + # ## Build script body ## + # This is the part that actually calls Plotly.js + if post_script: + then_post_script = """.then(function(){{ + {post_script} + }})""".format( + post_script=post_script.replace('{plot_id}', plotdivid)) + else: + then_post_script = '' + + then_addframes = '' + then_animate = '' + if jframes: + then_addframes = """.then(function(){{ + Plotly.addFrames('{id}', {frames}); + }})""".format(id=plotdivid, frames=jframes) + + if auto_play: + then_animate = """.then(function(){{ + Plotly.animate('{id}'); + }})""".format(id=plotdivid) + + script = """ + if (document.getElementById("{id}")) {{ + Plotly.newPlot( + '{id}', + {data}, + {layout}, + {config} + ){then_addframes}{then_animate}{then_post_script} + }}""".format( + id=plotdivid, + data=jdata, + layout=jlayout, + config=jconfig, + then_addframes=then_addframes, + then_animate=then_animate, + then_post_script=then_post_script) + + # ## Handle loading/initializing plotly.js ## + include_plotlyjs_orig = include_plotlyjs + if isinstance(include_plotlyjs, six.string_types): + include_plotlyjs = include_plotlyjs.lower() + + # Start/end of requirejs block (if any) + require_start = '' + require_end = '' + + # Init and load + load_plotlyjs = '' + + # Init plotlyjs. This block needs to run before plotly.js is loaded in + # order for MathJax configuration to work properly + if include_plotlyjs == 'require': + require_start = 'require(["plotly"], function(Plotly) {' + require_end = '});' + + elif include_plotlyjs == 'cdn': + load_plotlyjs = """\ + {win_config} + \ + """.format(win_config=_window_plotly_config) + + elif include_plotlyjs == 'directory': + load_plotlyjs = """\ + {win_config} + \ + """.format(win_config=_window_plotly_config) + + elif (isinstance(include_plotlyjs, six.string_types) and + include_plotlyjs.endswith('.js')): + load_plotlyjs = """\ + {win_config} + \ + """.format(win_config=_window_plotly_config, + url=include_plotlyjs_orig) + + elif include_plotlyjs: + load_plotlyjs = """\ + {win_config} + \ + """.format(win_config=_window_plotly_config, + plotlyjs=get_plotlyjs()) + + # ## Handle loading/initializing MathJax ## + include_mathjax_orig = include_mathjax + if isinstance(include_mathjax, six.string_types): + include_mathjax = include_mathjax.lower() + + mathjax_template = """\ + """ + + if include_mathjax == 'cdn': + mathjax_script = mathjax_template.format( + url=('https://cdnjs.cloudflare.com' + '/ajax/libs/mathjax/2.7.5/MathJax.js')) + _mathjax_config + + elif (isinstance(include_mathjax, six.string_types) and + include_mathjax.endswith('.js')): + + mathjax_script = mathjax_template.format( + url=include_mathjax_orig) + _mathjax_config + elif not include_mathjax: + mathjax_script = '' + else: + raise ValueError("""\ +Invalid value of type {typ} received as the include_mathjax argument + Received value: {val} + +include_mathjax may be specified as False, 'cdn', or a string ending with '.js' + """.format(typ=type(include_mathjax), val=repr(include_mathjax))) + + plotly_html_div = """\ +
+ {mathjax_script} + {load_plotlyjs} +
+ +
""".format( + mathjax_script=mathjax_script, + load_plotlyjs=load_plotlyjs, + id=plotdivid, + plotly_platform_url=plotly_platform_url, + require_start=require_start, + script=script, + require_end=require_end) + + if full_html: + return """\ + + + + {div} + +""".format(div=plotly_html_div) + else: + return plotly_html_div + + +def write_html(fig, + file, + config=None, + auto_play=True, + include_plotlyjs=True, + include_mathjax=False, + post_script=None, + full_html=True, + validate=True, + auto_open=False): + """ + Write a figure to an HTML file representation + + Parameters + ---------- + fig: + Figure object or dict representing a figure + file: str or writeable + A string representing a local file path or a writeable object + (e.g. an open file descriptor) + config: dict or None (default None) + Plotly.js figure config options + auto_play: bool (default=True) + Whether to automatically start the animation sequence on page load + if the figure contains frames. Has no effect if the figure does not + contain frames. + include_plotlyjs: bool or string (default True) + Specifies how the plotly.js library is included/loaded in the output + div string. + + If True, a script tag containing the plotly.js source code (~3MB) + is included in the output. HTML files generated with this option are + fully self-contained and can be used offline. + + If 'cdn', a script tag that references the plotly.js CDN is included + in the output. HTML files generated with this option are about 3MB + smaller than those generated with include_plotlyjs=True, but they + require an active internet connection in order to load the plotly.js + library. + + If 'directory', a script tag is included that references an external + plotly.min.js bundle that is assumed to reside in the same + directory as the HTML file. If `file` is a string to a local file path + and `full_html` is True then + + If 'directory', a script tag is included that references an external + plotly.min.js bundle that is assumed to reside in the same + directory as the HTML file. If `file` is a string to a local file + path and `full_html` is True, then the plotly.min.js bundle is copied + into the directory of the resulting HTML file. If a file named + plotly.min.js already exists in the output directory then this file + is left unmodified and no copy is performed. HTML files generated + with this option can be used offline, but they require a copy of + the plotly.min.js bundle in the same directory. This option is + useful when many figures will be saved as HTML files in the same + directory because the plotly.js source code will be included only + once per output directory, rather than once per output file. + + If 'require', Plotly.js is loaded using require.js. This option + assumes that require.js is globally available and that it has been + globally configured to know how to find Plotly.js as 'plotly'. + This option is not advised when full_html=True as it will result + in a non-functional html file. + + If a string that ends in '.js', a script tag is included that + references the specified path. This approach can be used to point + the resulting HTML file to an alternative CDN or local bundle. + + If False, no script tag referencing plotly.js is included. This is + useful when the resulting div string will be placed inside an HTML + document that already loads plotly.js. This option is not advised + when full_html=True as it will result in a non-functional html file. + + include_mathjax: bool or string (default False) + Specifies how the MathJax.js library is included in the output html + div string. MathJax is required in order to display labels + with LaTeX typesetting. + + If False, no script tag referencing MathJax.js will be included in the + output. + + If 'cdn', a script tag that references a MathJax CDN location will be + included in the output. HTML div strings generated with this option + will be able to display LaTeX typesetting as long as internet access + is available. + + If a string that ends in '.js', a script tag is included that + references the specified path. This approach can be used to point the + resulting HTML div string to an alternative CDN. + post_script: str or None (default None) + JavaScript snippet to be included in the resulting div just after + plot creation. The string may include '{plot_id}' placeholders that + will then be replaced by the `id` of the div element that the + plotly.js figure is associated with. One application for this script + is to install custom plotly.js event handlers. + full_html: bool (default True) + If True, produce a string containing a complete HTML document + starting with an tag. If False, produce a string containing + a single
element. + validate: bool (default True) + True if the figure should be validated before being converted to + JSON, False otherwise. + auto_open: bool (default True + If True, open the saved file in a web browser after saving. + This argument only applies if `full_html` is True. + Returns + ------- + str + Representation of figure as an HTML div string + """ + + # Build HTML string + html_str = to_html( + fig, + config=config, + auto_play=auto_play, + include_plotlyjs=include_plotlyjs, + include_mathjax=include_mathjax, + post_script=post_script, + full_html=full_html, + validate=validate) + + # Check if file is a string + file_is_str = isinstance(file, six.string_types) + + # Write HTML string + if file_is_str: + with open(file, 'w') as f: + f.write(html_str) + else: + file.write(html_str) + + # Check if we should copy plotly.min.js to output directory + if file_is_str and full_html and include_plotlyjs == 'directory': + bundle_path = os.path.join( + os.path.dirname(file), 'plotly.min.js') + + if not os.path.exists(bundle_path): + with open(bundle_path, 'w') as f: + f.write(get_plotlyjs()) + + # Handle auto_open + if file_is_str and full_html and auto_open: + url = 'file://' + os.path.abspath(file) + webbrowser.open(url) diff --git a/plotly/io/_renderers.py b/plotly/io/_renderers.py new file mode 100644 index 00000000000..293f75e9bbf --- /dev/null +++ b/plotly/io/_renderers.py @@ -0,0 +1,444 @@ +from __future__ import absolute_import, division + +import textwrap +from copy import copy + +import six +import os + +from plotly import optional_imports +from _plotly_future_ import _future_flags + +from plotly.io._base_renderers import ( + MimetypeRenderer, ExternalRenderer, PlotlyRenderer, NotebookRenderer, + KaggleRenderer, ColabRenderer, JsonRenderer, PngRenderer, JpegRenderer, + SvgRenderer, PdfRenderer, BrowserRenderer, IFrameRenderer) +from plotly.io._utils import validate_coerce_fig_to_dict + +ipython = optional_imports.get_module('IPython') +ipython_display = optional_imports.get_module('IPython.display') + + +# Renderer configuration class +# ----------------------------- +class RenderersConfig(object): + """ + Singleton object containing the current renderer configurations + """ + def __init__(self): + self._renderers = {} + self._default_name = None + self._default_renderers = [] + self._render_on_display = False + self._to_activate = [] + + # ### Magic methods ### + # Make this act as a dict of renderers + def __len__(self): + return len(self._renderers) + + def __contains__(self, item): + return item in self._renderers + + def __iter__(self): + return iter(self._renderers) + + def __getitem__(self, item): + renderer = self._renderers[item] + return renderer + + def __setitem__(self, key, value): + if not isinstance(value, (MimetypeRenderer, ExternalRenderer)): + raise ValueError("""\ +Renderer must be a subclass of MimetypeRenderer or ExternalRenderer. + Received value with type: {typ}""".format(typ=type(value))) + + self._renderers[key] = value + + def __delitem__(self, key): + # Remove template + del self._renderers[key] + + # Check if we need to remove it as the default + if self._default == key: + self._default = None + + def keys(self): + return self._renderers.keys() + + def items(self): + return self._renderers.items() + + def update(self, d={}, **kwargs): + """ + Update one or more renderers from a dict or from input keyword + arguments. + + Parameters + ---------- + d: dict + Dictionary from renderer names to new renderer objects. + + kwargs + Named argument value pairs where the name is a renderer name + and the value is a new renderer object + """ + for k, v in dict(d, **kwargs).items(): + self[k] = v + + # ### Properties ### + @property + def default(self): + """ + The default renderer, or None if no there is no default + + If not None, the default renderer is used to render + figures when the `plotly.io.show` function is called on a Figure. + + If `plotly.io.renderers.render_on_display` is True, then the default + renderer will also be used to display Figures automatically when + displayed in the Jupyter Notebook + + Multiple renderers may be registered by separating their names with + '+' characters. For example, to specify rendering compatible with + the classic Jupyter Notebook, JupyterLab, and PDF export: + + >>> import plotly.io as pio + >>> pio.renderers.default = 'notebook+jupyterlab+pdf' + + The names of available renderers may be retrieved with: + + >>> import plotly.io as pio + >>> list(pio.renderers) + + Returns + ------- + str + """ + return self._default_name + + @default.setter + def default(self, value): + # Handle None + if not value: + # _default_name should always be a string so we can do + # pio.renderers.default.split('+') + self._default_name = '' + self._default_renderers = [] + return + + # Store defaults name and list of renderer(s) + renderer_names = self._validate_coerce_renderers(value) + self._default_name = value + self._default_renderers = [self[name] for name in renderer_names] + + # Register renderers for activation before their next use + self._to_activate.extend(self._default_renderers) + + @property + def render_on_display(self): + """ + If True, the default mimetype renderers will be used to render + figures when they are displayed in an IPython context. + + Returns + ------- + bool + """ + return self._render_on_display + + @render_on_display.setter + def render_on_display(self, val): + self._render_on_display = bool(val) + + def _activate_pending_renderers(self, cls=object): + """ + Activate all renderers that are waiting in the _to_activate list + + Parameters + ---------- + cls + Only activate renders that are subclasses of this class + """ + to_activate_with_cls = [r for r in self._to_activate + if cls and isinstance(r, cls)] + + while to_activate_with_cls: + # Activate renderers from left to right so that right-most + # renderers take precedence + renderer = to_activate_with_cls.pop(0) + renderer.activate() + + self._to_activate = [r for r in self._to_activate + if not (cls and isinstance(r, cls))] + + def _validate_coerce_renderers(self, renderers_string): + """ + Input a string and validate that it contains the names of one or more + valid renderers separated on '+' characters. If valid, return + a list of the renderer names + + Parameters + ---------- + renderers_string: str + + Returns + ------- + list of str + """ + # Validate value + if not isinstance(renderers_string, six.string_types): + raise ValueError('Renderer must be specified as a string') + + renderer_names = renderers_string.split('+') + invalid = [name for name in renderer_names if name not in self] + if invalid: + raise ValueError(""" +Invalid named renderer(s) received: {}""".format(str(invalid))) + + return renderer_names + + def __repr__(self): + return """\ +Renderers configuration +----------------------- + Default renderer: {default} + Available renderers: +{available} +""".format(default=repr(self.default), + available=self._available_renderers_str()) + + def _available_renderers_str(self): + """ + Return nicely wrapped string representation of all + available renderer names + """ + available = '\n'.join(textwrap.wrap( + repr(list(self)), + width=79 - 8, + initial_indent=' ' * 8, + subsequent_indent=' ' * 9 + )) + return available + + def _build_mime_bundle(self, fig_dict, renderers_string=None, **kwargs): + """ + Build a mime bundle dict containing a kev/value pair for each + MimetypeRenderer specified in either the default renderer string, + or in the supplied renderers_string argument. + + Note that this method skips any renderers that are not subclasses + of MimetypeRenderer. + + Parameters + ---------- + fig_dict: dict + Figure dictionary + renderers_string: str or None (default None) + Renderer string to process rather than the current default + renderer string + + Returns + ------- + dict + """ + if renderers_string: + renderer_names = self._validate_coerce_renderers(renderers_string) + renderers_list = [self[name] for name in renderer_names] + + # Activate these non-default renderers + for renderer in renderers_list: + if isinstance(renderer, MimetypeRenderer): + renderer.activate() + else: + # Activate any pending default renderers + self._activate_pending_renderers(cls=MimetypeRenderer) + renderers_list = self._default_renderers + + bundle = {} + for renderer in renderers_list: + if isinstance(renderer, MimetypeRenderer): + renderer = copy(renderer) + for k, v in kwargs.items(): + if hasattr(renderer, k): + setattr(renderer, k, v) + + bundle.update(renderer.to_mimebundle(fig_dict)) + + return bundle + + def _perform_external_rendering( + self, fig_dict, renderers_string=None, **kwargs): + """ + Perform external rendering for each ExternalRenderer specified + in either the default renderer string, or in the supplied + renderers_string argument. + + Note that this method skips any renderers that are not subclasses + of ExternalRenderer. + + Parameters + ---------- + fig_dict: dict + Figure dictionary + renderers_string: str or None (default None) + Renderer string to process rather than the current default + renderer string + + Returns + ------- + None + """ + if renderers_string: + renderer_names = self._validate_coerce_renderers(renderers_string) + renderers_list = [self[name] for name in renderer_names] + + # Activate these non-default renderers + for renderer in renderers_list: + if isinstance(renderer, ExternalRenderer): + renderer.activate() + else: + self._activate_pending_renderers(cls=ExternalRenderer) + renderers_list = self._default_renderers + + for renderer in renderers_list: + if isinstance(renderer, ExternalRenderer): + renderer = copy(renderer) + for k, v in kwargs.items(): + if hasattr(renderer, k): + setattr(renderer, k, v) + + renderer.render(fig_dict) + + +# Make renderers a singleton object +# --------------------------------- +renderers = RenderersConfig() +del RenderersConfig + + +# Show +def show(fig, renderer=None, validate=True, **kwargs): + """ + Show a figure using either the default renderer(s) or the renderer(s) + specified by the renderer argument + + Parameters + ---------- + fig: dict of Figure + The Figure object or figure dict to display + + renderer: str or None (default None) + A string containing the names of one or more registered renderers + (separated by '+' characters) or None. If None, then the default + renderers specified in plotly.io.renderers.default are used. + + validate: bool (default True) + True if the figure should be validated before being shown, + False otherwise. + + Returns + ------- + None + """ + fig_dict = validate_coerce_fig_to_dict(fig, validate) + + # Mimetype renderers + bundle = renderers._build_mime_bundle( + fig_dict, renderers_string=renderer, **kwargs) + if bundle: + if not ipython_display: + raise ValueError( + 'Mime type rendering requires ipython but it is not installed') + + ipython_display.display(bundle, raw=True) + + # external renderers + renderers._perform_external_rendering( + fig_dict, renderers_string=renderer, **kwargs) + + +# Register renderers +# ------------------ + +# Plotly mime type +plotly_renderer = PlotlyRenderer() +renderers['plotly_mimetype'] = plotly_renderer +renderers['jupyterlab'] = plotly_renderer +renderers['nteract'] = plotly_renderer +renderers['vscode'] = plotly_renderer + +# HTML-based +config = {} +renderers['notebook'] = NotebookRenderer(config=config) +renderers['notebook_connected'] = NotebookRenderer( + config=config, connected=True) +renderers['kaggle'] = KaggleRenderer(config=config) +renderers['colab'] = ColabRenderer(config=config) + +# JSON +renderers['json'] = JsonRenderer() + +# Static Image +img_kwargs = dict(height=450, width=700) +renderers['png'] = PngRenderer(**img_kwargs) +jpeg_renderer = JpegRenderer(**img_kwargs) +renderers['jpeg'] = jpeg_renderer +renderers['jpg'] = jpeg_renderer +renderers['svg'] = SvgRenderer(**img_kwargs) +renderers['pdf'] = PdfRenderer(**img_kwargs) + +# External +renderers['browser'] = BrowserRenderer(config=config) +renderers['firefox'] = BrowserRenderer(config=config, using='firefox') +renderers['chrome'] = BrowserRenderer(config=config, using='chrome') +renderers['chromium'] = BrowserRenderer(config=config, using='chromium') +renderers['iframe'] = IFrameRenderer(config=config) + +# Set default renderer +# -------------------- +if 'renderer_defaults' in _future_flags: + # Version 4 renderer configuration + default_renderer = None + + # Try to detect environment so that we can enable a useful + # default renderer + if ipython and ipython.get_ipython(): + try: + import google.colab + + default_renderer = 'colab' + except ImportError: + pass + + # Check if we're running in a Kaggle notebook + if not default_renderer and os.path.exists('/kaggle/input'): + default_renderer = 'kaggle' + + # Check if we're running in VSCode + if not default_renderer and 'VSCODE_PID' in os.environ: + default_renderer = 'vscode' + + # Fallback to renderer combination that will work automatically + # in the classic notebook, jupyterlab, nteract, vscode, and + # nbconvert HTML export. We use 'notebook_connected' rather than + # 'notebook' to avoid bloating notebook size and slowing down + # plotly.py initial import. This comes at the cost of requiring + # internet connectivity to view, but that is a preferable + # trade-off to adding ~3MB to each saved notebook. + # + # Note that this doesn't cause any problem for offline + # JupyterLab users. + if not default_renderer: + default_renderer = 'notebook_connected+plotly_mimetype' + else: + # If ipython isn't available, try to display figures in the default + # browser + default_renderer = 'browser' + + renderers.render_on_display = True + renderers.default = default_renderer +else: + # Version 3 defaults + renderers.render_on_display = False + renderers.default = 'plotly_mimetype' diff --git a/plotly/io/base_renderers.py b/plotly/io/base_renderers.py new file mode 100644 index 00000000000..6042296a644 --- /dev/null +++ b/plotly/io/base_renderers.py @@ -0,0 +1,5 @@ +from ._base_renderers import ( + MimetypeRenderer, PlotlyRenderer, JsonRenderer, ImageRenderer, + PngRenderer, SvgRenderer, PdfRenderer, JpegRenderer, HtmlRenderer, + ColabRenderer, KaggleRenderer, NotebookRenderer, ExternalRenderer, + BrowserRenderer) diff --git a/plotly/offline/offline.py b/plotly/offline/offline.py index 1180a897c1d..5cad091f011 100644 --- a/plotly/offline/offline.py +++ b/plotly/offline/offline.py @@ -24,8 +24,6 @@ ipython_display = optional_imports.get_module('IPython.display') matplotlib = optional_imports.get_module('matplotlib') -__PLOTLY_OFFLINE_INITIALIZED = False - __IMAGE_FORMATS = ['jpeg', 'png', 'webp', 'svg'] @@ -222,18 +220,35 @@ def get_image_download_script(caller): raise ValueError('caller should only be one of `iplot` or `plot`') return( - ('') - ) + 'downloadimage(\'{format}\', {height}, {width}, ' + '\'{filename}\');' + + check_end)) + + +def build_save_image_post_script( + image, image_filename, image_height, image_width, caller): + if image: + if image not in __IMAGE_FORMATS: + raise ValueError('The image parameter must be one of the ' + 'following: {}'.format(__IMAGE_FORMATS) + ) + + script = get_image_download_script(caller) + post_script = script.format( + format=image, + width=image_width, + height=image_height, + filename=image_filename) + else: + post_script = None + + return post_script def init_notebook_mode(connected=False): @@ -262,162 +277,26 @@ def init_notebook_mode(connected=False): your notebook, resulting in much larger notebook sizes compared to the case where `connected=True`. """ + import plotly.io as pio + if not ipython: raise ImportError('`iplot` can only run inside an IPython Notebook.') - global __PLOTLY_OFFLINE_INITIALIZED - if connected: - # Inject plotly.js into the output cell - script_inject = ( - '{win_config}' - '{mathjax_config}' - '' - ).format(win_config=_window_plotly_config, - mathjax_config=_mathjax_config) - else: - # Inject plotly.js into the output cell - script_inject = ( - '{win_config}' - '{mathjax_config}' - '' - '').format(script=get_plotlyjs(), - win_config=_window_plotly_config, - mathjax_config=_mathjax_config) - - display_bundle = { - 'text/html': script_inject, - 'text/vnd.plotly.v1+html': script_inject - } - ipython_display.display(display_bundle, raw=True) - __PLOTLY_OFFLINE_INITIALIZED = True - - -def _plot_html(figure_or_data, config, validate, default_width, - default_height, global_requirejs, auto_play): - - 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(height) - except (ValueError, TypeError): - pass - else: - height = str(height) + 'px' - - plotdivid = uuid.uuid4() - jdata = _json.dumps(figure.get('data', []), cls=utils.PlotlyJSONEncoder) - jlayout = _json.dumps(figure.get('layout', {}), - cls=utils.PlotlyJSONEncoder) - - if figure.get('frames', None): - jframes = _json.dumps(figure.get('frames', []), - cls=utils.PlotlyJSONEncoder) - else: - jframes = None - - jconfig = _json.dumps(_get_jconfig(config)) - plotly_platform_url = plotly.plotly.get_config().get('plotly_domain', - 'https://plot.ly') - - if jframes: - if auto_play: - animate = (".then(function(){" + - "Plotly.animate('{id}');".format(id=plotdivid) + - "})") - else: - animate = '' - - script = ''' - if (document.getElementById("{id}")) {{ - Plotly.plot( - '{id}', - {data}, - {layout}, - {config} - ).then(function () {add_frames}){animate} - }} - '''.format( - id=plotdivid, - data=jdata, - layout=jlayout, - config=jconfig, - add_frames="{" + "return Plotly.addFrames('{id}',{frames}".format( - id=plotdivid, frames=jframes - ) + ");}", - animate=animate - ) + pio.renderers.default = 'notebook_connected+plotly_mimetype' else: - script = """ -if (document.getElementById("{id}")) {{ - Plotly.newPlot("{id}", {data}, {layout}, {config}); -}} -""".format( - id=plotdivid, - data=jdata, - layout=jlayout, - config=jconfig) - - optional_line1 = ('require(["plotly"], function(Plotly) {{ ' - if global_requirejs else '') - optional_line2 = ('}});' if global_requirejs else '') - - plotly_html_div = ( - '' - '
' - '
' - '' - '').format( - id=plotdivid, script=script, - height=height, width=width) + pio.renderers.default = 'notebook+plotly_mimetype' - return plotly_html_div, plotdivid, width, height + # Trigger immediate activation of notebook. This way the plotly.js + # library reference is available to the notebook immediately + pio.renderers._activate_pending_renderers() def iplot(figure_or_data, show_link=False, link_text='Export to plot.ly', validate=True, image=None, filename='plot_image', image_width=800, image_height=600, config=None, auto_play=True): """ - Draw plotly graphs inside an IPython or Jupyter notebook without - connecting to an external server. - To save the chart to Plotly Cloud or Plotly Enterprise, use - `plotly.plotly.iplot`. - To embed an image of the chart, use `plotly.image.ishow`. + Draw plotly graphs inside an IPython or Jupyter notebook figure_or_data -- a plotly.graph_objs.Figure or plotly.graph_objs.Data or dict or list that describes a Plotly graph. @@ -438,8 +317,8 @@ def iplot(figure_or_data, show_link=False, link_text='Export to plot.ly', the format of the image to be downloaded, if we choose to download an image. This parameter has a default value of None indicating that no image should be downloaded. Please note: for higher resolution images - and more export options, consider making requests to our image servers. - Type: `help(py.image)` for more details. + and more export options, consider using plotly.io.write_image. See + https://plot.ly/python/static-image-export/ for more details. filename (default='plot') -- Sets the name of the file your image will be saved to. The extension should not be included. image_height (default=600) -- Specifies the height of the image in `px`. @@ -461,6 +340,8 @@ def iplot(figure_or_data, show_link=False, link_text='Export to plot.ly', iplot([{'x': [1, 2, 3], 'y': [5, 2, 7]}], image='png') ``` """ + import plotly.io as pio + if not ipython: raise ImportError('`iplot` can only run inside an IPython Notebook.') @@ -468,63 +349,19 @@ def iplot(figure_or_data, show_link=False, link_text='Export to plot.ly', config.setdefault('showLink', show_link) config.setdefault('linkText', link_text) - jconfig = _get_jconfig(config) - + # Get figure figure = tools.return_figure_from_figure_or_data(figure_or_data, validate) - # Though it can add quite a bit to the display-bundle size, we include - # multiple representations of the plot so that the display environment can - # choose which one to act on. - data = _json.loads(_json.dumps(figure['data'], - cls=plotly.utils.PlotlyJSONEncoder)) - layout = _json.loads(_json.dumps(figure.get('layout', {}), - cls=plotly.utils.PlotlyJSONEncoder)) - frames = _json.loads(_json.dumps(figure.get('frames', None), - cls=plotly.utils.PlotlyJSONEncoder)) - - fig = {'data': data, 'layout': layout, 'config': jconfig} - if frames: - fig['frames'] = frames - - display_bundle = {'application/vnd.plotly.v1+json': fig} + # Handle image request + post_script = build_save_image_post_script( + image, filename, image_height, image_width, 'iplot') - if __PLOTLY_OFFLINE_INITIALIZED: - plot_html, plotdivid, width, height = _plot_html( - figure_or_data, config, validate, '100%', 525, True, auto_play) - resize_script = '' - if width == '100%' or height == '100%': - resize_script = _build_resize_script( - plotdivid, 'window._Plotly') - - display_bundle['text/html'] = plot_html + resize_script - display_bundle['text/vnd.plotly.v1+html'] = plot_html + resize_script - - ipython_display.display(display_bundle, raw=True) - - if image: - if not __PLOTLY_OFFLINE_INITIALIZED: - raise PlotlyError('\n'.join([ - 'Plotly Offline mode has not been initialized in this notebook. ' - 'Run: ', - '', - 'import plotly', - 'plotly.offline.init_notebook_mode() ' - '# run at the start of every ipython notebook', - ])) - if image not in __IMAGE_FORMATS: - raise ValueError('The image parameter must be one of the following' - ': {}'.format(__IMAGE_FORMATS) - ) - # if image is given, and is a valid format, we will download the image - script = get_image_download_script('iplot').format(format=image, - width=image_width, - height=image_height, - filename=filename, - plot_id=plotdivid) - # allow time for the plot to draw - time.sleep(1) - # inject code to download an image of the plot - ipython_display.display(ipython_display.HTML(script)) + # Show figure + pio.show(figure, + validate=validate, + config=config, + auto_play=auto_play, + post_script=post_script) def plot(figure_or_data, show_link=False, link_text='Export to plot.ly', @@ -645,6 +482,9 @@ def plot(figure_or_data, show_link=False, link_text='Export to plot.ly', sequence on page load if the figure contains frames. Has no effect if the figure does not contain frames. """ + import plotly.io as pio + + # Output type if output_type not in ['div', 'file']: raise ValueError( "`output_type` argument must be 'div' or 'file'. " @@ -655,123 +495,45 @@ def plot(figure_or_data, show_link=False, link_text='Export to plot.ly', "Adding .html to the end of your file.") filename += '.html' + # Config config = dict(config) if config else {} config.setdefault('showLink', show_link) config.setdefault('linkText', link_text) - plot_html, plotdivid, width, height = _plot_html( - figure_or_data, config, validate, - '100%', '100%', global_requirejs=False, auto_play=auto_play) + 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', '100%') - # Build resize_script - resize_script = '' if width == '100%' or height == '100%': - resize_script = _build_resize_script(plotdivid) - - # Process include_plotlyjs and build plotly_js_script - include_plotlyjs_orig = include_plotlyjs - if isinstance(include_plotlyjs, six.string_types): - include_plotlyjs = include_plotlyjs.lower() - - if include_plotlyjs == 'cdn': - plotly_js_script = _window_plotly_config + """\ -""" - elif include_plotlyjs == 'directory': - plotly_js_script = (_window_plotly_config + - '') - elif (isinstance(include_plotlyjs, six.string_types) and - include_plotlyjs.endswith('.js')): - plotly_js_script = (_window_plotly_config + - ''.format( - url=include_plotlyjs_orig)) - elif include_plotlyjs: - plotly_js_script = ''.join([ - _window_plotly_config, - '', - ]) - else: - plotly_js_script = '' - - # Process include_mathjax and build mathjax_script - include_mathjax_orig = include_mathjax - if isinstance(include_mathjax, six.string_types): - include_mathjax = include_mathjax.lower() - - if include_mathjax == 'cdn': - mathjax_script = _build_mathjax_script( - url=('https://cdnjs.cloudflare.com' - '/ajax/libs/mathjax/2.7.5/MathJax.js')) + _mathjax_config - elif (isinstance(include_mathjax, six.string_types) and - include_mathjax.endswith('.js')): - mathjax_script = _build_mathjax_script( - url=include_mathjax_orig) + _mathjax_config - elif not include_mathjax: - mathjax_script = '' - else: - raise ValueError("""\ -Invalid value of type {typ} received as the include_mathjax argument - Received value: {val} + config.setdefault('responsive', True) -include_mathjax may be specified as False, 'cdn', or a string ending with '.js' -""".format(typ=type(include_mathjax), val=repr(include_mathjax))) + # Handle image request + post_script = build_save_image_post_script( + image, image_filename, image_height, image_width, 'plot') if output_type == 'file': - with open(filename, 'w') as f: - if image: - if image not in __IMAGE_FORMATS: - raise ValueError('The image parameter must be one of the ' - 'following: {}'.format(__IMAGE_FORMATS) - ) - # if the check passes then download script is injected. - # write the download script: - script = get_image_download_script('plot') - script = script.format(format=image, - width=image_width, - height=image_height, - filename=image_filename, - plot_id=plotdivid) - else: - script = '' - - f.write(''.join([ - '', - '', - '', - mathjax_script, - plotly_js_script, - plot_html, - resize_script, - script, - '', - ''])) - - # Check if we should copy plotly.min.js to output directory - if include_plotlyjs == 'directory': - bundle_path = os.path.join( - os.path.dirname(filename), 'plotly.min.js') - - if not os.path.exists(bundle_path): - with open(bundle_path, 'w') as f: - f.write(get_plotlyjs()) - - url = 'file://' + os.path.abspath(filename) - if auto_open: - webbrowser.open(url) - - return url - - elif output_type == 'div': - - return ''.join([ - '
', - mathjax_script, - plotly_js_script, - plot_html, - resize_script, - '
', - ]) + pio.write_html( + figure, + filename, + config=config, + auto_play=auto_play, + include_plotlyjs=include_plotlyjs, + include_mathjax=include_mathjax, + post_script=post_script, + full_html=True, + validate=validate, + auto_open=auto_open) + return filename + else: + return pio.to_html( + figure, + config=config, + auto_play=auto_play, + include_plotlyjs=include_plotlyjs, + include_mathjax=include_mathjax, + post_script=post_script, + full_html=False, + validate=validate) def plot_mpl(mpl_fig, resize=False, strip_style=False, diff --git a/plotly/tests/test_core/test_offline/test_offline.py b/plotly/tests/test_core/test_offline/test_offline.py index 8fb3499180c..da1a93e21a1 100644 --- a/plotly/tests/test_core/test_offline/test_offline.py +++ b/plotly/tests/test_core/test_offline/test_offline.py @@ -36,10 +36,7 @@ } -resize_code_strings = [ - 'window.addEventListener("resize", ', - 'Plotly.Plots.resize(' - ] +resize_code_strings = ['"responsive": true'] PLOTLYJS = plotly.offline.get_plotlyjs() @@ -68,6 +65,8 @@ do_auto_play = 'Plotly.animate' +download_image = 'Plotly.downloadImage' + class PlotlyOfflineBaseTestCase(TestCase): def tearDown(self): # Some offline tests produce an html file. Make sure we clean up :) @@ -418,6 +417,18 @@ def test_auto_play(self): self.assertIn(do_auto_play, html) def test_no_auto_play(self): - html = plotly.offline.plot(fig_frames, output_type='div', auto_play=False) + html = plotly.offline.plot( + fig_frames, output_type='div',auto_play=False) self.assertIn(add_frames, html) self.assertNotIn(do_auto_play, html) + + def test_download_image(self): + # Not download image by default + html = plotly.offline.plot( + fig_frames, output_type='div', auto_play=False) + self.assertNotIn(download_image, html) + + # Request download image + html = plotly.offline.plot( + fig_frames, output_type='div', auto_play=False, image='png') + self.assertIn(download_image, html) \ No newline at end of file diff --git a/plotly/tests/test_io/test_renderers.py b/plotly/tests/test_io/test_renderers.py new file mode 100644 index 00000000000..a916523f549 --- /dev/null +++ b/plotly/tests/test_io/test_renderers.py @@ -0,0 +1,277 @@ +import json +import sys +import base64 +import threading +import time + +import pytest +import requests +import numpy as np + +import plotly.graph_objs as go +import plotly.io as pio +from plotly.offline import get_plotlyjs + +if sys.version_info.major == 3 and sys.version_info.minor >= 3: + import unittest.mock as mock + from unittest.mock import MagicMock +else: + import mock + from mock import MagicMock + + +# fixtures +# -------- +@pytest.fixture +def fig1(request): + return go.Figure(data=[{'type': 'scatter', + 'y': np.array([2, 1, 3, 2, 4, 2]), + 'marker': {'color': 'green'}}], + layout={'title': {'text': 'Figure title'}}) + + +# JSON +# ---- +def test_json_renderer_mimetype(fig1): + pio.renderers.default = 'json' + expected = {'application/json': json.loads( + pio.to_json(fig1, remove_uids=False))} + + pio.renderers.render_on_display = False + assert fig1._repr_mimebundle_(None, None) is None + + pio.renderers.render_on_display = True + bundle = fig1._repr_mimebundle_(None, None) + assert bundle == expected + + +def test_json_renderer_show(fig1): + pio.renderers.default = 'json' + expected_bundle = {'application/json': json.loads( + pio.to_json(fig1, remove_uids=False))} + + with mock.patch('IPython.display.display') as mock_display: + pio.show(fig1) + + mock_display.assert_called_once_with(expected_bundle, raw=True) + + +def test_json_renderer_show_override(fig1): + pio.renderers.default = 'notebook' + expected_bundle = {'application/json': json.loads( + pio.to_json(fig1, remove_uids=False))} + + with mock.patch('IPython.display.display') as mock_display: + pio.show(fig1, renderer='json') + + mock_display.assert_called_once_with(expected_bundle, raw=True) + + +# Plotly mimetype +# --------------- +plotly_mimetype = 'application/vnd.plotly.v1+json' +plotly_mimetype_renderers = [ + 'plotly_mimetype', 'jupyterlab', 'vscode', 'nteract'] + + +@pytest.mark.parametrize('renderer', plotly_mimetype_renderers) +def test_plotly_mimetype_renderer_mimetype(fig1, renderer): + pio.renderers.default = renderer + expected = {plotly_mimetype: json.loads( + pio.to_json(fig1, remove_uids=False))} + + expected[plotly_mimetype]['config'] = { + 'plotlyServerURL': 'https://plot.ly'} + + pio.renderers.render_on_display = False + assert fig1._repr_mimebundle_(None, None) is None + + pio.renderers.render_on_display = True + bundle = fig1._repr_mimebundle_(None, None) + assert bundle == expected + + +@pytest.mark.parametrize('renderer', plotly_mimetype_renderers) +def test_plotly_mimetype_renderer_show(fig1, renderer): + pio.renderers.default = renderer + expected = {plotly_mimetype: json.loads( + pio.to_json(fig1, remove_uids=False))} + + expected[plotly_mimetype]['config'] = { + 'plotlyServerURL': 'https://plot.ly'} + + with mock.patch('IPython.display.display') as mock_display: + pio.show(fig1) + + mock_display.assert_called_once_with(expected, raw=True) + + +# Static Image +# ------------ +# See plotly/tests/test_orca/test_image_renderers.py + +# HTML +# ---- +def assert_full_html(html): + assert html.startswith(' doc - self.assertTrue(html.startswith('') and html.endswith('')) + self.assertTrue(html.startswith('') + and html.endswith('')) diff --git a/plotly/tests/test_orca/test_image_renderers.py b/plotly/tests/test_orca/test_image_renderers.py new file mode 100644 index 00000000000..d04bab8e99b --- /dev/null +++ b/plotly/tests/test_orca/test_image_renderers.py @@ -0,0 +1,135 @@ +import base64 +import sys +import json + +import pytest +import numpy as np + +from plotly import io as pio +import plotly.graph_objs as go +from plotly.config import get_config + +if sys.version_info.major == 3 and sys.version_info.minor >= 3: + import unittest.mock as mock +else: + import mock + +plotly_mimetype = 'application/vnd.plotly.v1+json' + + +# fixtures +# -------- +@pytest.fixture +def fig1(request): + return go.Figure(data=[{'type': 'scatter', + 'marker': {'color': 'green'}, + 'y': np.array([2, 1, 3, 2, 4, 2])}], + layout={'title': {'text': 'Figure title'}}) + + +def test_png_renderer_mimetype(fig1): + pio.renderers.default = 'png' + + # Configure renderer so that we can use the same parameters + # to build expected image below + pio.renderers['png'].width = 400 + pio.renderers['png'].height = 500 + pio.renderers['png'].scale = 1 + + image_bytes = pio.to_image(fig1, width=400, height=500, scale=1) + image_str = base64.b64encode(image_bytes).decode('utf8') + + expected = {'image/png': image_str} + + pio.renderers.render_on_display = False + assert fig1._repr_mimebundle_(None, None) is None + + pio.renderers.render_on_display = True + bundle = fig1._repr_mimebundle_(None, None) + assert bundle == expected + + +def test_svg_renderer_show(fig1): + pio.renderers.default = 'svg' + pio.renderers['svg'].width = 400 + pio.renderers['svg'].height = 500 + pio.renderers['svg'].scale = 1 + + with mock.patch('IPython.display.display') as mock_display: + pio.show(fig1) + + # Check call args. + # SVGs generated by orca are currently not reproducible so we just + # check the mime type and that the resulting string is an SVG with the + # expected size + mock_call_args = mock_display.call_args + + mock_arg1 = mock_call_args[0][0] + assert list(mock_arg1) == ['image/svg+xml'] + assert mock_arg1['image/svg+xml'].startswith( + '