Skip to content

Commit b7d3539

Browse files
NielsPraetjonasvdd
andauthored
♻️ deprecate JupyterDash in favor for updated Dash version (#233)
* 📌 build: update Dash version to '^2.11.0' from '^2.2.0' * 🚧 refactor: rewrite show_dash and JupyterDashPersistentInlineOutput with new Dash code * 🚧 fix: use run function from Dash instance instead of JupyterDash * ♻️ refactor: Use Dash for normal modes. Fall back to JupyterDash for inline_persistent mode * 🚸 build: make jupyter-dash an optional dependency * 📝 docs: update documentation in * 📝 docs: add 'flask-cors' dependency to explanation * 🎨 chore: fix linewidth of comment * 💨 formatting * 🎨 refactor: move optional class to separate file * 🎨 chore: format code --------- Co-authored-by: jonasvdd <[email protected]>
1 parent 223eda3 commit b7d3539

File tree

5 files changed

+720
-862
lines changed

5 files changed

+720
-862
lines changed

examples/basic_example.ipynb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -735,7 +735,7 @@
735735
"The example below illustrates this behavior\n",
736736
"\n",
737737
"> **Note**: \n",
738-
"> * you must have `kaleido` installed for this to work.\n",
738+
"> * you must have `kaleido`, `flask-cors` and `jupyter-dash` installed for this to work.\n",
739739
"> * The static output figure will only be shown in environments where javascript code is allowed to execute."
740740
]
741741
},

plotly_resampler/figure_resampler/figure_resampler.py

Lines changed: 45 additions & 184 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,11 @@
1010

1111
__author__ = "Jonas Van Der Donckt, Jeroen Van Der Donckt, Emiel Deprost"
1212

13-
import base64
14-
import contextlib
15-
import uuid
1613
import warnings
1714
from typing import List, Tuple
1815

1916
import dash
2017
import plotly.graph_objects as go
21-
from jupyter_dash import JupyterDash
2218
from plotly.basedatatypes import BaseFigure
2319
from trace_updater import TraceUpdater
2420

@@ -31,174 +27,12 @@
3127
from .figure_resampler_interface import AbstractFigureAggregator
3228
from .utils import is_figure, is_fr
3329

30+
try:
31+
from .jupyter_dash_persistent_inline_output import JupyterDashPersistentInlineOutput
3432

