Skip to content

jupyter_dash_persistent_inline_output

JupyterDashPersistentInlineOutput

Extension of the JupyterDash class to support the custom inline output for FigureResampler figures.

Specifically we embed a div in the notebook to display the figure inline.

  • In this div the figure is shown as an iframe when the server (of the dash app) is alive.
  • In this div the figure is shown as an image when the server (of the dash app) is dead.

As the HTML & javascript code is embedded in the notebook output, which is loaded each time you open the notebook, the figure is always displayed (either as iframe or just an image). Hence, this extension enables to maintain always an output in the notebook.

.. Note:: This subclass is only used when the mode is set to "inline_persistent" in the :func:FigureResampler.show_dash <plotly_resampler.figure_resampler.FigureResampler.show_dash> method. However, the mode should be passed as "inline" since this subclass overwrites the inline behavior.

.. Note:: This subclass utilizes the optional flask_cors package to detect whether the server is alive or not.

Source code in plotly_resampler/figure_resampler/jupyter_dash_persistent_inline_output.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
class JupyterDashPersistentInlineOutput:
    """Extension of the JupyterDash class to support the custom inline output for
    ``FigureResampler`` figures.

    Specifically we embed a div in the notebook to display the figure inline.

     - In this div the figure is shown as an iframe when the server (of the dash app)
       is alive.
     - In this div the figure is shown as an image when the server (of the dash app)
       is dead.

    As the HTML & javascript code is embedded in the notebook output, which is loaded
    each time you open the notebook, the figure is always displayed (either as iframe
    or just an image).
    Hence, this extension enables to maintain always an output in the notebook.

    .. Note::
        This subclass is only used when the mode is set to ``"inline_persistent"`` in
        the :func:`FigureResampler.show_dash <plotly_resampler.figure_resampler.FigureResampler.show_dash>`
        method. However, the mode should be passed as ``"inline"`` since this subclass
        overwrites the inline behavior.

    .. Note::
        This subclass utilizes the optional ``flask_cors`` package to detect whether the
        server is alive or not.

    """

    def __init__(self, fig: Figure) -> None:
        super().__init__()
        self.fig = fig

        # The unique id of this app
        # This id is used to couple the output in the notebook with this app
        # A fetch request is performed to the _is_alive_{uid} endpoint to check if the
        # app is still alive.
        self.uid = str(uuid.uuid4())

    def _display_inline_output(self, dashboard_url, width, height):
        """Display the dash app persistent inline in the notebook.

        The figure is displayed as an iframe in the notebook if the server is reachable,
        otherwise as an image.
        """
        # TODO: check whether an error gets logged in case of crash
        # TODO: add option to opt out of this
        from IPython.display import display

        try:
            import flask_cors  # noqa: F401
        except (ImportError, ModuleNotFoundError):
            warnings.warn(
                "'flask_cors' is not installed. The persistent inline output will "
                + " not be able to detect whether the server is alive or not."
            )

        # Get the image from the dashboard and encode it as base64
        fig = self.fig  # is stored in the show_dash method
        f_width = 1000 if fig.layout.width is None else fig.layout.width
        fig_base64 = base64.b64encode(
            fig.to_image(format="png", width=f_width, scale=1, height=fig.layout.height)
        ).decode("utf8")

        # The html (& javascript) code to display the app / figure
        uid = self.uid
        display(
            {
                "text/html": f"""
                <div id='PR_div__{uid}'></div>
                <script type='text/javascript'>
                """
                + """

                function setOutput(timeout) {
                    """
                +
                # Variables should be in local scope (in the closure)
                f"""
                    var pr_div = document.getElementById('PR_div__{uid}');
                    var url = '{dashboard_url}';
                    var pr_img_src = 'data:image/png;base64, {fig_base64}';
                    var is_alive_suffix = '_is_alive_{uid}';
                    """
                + """

                    if (pr_div.firstChild) return  // return if already loaded

                    const controller = new AbortController();
                    const signal = controller.signal;

                    return fetch(url + is_alive_suffix, {method: 'GET', signal: signal})
                        .then(response => response.text())
                        .then(data =>
                            {
                                if (data == "Alive") {
                                    console.log("Server is alive");
                                    iframeOutput(pr_div, url);
                                } else {
                                    // I think this case will never occur because of CORS
                                    console.log("Server is dead");
                                    imageOutput(pr_div, pr_img_src);
                                }
                            }
                        )
                        .catch(error => {
                            console.log("Server is unreachable");
                            imageOutput(pr_div, pr_img_src);
                        })
                }

                setOutput(350);

                function imageOutput(element, pr_img_src) {
                    console.log('Setting image');
                    var pr_img = document.createElement("img");
                    pr_img.setAttribute("src", pr_img_src)
                    pr_img.setAttribute("alt", 'Server unreachable - using image instead');
                    """
                + f"""
                    pr_img.setAttribute("max-width", '{width}');
                    pr_img.setAttribute("max-height", '{height}');
                    pr_img.setAttribute("width", 'auto');
                    pr_img.setAttribute("height", 'auto');
                    """
                + """
                    element.appendChild(pr_img);
                }

                function iframeOutput(element, url) {
                    console.log('Setting iframe');
                    var pr_iframe = document.createElement("iframe");
                    pr_iframe.setAttribute("src", url);
                    pr_iframe.setAttribute("frameborder", '0');
                    pr_iframe.setAttribute("allowfullscreen", '');
                    """
                + f"""
                    pr_iframe.setAttribute("width", '{width}');
                    pr_iframe.setAttribute("height", '{height}');
                    """
                + """
                    element.appendChild(pr_iframe);
                }
                </script>
                """
            },
            raw=True,
            clear=True,
            display_id=uid,
        )

    # NOTE: Minimally adatped version from dash._jupyter.JupyterDash.run_server
    def run_app(
        self,
        app,
        width="100%",
        height=650,
        host="127.0.0.1",
        port=8050,
        server_url=None,
    ):
        """Run the inline persistent dash app in the notebook.

        Parameters
        ----------
        app : dash.Dash
            A Dash application instance
        width : str, optional
            Width of app when displayed using mode="inline", by default "100%"
        height : int, optional
            Height of app when displayed using mode="inline", by default 650
        host : str, optional
            Host of the server, by default "127.0.0.1"
        port : int, optional
            Port used by the server, by default 8050
        server_url : str, optional
            Use if a custom url is required to display the app, by default None

        """
        # Terminate any existing server using this port
        old_server = JupyterDash._servers.get((host, port))
        if old_server:
            old_server.shutdown()
            del JupyterDash._servers[(host, port)]

        # Configure pathname prefix
        if "base_subpath" in _jupyter_config:
            requests_pathname_prefix = (
                _jupyter_config["base_subpath"].rstrip("/") + "/proxy/{port}/"
            )
        else:
            requests_pathname_prefix = app.config.get("requests_pathname_prefix", None)

        if requests_pathname_prefix is not None:
            requests_pathname_prefix = requests_pathname_prefix.format(port=port)
        else:
            requests_pathname_prefix = "/"

        # FIXME Move config initialization to main dash __init__
        # low-level setter to circumvent Dash's config locking
        # normally it's unsafe to alter requests_pathname_prefix this late, but
        # Jupyter needs some unusual behavior.
        dict.__setitem__(
            app.config, "requests_pathname_prefix", requests_pathname_prefix
        )

        # # Compute server_url url
        if server_url is None:
            if "server_url" in _jupyter_config:
                server_url = _jupyter_config["server_url"].rstrip("/")
            else:
                domain_base = os.environ.get("DASH_DOMAIN_BASE", None)
                if domain_base:
                    # Dash Enterprise sets DASH_DOMAIN_BASE environment variable
                    server_url = "https://" + domain_base
                else:
                    server_url = f"http://{host}:{port}"
        else:
            server_url = server_url.rstrip("/")

        # server_url = "http://{host}:{port}".format(host=host, port=port)

        dashboard_url = f"{server_url}{requests_pathname_prefix}"

        # prevent partial import of orjson when it's installed and mode=jupyterlab
        # TODO: why do we need this? Why only in this mode? Importing here in
        # all modes anyway, in case there's a way it can pop up in another mode
        try:
            # pylint: disable=C0415,W0611
            import orjson  # noqa: F401
        except ImportError:
            pass

        err_q = queue.Queue()

        server = make_server(host, port, app.server, threaded=True, processes=0)
        logging.getLogger("werkzeug").setLevel(logging.ERROR)

        # ---------------------------------------------------------------------
        # NOTE: added this code to mimic the _alive_{token} endpoint but with cors
        with contextlib.suppress(ImportWarning, ModuleNotFoundError):
            from flask_cors import cross_origin

            @app.server.route(f"/_is_alive_{self.uid}", methods=["GET"])
            @cross_origin(origins=["*"], allow_headers=["Content-Type"])
            def broadcast_alive():
                return "Alive"

        # ---------------------------------------------------------------------

        @retry(
            stop_max_attempt_number=15,
            wait_exponential_multiplier=100,
            wait_exponential_max=1000,
        )
        def run():
            try:
                server.serve_forever()
            except SystemExit:
                pass
            except Exception as error:
                err_q.put(error)
                raise error

        thread = threading.Thread(target=run)
        thread.daemon = True
        thread.start()

        JupyterDash._servers[(host, port)] = server

        # Wait for server to start up
        alive_url = f"http://{host}:{port}/_alive_{JupyterDash.alive_token}"

        def _get_error():
            try:
                err = err_q.get_nowait()
                if err:
                    raise err
            except queue.Empty:
                pass

        # Wait for app to respond to _alive endpoint
        @retry(
            stop_max_attempt_number=15,
            wait_exponential_multiplier=10,
            wait_exponential_max=1000,
        )
        def wait_for_app():
            _get_error()
            try:
                req = requests.get(alive_url)
                res = req.content.decode()
                if req.status_code != 200:
                    raise Exception(res)

                if res != "Alive":
                    url = f"http://{host}:{port}"
                    raise OSError(
                        f"Address '{url}' already in use.\n"
                        "    Try passing a different port to run_server."
                    )
            except requests.ConnectionError as err:
                _get_error()
                raise err

        try:
            wait_for_app()
            self._display_inline_output(dashboard_url, width=width, height=height)

        except Exception as final_error:  # pylint: disable=broad-except
            msg = str(final_error)
            if msg.startswith("<!"):
                display(HTML(msg))
            else:
                raise final_error

