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