35-
class JupyterDashPersistentInlineOutput(JupyterDash):
36-
"""Extension of the JupyterDash class to support the custom inline output for
37-
``FigureResampler`` figures.
38-
39-
Specifically we embed a div in the notebook to display the figure inline.
40-
41-
- In this div the figure is shown as an iframe when the server (of the dash app)
42-
is alive.
43-
- In this div the figure is shown as an image when the server (of the dash app)
44-
is dead.
45-
46-
As the HTML & javascript code is embedded in the notebook output, which is loaded
47-
each time you open the notebook, the figure is always displayed (either as iframe
48-
or just an image).
49-
Hence, this extension enables to maintain always an output in the notebook.
50-
51-
.. Note::
52-
This subclass is only used when the mode is set to ``"inline_persistent"`` in
53-
the :func:`FigureResampler.show_dash <plotly_resampler.figure_resampler.FigureResampler.show_dash>`
54-
method. However, the mode should be passed as ``"inline"`` since this subclass
55-
overwrites the inline behavior.
56-
57-
.. Note::
58-
This subclass utilizes the optional ``flask_cors`` package to detect whether the
59-
server is alive or not.
60-
61-
"""
62-
63-
def __init__(self, *args, **kwargs):
64-
super().__init__(*args, **kwargs)
65-
66-
self._uid = str(uuid.uuid4()) # A new unique id for each app
67-
68-
with contextlib.suppress(ImportWarning, ModuleNotFoundError):
69-
from flask_cors import cross_origin
70-
71-
# Mimic the _alive_{token} endpoint but with cors
72-
@self.server.route(f"/_is_alive_{self._uid}", methods=["GET"])
73-
@cross_origin(origin=["*"], allow_headers=["Content-Type"])
74-
def broadcast_alive():
75-
return "Alive"
76-
77-
def _display_inline_output(self, dashboard_url, width, height):
78-
"""Display the dash app persistent inline in the notebook.
79-
80-
The figure is displayed as an iframe in the notebook if the server is reachable,
81-
otherwise as an image.
82-
"""
83-
# TODO: check whether an error gets logged in case of crash
84-
# TODO: add option to opt out of this
85-
from IPython.display import display
86-
87-
try:
88-
import flask_cors # noqa: F401
89-
except (ImportError, ModuleNotFoundError):
90-
warnings.warn(
91-
"'flask_cors' is not installed. The persistent inline output will "
92-
+ " not be able to detect whether the server is alive or not."
93-
)
94-
95-
# Get the image from the dashboard and encode it as base64
96-
fig = self.layout.children[0].figure # is stored there in the show_dash method
97-
f_width = 1000 if fig.layout.width is None else fig.layout.width
98-
fig_base64 = base64.b64encode(
99-
fig.to_image(format="png", width=f_width, scale=1, height=fig.layout.height)
100-
).decode("utf8")
101-
102-
# The unique id of this app
103-
# This id is used to couple the output in the notebook with this app
104-
# A fetch request is performed to the _is_alive_{uid} endpoint to check if the
105-
# app is still alive.
106-
uid = self._uid
107-
108-
# The html (& javascript) code to display the app / figure
109-
display(
110-
{
111-
"text/html": f"""
112-
<div id='PR_div__{uid}'></div>
113-
<script type='text/javascript'>
114-
"""
115-
+ """
116-
117-
function setOutput(timeout) {
118-
"""
119-
+
120-
# Variables should be in local scope (in the closure)
121-
f"""
122-
var pr_div = document.getElementById('PR_div__{uid}');
123-
var url = '{dashboard_url}';
124-
var pr_img_src = 'data:image/png;base64, {fig_base64}';
125-
var is_alive_suffix = '_is_alive_{uid}';
126-
"""
127-
+ """
128-
129-
if (pr_div.firstChild) return // return if already loaded
130-
131-
const controller = new AbortController();
132-
const signal = controller.signal;
133-
134-
return fetch(url + is_alive_suffix, {method: 'GET', signal: signal})
135-
.then(response => response.text())
136-
.then(data =>
137-
{
138-
if (data == "Alive") {
139-
console.log("Server is alive");
140-
iframeOutput(pr_div, url);
141-
} else {
142-
// I think this case will never occur because of CORS
143-
console.log("Server is dead");
144-
imageOutput(pr_div, pr_img_src);
145-
}
146-
}
147-
)
148-
.catch(error => {
149-
console.log("Server is unreachable");
150-
imageOutput(pr_div, pr_img_src);
151-
})
152-
}
153-
154-
setOutput(350);
155-
156-
function imageOutput(element, pr_img_src) {
157-
console.log('Setting image');
158-
var pr_img = document.createElement("img");
159-
pr_img.setAttribute("src", pr_img_src)
160-
pr_img.setAttribute("alt", 'Server unreachable - using image instead');
161-
"""
162-
+ f"""
163-
pr_img.setAttribute("max-width", '{width}');
164-
pr_img.setAttribute("max-height", '{height}');
165-
pr_img.setAttribute("width", 'auto');
166-
pr_img.setAttribute("height", 'auto');
167-
"""
168-
+ """
169-
element.appendChild(pr_img);
170-
}
171-
172-
function iframeOutput(element, url) {
173-
console.log('Setting iframe');
174-
var pr_iframe = document.createElement("iframe");
175-
pr_iframe.setAttribute("src", url);
176-
pr_iframe.setAttribute("frameborder", '0');
177-
pr_iframe.setAttribute("allowfullscreen", '');
178-
"""
179-
+ f"""
180-
pr_iframe.setAttribute("width", '{width}');
181-
pr_iframe.setAttribute("height", '{height}');
182-
"""
183-
+ """
184-
element.appendChild(pr_iframe);
185-
}
186-
</script>
187-
"""
188-
},
189-
raw=True,
190-
clear=True,
191-
display_id=uid,
192-
)
193-
194-
def _display_in_jupyter(self, dashboard_url, port, mode, width, height):
195-
"""Override the display method to retain some output when displaying inline
196-
in jupyter.
197-
"""
198-
if mode == "inline":
199-
self._display_inline_output(dashboard_url, width, height)
200-
else:
201-
super()._display_in_jupyter(dashboard_url, port, mode, width, height)
33+
_jupyter_dash_installed = True
34+
except ImportError:
35+
_jupyter_dash_installed = False
20236

20337