run_app(app, width='100%', height=650, host='127.0.0.1', port=8050, server_url=None)

Run the inline persistent dash app in the notebook.

Parameters:

Name Type Description Default
app Dash

A Dash application instance

required
width str

Width of app when displayed using mode=”inline”, by default “100%”

'100%'
height int

Height of app when displayed using mode=”inline”, by default 650

650
host str

Host of the server, by default “127.0.0.1”

'127.0.0.1'
port int

Port used by the server, by default 8050

8050
server_url str

Use if a custom url is required to display the app, by default None

None
Source code in plotly_resampler/figure_resampler/jupyter_dash_persistent_inline_output.py
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
def run_app(
    self,
    app,
    width="100%",
    height=650,
    host="127.0.0.1",
    port=8050,
    server_url=None,
):
    """Run the inline persistent dash app in the notebook.

    Parameters
    ----------
    app : dash.Dash
        A Dash application instance
    width : str, optional
        Width of app when displayed using mode="inline", by default "100%"
    height : int, optional
        Height of app when displayed using mode="inline", by default 650
    host : str, optional
        Host of the server, by default "127.0.0.1"
    port : int, optional
        Port used by the server, by default 8050
    server_url : str, optional
        Use if a custom url is required to display the app, by default None

    """
    # Terminate any existing server using this port
    old_server = JupyterDash._servers.get((host, port))
    if old_server:
        old_server.shutdown()
        del JupyterDash._servers[(host, port)]

    # Configure pathname prefix
    if "base_subpath" in _jupyter_config:
        requests_pathname_prefix = (
            _jupyter_config["base_subpath"].rstrip("/") + "/proxy/{port}/"
        )
    else:
        requests_pathname_prefix = app.config.get("requests_pathname_prefix", None)

    if requests_pathname_prefix is not None:
        requests_pathname_prefix = requests_pathname_prefix.format(port=port)
    else:
        requests_pathname_prefix = "/"

    # FIXME Move config initialization to main dash __init__
    # low-level setter to circumvent Dash's config locking
    # normally it's unsafe to alter requests_pathname_prefix this late, but
    # Jupyter needs some unusual behavior.
    dict.__setitem__(
        app.config, "requests_pathname_prefix", requests_pathname_prefix
    )

    # # Compute server_url url
    if server_url is None:
        if "server_url" in _jupyter_config:
            server_url = _jupyter_config["server_url"].rstrip("/")
        else:
            domain_base = os.environ.get("DASH_DOMAIN_BASE", None)
            if domain_base:
                # Dash Enterprise sets DASH_DOMAIN_BASE environment variable
                server_url = "https://" + domain_base
            else:
                server_url = f"http://{host}:{port}"
    else:
        server_url = server_url.rstrip("/")

    # server_url = "http://{host}:{port}".format(host=host, port=port)

    dashboard_url = f"{server_url}{requests_pathname_prefix}"

    # prevent partial import of orjson when it's installed and mode=jupyterlab
    # TODO: why do we need this? Why only in this mode? Importing here in
    # all modes anyway, in case there's a way it can pop up in another mode
    try:
        # pylint: disable=C0415,W0611
        import orjson  # noqa: F401
    except ImportError:
        pass

    err_q = queue.Queue()

    server = make_server(host, port, app.server, threaded=True, processes=0)
    logging.getLogger("werkzeug").setLevel(logging.ERROR)

    # ---------------------------------------------------------------------
    # NOTE: added this code to mimic the _alive_{token} endpoint but with cors
    with contextlib.suppress(ImportWarning, ModuleNotFoundError):
        from flask_cors import cross_origin

        @app.server.route(f"/_is_alive_{self.uid}", methods=["GET"])
        @cross_origin(origins=["*"], allow_headers=["Content-Type"])
        def broadcast_alive():
            return "Alive"

    # ---------------------------------------------------------------------

    @retry(
        stop_max_attempt_number=15,
        wait_exponential_multiplier=100,
        wait_exponential_max=1000,
    )
    def run():
        try:
            server.serve_forever()
        except SystemExit:
            pass
        except Exception as error:
            err_q.put(error)
            raise error

    thread = threading.Thread(target=run)
    thread.daemon = True
    thread.start()

    JupyterDash._servers[(host, port)] = server

    # Wait for server to start up
    alive_url = f"http://{host}:{port}/_alive_{JupyterDash.alive_token}"

    def _get_error():
        try:
            err = err_q.get_nowait()
            if err:
                raise err
        except queue.Empty:
            pass

    # Wait for app to respond to _alive endpoint
    @retry(
        stop_max_attempt_number=15,
        wait_exponential_multiplier=10,
        wait_exponential_max=1000,
    )
    def wait_for_app():
        _get_error()
        try:
            req = requests.get(alive_url)
            res = req.content.decode()
            if req.status_code != 200:
                raise Exception(res)

            if res != "Alive":
                url = f"http://{host}:{port}"
                raise OSError(
                    f"Address '{url}' already in use.\n"
                    "    Try passing a different port to run_server."
                )
        except requests.ConnectionError as err:
            _get_error()
            raise err

    try:
        wait_for_app()
        self._display_inline_output(dashboard_url, width=width, height=height)

    except Exception as final_error:  # pylint: disable=broad-except
        msg = str(final_error)
        if msg.startswith("<!"):
            display(HTML(msg))
        else:
            raise final_error