From 2f58aa69d21aca70cff42e836c4cac4520456c55 Mon Sep 17 00:00:00 2001 From: Austen Date: Fri, 5 Apr 2019 15:16:39 +0100 Subject: [PATCH 1/4] added animation opts to python interface --- plotly/offline/offline.py | 53 +++++++++++++++---- .../chunked_requests/chunked_request.py | 36 +++---------- 2 files changed, 52 insertions(+), 37 deletions(-) diff --git a/plotly/offline/offline.py b/plotly/offline/offline.py index 1180a897c1..8abc0e4abd 100644 --- a/plotly/offline/offline.py +++ b/plotly/offline/offline.py @@ -312,7 +312,7 @@ def init_notebook_mode(connected=False): def _plot_html(figure_or_data, config, validate, default_width, - default_height, global_requirejs, auto_play): + default_height, global_requirejs, auto_play, animation_opts=None): figure = tools.return_figure_from_figure_or_data(figure_or_data, validate) @@ -350,9 +350,12 @@ def _plot_html(figure_or_data, config, validate, default_width, if jframes: if auto_play: + print(plotdivid) animate = (".then(function(){" + - "Plotly.animate('{id}');".format(id=plotdivid) + + "Plotly.animate('{id}',null{opts});".format(id=plotdivid, + opts=", "+ str(animation_opts) if animation_opts is not None else "") + #{frame: {duration: '{dr}'}} , null, {traces: [0]} "})") + else: animate = '' @@ -378,7 +381,7 @@ def _plot_html(figure_or_data, config, validate, default_width, else: script = """ if (document.getElementById("{id}")) {{ - Plotly.newPlot("{id}", {data}, {layout}, {config}); + Plotly.newPlot("{id}", {data}, {layout}, {config}); }} """.format( id=plotdivid, @@ -411,7 +414,7 @@ def _plot_html(figure_or_data, config, validate, default_width, 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): + image_height=600, config=None, auto_play=True, animation_opts=None): """ Draw plotly graphs inside an IPython or Jupyter notebook without connecting to an external server. @@ -460,6 +463,21 @@ def iplot(figure_or_data, show_link=False, link_text='Export to plot.ly', format you want. e.g. `image='png'` iplot([{'x': [1, 2, 3], 'y': [5, 2, 7]}], image='png') ``` + animation_opts (default=None) -- Custom animation parameters to be passed + to the function Plotly.animate in Plotly.js in the form str(dict) + Example: + ``` + from plotly.offline import iplot + figure = {'data': [{'x': [0, 1], 'y': [0, 1]}], + 'layout': {'xaxis': {'range': [0, 5], 'autorange': False}, + 'yaxis': {'range': [0, 5], 'autorange': False}, + 'title': 'Start Title'}, + 'frames': [{'data': [{'x': [1, 2], 'y': [1, 2]}]}, + {'data': [{'x': [1, 4], 'y': [1, 4]}]}, + {'data': [{'x': [3, 4], 'y': [3, 4]}], + 'layout': {'title': 'End Title'}}]} + iplot(figure,animation_opts="{frame: {duration: 1}}") + ``` """ if not ipython: raise ImportError('`iplot` can only run inside an IPython Notebook.') @@ -490,7 +508,8 @@ def iplot(figure_or_data, show_link=False, link_text='Export to plot.ly', if __PLOTLY_OFFLINE_INITIALIZED: plot_html, plotdivid, width, height = _plot_html( - figure_or_data, config, validate, '100%', 525, True, auto_play) + figure_or_data, config, validate, '100%', + 525, True, auto_play,animation_opts=animation_opts) resize_script = '' if width == '100%' or height == '100%': resize_script = _build_resize_script( @@ -531,7 +550,7 @@ def plot(figure_or_data, show_link=False, link_text='Export to plot.ly', validate=True, output_type='file', include_plotlyjs=True, filename='temp-plot.html', auto_open=True, image=None, image_filename='plot_image', image_width=800, image_height=600, - config=None, include_mathjax=False, auto_play=True): + config=None, include_mathjax=False, auto_play=True, animation_opts=None): """ Create a plotly graph locally as an HTML document or string. Example: @@ -644,6 +663,21 @@ def plot(figure_or_data, show_link=False, link_text='Export to plot.ly', auto_play (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. + animation_opts (default=None) -- Custom animation parameters to be passed + to the function Plotly.animate in Plotly.js in the form str(dict) + Example: + ``` + from plotly.offline import plot + figure = {'data': [{'x': [0, 1], 'y': [0, 1]}], + 'layout': {'xaxis': {'range': [0, 5], 'autorange': False}, + 'yaxis': {'range': [0, 5], 'autorange': False}, + 'title': 'Start Title'}, + 'frames': [{'data': [{'x': [1, 2], 'y': [1, 2]}]}, + {'data': [{'x': [1, 4], 'y': [1, 4]}]}, + {'data': [{'x': [3, 4], 'y': [3, 4]}], + 'layout': {'title': 'End Title'}}]} + plot(figure,animation_opts="{frame: {duration: 1}}") + ``` """ if output_type not in ['div', 'file']: raise ValueError( @@ -661,7 +695,8 @@ def plot(figure_or_data, show_link=False, link_text='Export to plot.ly', plot_html, plotdivid, width, height = _plot_html( figure_or_data, config, validate, - '100%', '100%', global_requirejs=False, auto_play=auto_play) + '100%', '100%', global_requirejs=False, + auto_play=auto_play, animation_opts=animation_opts) # Build resize_script resize_script = '' @@ -701,7 +736,7 @@ def plot(figure_or_data, show_link=False, link_text='Export to plot.ly', if include_mathjax == 'cdn': mathjax_script = _build_mathjax_script( - url=('https://cdnjs.cloudflare.com' + 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')): @@ -714,7 +749,7 @@ def plot(figure_or_data, show_link=False, link_text='Export to plot.ly', 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' +include_mathjax may be specified as False, 'cdn', or a string ending with '.js' """.format(typ=type(include_mathjax), val=repr(include_mathjax))) if output_type == 'file': diff --git a/plotly/plotly/chunked_requests/chunked_request.py b/plotly/plotly/chunked_requests/chunked_request.py index 4f8d325edb..eb70488f59 100644 --- a/plotly/plotly/chunked_requests/chunked_request.py +++ b/plotly/plotly/chunked_requests/chunked_request.py @@ -4,9 +4,7 @@ import ssl from six.moves import http_client -from six.moves.urllib.parse import urlparse, unquote - -from plotly.api import utils +from six.moves.urllib.parse import urlparse class Stream: @@ -27,7 +25,7 @@ def __init__(self, server, port=80, headers={}, url='/', ssl_enabled=False, self._ssl_verification_enabled = ssl_verification_enabled self._connect() - def write(self, data, reconnect_on=('', 200, 502)): + def write(self, data, reconnect_on=('', 200, )): ''' Send `data` to the server in chunk-encoded form. Check the connection before writing and reconnect if disconnected and if the response status code is in `reconnect_on`. @@ -88,34 +86,21 @@ def _get_proxy_config(self): proxy_server = None proxy_port = None - proxy_username = None - proxy_password = None - proxy_auth = None ssl_enabled = self._ssl_enabled if ssl_enabled: - proxy = (os.environ.get("https_proxy") or - os.environ.get("HTTPS_PROXY")) + proxy = os.environ.get("https_proxy") else: - proxy = (os.environ.get("http_proxy") or - os.environ.get("HTTP_PROXY")) - - no_proxy = os.environ.get("no_proxy") or os.environ.get("NO_PROXY") + proxy = os.environ.get("http_proxy") + no_proxy = os.environ.get("no_proxy") no_proxy_url = no_proxy and self._server in no_proxy if proxy and not no_proxy_url: p = urlparse(proxy) proxy_server = p.hostname proxy_port = p.port - proxy_username = p.username - proxy_password = p.password - - if proxy_username and proxy_password: - username = unquote(proxy_username) - password = unquote(proxy_password) - proxy_auth = utils.basic_auth(username, password) - return proxy_server, proxy_port, proxy_auth + return proxy_server, proxy_port def _get_ssl_context(self): """ @@ -138,7 +123,7 @@ def _connect(self): port = self._port headers = self._headers ssl_enabled = self._ssl_enabled - proxy_server, proxy_port, proxy_auth = self._get_proxy_config() + proxy_server, proxy_port = self._get_proxy_config() if (proxy_server and proxy_port): if ssl_enabled: @@ -150,12 +135,7 @@ def _connect(self): self._conn = http_client.HTTPConnection( proxy_server, proxy_port ) - - tunnel_headers = None - if proxy_auth: - tunnel_headers = {'Proxy-Authorization': proxy_auth} - - self._conn.set_tunnel(server, port, headers=tunnel_headers) + self._conn.set_tunnel(server, port) else: if ssl_enabled: context = self._get_ssl_context() From f80ad21a486b64f1c7f76ab7edfe167327165196 Mon Sep 17 00:00:00 2001 From: Austen Date: Tue, 9 Apr 2019 09:25:23 +0100 Subject: [PATCH 2/4] test --- plotly/offline/offline.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plotly/offline/offline.py b/plotly/offline/offline.py index 8abc0e4abd..c8f2ef6b73 100644 --- a/plotly/offline/offline.py +++ b/plotly/offline/offline.py @@ -338,6 +338,7 @@ def _plot_html(figure_or_data, config, validate, default_width, jlayout = _json.dumps(figure.get('layout', {}), cls=utils.PlotlyJSONEncoder) + if figure.get('frames', None): jframes = _json.dumps(figure.get('frames', []), cls=utils.PlotlyJSONEncoder) @@ -347,7 +348,7 @@ def _plot_html(figure_or_data, config, validate, default_width, jconfig = _json.dumps(_get_jconfig(config)) plotly_platform_url = plotly.plotly.get_config().get('plotly_domain', 'https://plot.ly') - + if jframes: if auto_play: print(plotdivid) From c068faa288dc80c857e0a1020b6772876ced81a5 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Wed, 10 Apr 2019 08:39:55 -0400 Subject: [PATCH 3/4] Revert chunked_request changes --- .../chunked_requests/chunked_request.py | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/plotly/plotly/chunked_requests/chunked_request.py b/plotly/plotly/chunked_requests/chunked_request.py index eb70488f59..4f8d325edb 100644 --- a/plotly/plotly/chunked_requests/chunked_request.py +++ b/plotly/plotly/chunked_requests/chunked_request.py @@ -4,7 +4,9 @@ import ssl from six.moves import http_client -from six.moves.urllib.parse import urlparse +from six.moves.urllib.parse import urlparse, unquote + +from plotly.api import utils class Stream: @@ -25,7 +27,7 @@ def __init__(self, server, port=80, headers={}, url='/', ssl_enabled=False, self._ssl_verification_enabled = ssl_verification_enabled self._connect() - def write(self, data, reconnect_on=('', 200, )): + def write(self, data, reconnect_on=('', 200, 502)): ''' Send `data` to the server in chunk-encoded form. Check the connection before writing and reconnect if disconnected and if the response status code is in `reconnect_on`. @@ -86,21 +88,34 @@ def _get_proxy_config(self): proxy_server = None proxy_port = None + proxy_username = None + proxy_password = None + proxy_auth = None ssl_enabled = self._ssl_enabled if ssl_enabled: - proxy = os.environ.get("https_proxy") + proxy = (os.environ.get("https_proxy") or + os.environ.get("HTTPS_PROXY")) else: - proxy = os.environ.get("http_proxy") - no_proxy = os.environ.get("no_proxy") + proxy = (os.environ.get("http_proxy") or + os.environ.get("HTTP_PROXY")) + + no_proxy = os.environ.get("no_proxy") or os.environ.get("NO_PROXY") no_proxy_url = no_proxy and self._server in no_proxy if proxy and not no_proxy_url: p = urlparse(proxy) proxy_server = p.hostname proxy_port = p.port + proxy_username = p.username + proxy_password = p.password + + if proxy_username and proxy_password: + username = unquote(proxy_username) + password = unquote(proxy_password) + proxy_auth = utils.basic_auth(username, password) - return proxy_server, proxy_port + return proxy_server, proxy_port, proxy_auth def _get_ssl_context(self): """ @@ -123,7 +138,7 @@ def _connect(self): port = self._port headers = self._headers ssl_enabled = self._ssl_enabled - proxy_server, proxy_port = self._get_proxy_config() + proxy_server, proxy_port, proxy_auth = self._get_proxy_config() if (proxy_server and proxy_port): if ssl_enabled: @@ -135,7 +150,12 @@ def _connect(self): self._conn = http_client.HTTPConnection( proxy_server, proxy_port ) - self._conn.set_tunnel(server, port) + + tunnel_headers = None + if proxy_auth: + tunnel_headers = {'Proxy-Authorization': proxy_auth} + + self._conn.set_tunnel(server, port, headers=tunnel_headers) else: if ssl_enabled: context = self._get_ssl_context() From cf88d9e379e18fe959102a5816864cdc1dfc48aa Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Wed, 10 Apr 2019 09:43:08 -0400 Subject: [PATCH 4/4] Update animation_opts support for new renderers framework --- plotly/io/_base_renderers.py | 45 ++++++++++++++----- plotly/io/_html.py | 26 +++++++++-- plotly/offline/offline.py | 29 ++++++++---- .../test_core/test_offline/test_offline.py | 23 +++++++++- 4 files changed, 98 insertions(+), 25 deletions(-) diff --git a/plotly/io/_base_renderers.py b/plotly/io/_base_renderers.py index 5f82d098f4..b8a684feb0 100644 --- a/plotly/io/_base_renderers.py +++ b/plotly/io/_base_renderers.py @@ -229,7 +229,8 @@ def __init__(self, global_init=False, config=None, auto_play=False, - post_script=None): + post_script=None, + animation_opts=None): self.config = dict(config) if config else {} self.auto_play = auto_play @@ -238,6 +239,7 @@ def __init__(self, self.requirejs = requirejs self.full_html = full_html self.post_script = post_script + self.animation_opts = animation_opts def activate(self): if self.global_init: @@ -315,7 +317,8 @@ def to_mimebundle(self, fig_dict): include_plotlyjs=include_plotlyjs, include_mathjax=include_mathjax, post_script=self.post_script, - full_html=self.full_html) + full_html=self.full_html, + animation_opts=self.animation_opts) return {'text/html': html} @@ -336,7 +339,8 @@ def __init__(self, connected=False, config=None, auto_play=False, - post_script=None): + post_script=None, + animation_opts=None): super(NotebookRenderer, self).__init__( connected=connected, full_html=False, @@ -344,7 +348,8 @@ def __init__(self, global_init=True, config=config, auto_play=auto_play, - post_script=post_script) + post_script=post_script, + animation_opts=animation_opts) class KaggleRenderer(HtmlRenderer): @@ -358,7 +363,12 @@ class KaggleRenderer(HtmlRenderer): mime type: 'text/html' """ - def __init__(self, config=None, auto_play=False, post_script=None): + def __init__(self, + config=None, + auto_play=False, + post_script=None, + animation_opts=None): + super(KaggleRenderer, self).__init__( connected=True, full_html=False, @@ -366,7 +376,8 @@ def __init__(self, config=None, auto_play=False, post_script=None): global_init=True, config=config, auto_play=auto_play, - post_script=post_script) + post_script=post_script, + animation_opts=animation_opts) class ColabRenderer(HtmlRenderer): @@ -377,7 +388,12 @@ class ColabRenderer(HtmlRenderer): mime type: 'text/html' """ - def __init__(self, config=None, auto_play=False, post_script=None): + def __init__(self, + config=None, + auto_play=False, + post_script=None, + animation_opts=None): + super(ColabRenderer, self).__init__( connected=True, full_html=True, @@ -385,7 +401,8 @@ def __init__(self, config=None, auto_play=False, post_script=None): global_init=False, config=config, auto_play=auto_play, - post_script=post_script) + post_script=post_script, + animation_opts=animation_opts) class IFrameRenderer(MimetypeRenderer): @@ -414,11 +431,13 @@ class IFrameRenderer(MimetypeRenderer): def __init__(self, config=None, auto_play=False, - post_script=None): + post_script=None, + animation_opts=None): self.config = config self.auto_play = auto_play self.post_script = post_script + self.animation_opts = animation_opts def to_mimebundle(self, fig_dict): from plotly.io import write_html @@ -457,6 +476,7 @@ def to_mimebundle(self, fig_dict): include_mathjax='cdn', auto_open=False, post_script=self.post_script, + animation_opts=self.animation_opts, validate=False) # Build IFrame @@ -547,7 +567,8 @@ def __init__(self, using=None, new=0, autoraise=True, - post_script=None): + post_script=None, + animation_opts=None): self.config = config self.auto_play = auto_play @@ -555,6 +576,7 @@ def __init__(self, self.new = new self.autoraise = autoraise self.post_script = post_script + self.animation_opts = animation_opts def render(self, fig_dict): renderer = HtmlRenderer( @@ -564,7 +586,8 @@ def render(self, fig_dict): global_init=False, config=self.config, auto_play=self.auto_play, - post_script=self.post_script) + post_script=self.post_script, + animation_opts=self.animation_opts) bundle = renderer.to_mimebundle(fig_dict) html = bundle['text/html'] diff --git a/plotly/io/_html.py b/plotly/io/_html.py index f70d269f7f..62596ae98f 100644 --- a/plotly/io/_html.py +++ b/plotly/io/_html.py @@ -30,6 +30,7 @@ def to_html(fig, include_mathjax=False, post_script=None, full_html=True, + animation_opts=None, validate=True): """ Convert a figure to an HTML string representation. @@ -102,6 +103,12 @@ def to_html(fig, If True, produce a string containing a complete HTML document starting with an tag. If False, produce a string containing a single
element. + animation_opts: dict or None (default None) + dict of custom animation parameters to be passed to the function + Plotly.animate in Plotly.js. See + https://github.com/plotly/plotly.js/blob/master/src/plots/animation_attributes.js + for available options. Has no effect if the figure does not contain + frames, or auto_play is False. validate: bool (default True) True if the figure should be validated before being converted to JSON, False otherwise. @@ -164,9 +171,14 @@ def to_html(fig, }})""".format(id=plotdivid, frames=jframes) if auto_play: + if animation_opts: + animation_opts_arg = ', ' + json.dumps(animation_opts) + else: + animation_opts_arg = '' then_animate = """.then(function(){{ - Plotly.animate('{id}'); - }})""".format(id=plotdivid) + Plotly.animate('{id}', null{animation_opts}); + }})""".format(id=plotdivid, + animation_opts=animation_opts_arg) script = """ if (document.getElementById("{id}")) {{ @@ -299,6 +311,7 @@ def write_html(fig, include_mathjax=False, post_script=None, full_html=True, + animation_opts=None, validate=True, auto_open=False): """ @@ -390,6 +403,12 @@ def write_html(fig, If True, produce a string containing a complete HTML document starting with an tag. If False, produce a string containing a single
element. + animation_opts: dict or None (default None) + dict of custom animation parameters to be passed to the function + Plotly.animate in Plotly.js. See + https://github.com/plotly/plotly.js/blob/master/src/plots/animation_attributes.js + for available options. Has no effect if the figure does not contain + frames, or auto_play is False. validate: bool (default True) True if the figure should be validated before being converted to JSON, False otherwise. @@ -411,7 +430,8 @@ def write_html(fig, include_mathjax=include_mathjax, post_script=post_script, full_html=full_html, - validate=validate) + validate=validate, + animation_opts=animation_opts) # Check if file is a string file_is_str = isinstance(file, six.string_types) diff --git a/plotly/offline/offline.py b/plotly/offline/offline.py index a74bc662ce..94a7fd07a2 100644 --- a/plotly/offline/offline.py +++ b/plotly/offline/offline.py @@ -329,9 +329,12 @@ def iplot(figure_or_data, show_link=False, link_text='Export to plot.ly', auto_play (default=True) -- Whether to automatically start the animation sequence if the figure contains frames. Has no effect if the figure does not contain frames. - animation_opts (default=None) -- Custom animation parameters to be passed - to the function Plotly.animate in Plotly.js in the form str(dict) - + animation_opts (default=None) -- dict of custom animation parameters to be + passed to the function Plotly.animate in Plotly.js. See + https://github.com/plotly/plotly.js/blob/master/src/plots/animation_attributes.js + for available options. Has no effect if the figure + does not contain frames, or auto_play is False. + Example: ``` from plotly.offline import init_notebook_mode, iplot @@ -341,7 +344,7 @@ def iplot(figure_or_data, show_link=False, link_text='Export to plot.ly', format you want. e.g. `image='png'` iplot([{'x': [1, 2, 3], 'y': [5, 2, 7]}], image='png') ``` - + animation_opts Example: ``` from plotly.offline import iplot @@ -377,14 +380,16 @@ def iplot(figure_or_data, show_link=False, link_text='Export to plot.ly', validate=validate, config=config, auto_play=auto_play, - post_script=post_script) + post_script=post_script, + animation_opts=animation_opts) def plot(figure_or_data, show_link=False, link_text='Export to plot.ly', validate=True, output_type='file', include_plotlyjs=True, filename='temp-plot.html', auto_open=True, image=None, image_filename='plot_image', image_width=800, image_height=600, - config=None, include_mathjax=False, auto_play=True, animation_opts=None): + config=None, include_mathjax=False, auto_play=True, + animation_opts=None): """ Create a plotly graph locally as an HTML document or string. Example: @@ -497,8 +502,12 @@ def plot(figure_or_data, show_link=False, link_text='Export to plot.ly', auto_play (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. - animation_opts (default=None) -- Custom animation parameters to be passed - to the function Plotly.animate in Plotly.js in the form str(dict) + animation_opts (default=None) -- dict of custom animation parameters to be + passed to the function Plotly.animate in Plotly.js. See + https://github.com/plotly/plotly.js/blob/master/src/plots/animation_attributes.js + for available options. Has no effect if the figure does not contain + frames, or auto_play is False. + Example: ``` from plotly.offline import plot @@ -553,6 +562,7 @@ def plot(figure_or_data, show_link=False, link_text='Export to plot.ly', post_script=post_script, full_html=True, validate=validate, + animation_opts=animation_opts, auto_open=auto_open) return filename else: @@ -564,7 +574,8 @@ def plot(figure_or_data, show_link=False, link_text='Export to plot.ly', include_mathjax=include_mathjax, post_script=post_script, full_html=False, - validate=validate) + validate=validate, + animation_opts=animation_opts) 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 da1a93e21a..b7d543a6b3 100644 --- a/plotly/tests/test_core/test_offline/test_offline.py +++ b/plotly/tests/test_core/test_offline/test_offline.py @@ -418,9 +418,28 @@ def test_auto_play(self): def test_no_auto_play(self): html = plotly.offline.plot( - fig_frames, output_type='div',auto_play=False) + fig_frames, output_type='div', auto_play=False) + self.assertIn(add_frames, html) + self.assertNotIn(do_auto_play, html) + + def test_animation_opts(self): + animation_opts = {'frame': {'duration': 5000}} + expected_opts_str = json.dumps(animation_opts) + + # When auto_play is False, animation options are skipped + html = plotly.offline.plot( + fig_frames, output_type='div', auto_play=False, animation_opts=animation_opts) self.assertIn(add_frames, html) self.assertNotIn(do_auto_play, html) + self.assertNotIn(expected_opts_str, html) + + # When auto_play is True, animation options are included + html = plotly.offline.plot( + fig_frames, output_type='div', auto_play=True, + animation_opts=animation_opts) + self.assertIn(add_frames, html) + self.assertIn(do_auto_play, html) + self.assertIn(expected_opts_str, html) def test_download_image(self): # Not download image by default @@ -431,4 +450,4 @@ def test_download_image(self): # 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 + self.assertIn(download_image, html)