20438
class FigureResampler(AbstractFigureAggregator, go.Figure):
@@ -353,9 +187,12 @@ def __init__(
353187
self.data[idx].update(graph_dict["data"][idx])
354188

355189
# The FigureResampler needs a dash app
356-
self._app: JupyterDash | dash.Dash | None = None
190+
self._app: dash.Dash | None = None
357191
self._port: int | None = None
358192
self._host: str | None = None
193+
# Certain functions will be different when using persistent inline
194+
# (namely `show_dash` and `stop_callback`)
195+
self._is_persistent_inline = False
359196

360197
def show_dash(
361198
self,
@@ -439,13 +276,23 @@ def show_dash(
439276

440277
# 1. Construct the Dash app layout
441278
if mode == "inline_persistent":
442-
# Inline persistent mode: we display a static image of the figure when the
443-
# app is not reachable
444-
# Note: this is the "inline" behavior of JupyterDashInlinePersistentOutput
445279
mode = "inline"
446-
app = JupyterDashPersistentInlineOutput("local_app")
280+
if _jupyter_dash_installed:
281+
# Inline persistent mode: we display a static image of the figure when the
282+
# app is not reachable
283+
# Note: this is the "inline" behavior of JupyterDashInlinePersistentOutput
284+
app = JupyterDashPersistentInlineOutput("local_app")
285+
self._is_persistent_inline = True
286+
else:
287+
# If Jupyter Dash is not installed, inline persistent won't work and hence
288+
# we default to normal inline mode with a normal Dash app
289+
app = dash.Dash("local_app")
290+
warnings.warn(
291+
"'jupyter_dash' is not installed. The persistent inline mode will not work. Defaulting to standard inline mode."
292+
)
447293
else:
448-
app = JupyterDash("local_app")
294+
# jupyter dash uses a normal Dash app as figure
295+
app = dash.Dash("local_app")
449296
app.layout = dash.html.Div(
450297
[
451298
dash.dcc.Graph(
@@ -458,13 +305,15 @@ def show_dash(
458305
)
459306
self.register_update_graph_callback(app, "resample-figure", "trace-updater")
460307

308+
height_param = "height" if self._is_persistent_inline else "jupyter_height"
309+
461310
# 2. Run the app
462-
if mode == "inline" and "height" not in kwargs:
311+
if mode == "inline" and height_param not in kwargs:
463312
# If app height is not specified -> re-use figure height for inline dash app
464313
# Note: default layout height is 450 (whereas default app height is 650)
465314
# See: https://plotly.com/python/reference/layout/#layout-height
466315
fig_height = self.layout.height if self.layout.height is not None else 450
467-
kwargs["height"] = fig_height + 18
316+
kwargs[height_param] = fig_height + 18
468317

469318
# kwargs take precedence over the show_dash_kwargs
470319
kwargs = {**self._show_dash_kwargs, **kwargs}
@@ -474,7 +323,11 @@ def show_dash(
474323
self._host = kwargs.get("host", "127.0.0.1")
475324
self._port = kwargs.get("port", "8050")
476325

477-
app.run_server(mode=mode, **kwargs)
326+
# function signature is slightly different for the Dash and JupyterDash implementations
327+
if self._is_persistent_inline:
328+
app.run(mode=mode, **kwargs)
329+
else:
330+
app.run(jupyter_mode=mode, **kwargs)
478331

479332
def stop_server(self, warn: bool = True):
480333
"""Stop the running dash-app.
@@ -488,11 +341,19 @@ def stop_server(self, warn: bool = True):
488341
This only works if the dash-app was started with :func:`show_dash`.
489342
"""
490343
if self._app is not None:
491-
old_server = self._app._server_threads.get((self._host, self._port))
344+
servers_dict = (
345+
self._app._server_threads
346+
if self._is_persistent_inline
347+
else dash.jupyter_dash._servers
348+
)
349+
old_server = servers_dict.get((self._host, self._port))
492350
if old_server:
493-
old_server.kill()
494-
old_server.join()
495-
del self._app._server_threads[(self._host, self._port)]
351+
if self._is_persistent_inline:
352+
old_server.kill()
353+
old_server.join()
354+
else:
355+
old_server.shutdown()
356+
del servers_dict[(self._host, self._port)]
496357
elif warn:
497358
warnings.warn(
498359
"Could not stop the server, either the \n"

0 commit comments

Comments
 (0)