1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
|
# type: ignore
import asyncio
import io
import inspect
import logging
import os
import queue
import uuid
import sys
import threading
import time
from typing import Optional
from typing_extensions import Literal
from werkzeug.serving import make_server
try:
from IPython import get_ipython
from IPython.display import IFrame, display, Javascript
from IPython.core.display import HTML
from IPython.core.ultratb import FormattedTB
from retrying import retry
from ipykernel.comm import Comm
import nest_asyncio
import requests
_dash_comm = Comm(target_name="dash")
_dep_installed = True
except ImportError:
_dep_installed = False
_dash_comm = None
get_ipython = lambda: None
JupyterDisplayMode = Literal["inline", "external", "jupyterlab", "tab", "_none"]
def _get_skip(error: Exception):
from dash._callback import ( # pylint: disable=import-outside-toplevel
_invoke_callback,
)
tb = error.__traceback__
skip = 1
while tb.tb_next is not None:
skip += 1
tb = tb.tb_next
if tb.tb_frame.f_code is _invoke_callback.__code__:
return skip
return skip
def _custom_formatargvalues(
args,
varargs,
varkw,
locals, # pylint: disable=W0622
formatarg=str,
formatvarargs=lambda name: "*" + name,
formatvarkw=lambda name: "**" + name,
formatvalue=lambda value: "=" + repr(value),
):
"""Copied from inspect.formatargvalues, modified to place function
arguments on separate lines"""
# pylint: disable=W0622
def convert(name, locals=locals, formatarg=formatarg, formatvalue=formatvalue):
return formatarg(name) + formatvalue(locals[name])
specs = []
# pylint: disable=C0200
for i in range(len(args)):
specs.append(convert(args[i]))
if varargs:
specs.append(formatvarargs(varargs) + formatvalue(locals[varargs]))
if varkw:
specs.append(formatvarkw(varkw) + formatvalue(locals[varkw]))
result = "(" + ", ".join(specs) + ")"
if len(result) < 40:
return result
# Put each arg on a separate line
return "(\n " + ",\n ".join(specs) + "\n)"
_jupyter_config = {}
_caller = {}
def _send_jupyter_config_comm_request():
# If running in an ipython kernel,
# request that the front end extension send us the notebook server base URL
if get_ipython() is not None:
if _dash_comm.kernel is not None:
_caller["parent"] = _dash_comm.kernel.get_parent()
_dash_comm.send({"type": "base_url_request"})
def _jupyter_comm_response_received():
return bool(_jupyter_config)
def _request_jupyter_config(timeout=2):
# Heavily inspired by implementation of CaptureExecution in the
if _dash_comm.kernel is None:
# Not in jupyter setting
return
_send_jupyter_config_comm_request()
# Get shell and kernel
shell = get_ipython()
kernel = shell.kernel
# Start capturing shell events to replay later
captured_events = []
def capture_event(stream, ident, parent):
captured_events.append((stream, ident, parent))
kernel.shell_handlers["execute_request"] = capture_event
# increment execution count to avoid collision error
shell.execution_count += 1
# Allow kernel to execute comms until we receive the jupyter configuration comm
# response
t0 = time.time()
while True:
if (time.time() - t0) > timeout:
# give up
raise EnvironmentError(
"Unable to communicate with the jupyter_dash notebook or JupyterLab \n"
"extension required to infer Jupyter configuration."
)
if _jupyter_comm_response_received():
break
if asyncio.iscoroutinefunction(kernel.do_one_iteration):
loop = asyncio.get_event_loop()
nest_asyncio.apply(loop)
loop.run_until_complete(kernel.do_one_iteration())
else:
kernel.do_one_iteration()
# Stop capturing events, revert the kernel shell handler to the default
# execute_request behavior
kernel.shell_handlers["execute_request"] = kernel.execute_request
# Replay captured events
# need to flush before replaying so messages show up in current cell not
# replay cells
sys.stdout.flush()
sys.stderr.flush()
for stream, ident, parent in captured_events:
# Using kernel.set_parent is the key to getting the output of the replayed
# events to show up in the cells that were captured instead of the current cell
kernel.set_parent(ident, parent)
kernel.execute_request(stream, ident, parent)
class JupyterDash:
"""
Interact with dash apps inside jupyter notebooks.
"""
default_mode: JupyterDisplayMode = "inline"
alive_token = str(uuid.uuid4())
inline_exceptions: bool = True
_servers = {}
def infer_jupyter_proxy_config(self):
"""
Infer the current Jupyter server configuration. This will detect
the proper request_pathname_prefix and server_url values to use when
displaying Dash apps.Dash requests will be routed through the proxy.
Requirements:
In the classic notebook, this method requires the `dash` nbextension
which should be installed automatically with the installation of the
jupyter-dash Python package. You can see what notebook extensions are installed
by running the following command:
$ jupyter nbextension list
In JupyterLab, this method requires the `@plotly/dash-jupyterlab` labextension. This
extension should be installed automatically with the installation of the
jupyter-dash Python package, but JupyterLab must be allowed to rebuild before
the extension is activated (JupyterLab should automatically detect the
extension and produce a popup dialog asking for permission to rebuild). You can
see what JupyterLab extensions are installed by running the following command:
$ jupyter labextension list
"""
if not self.in_ipython or self.in_colab:
# No op when not running in a Jupyter context or when in Colab
return
# Assume classic notebook or JupyterLab
_request_jupyter_config()
def __init__(self):
self.in_ipython = get_ipython() is not None
self.in_colab = "google.colab" in sys.modules
if _dep_installed and self.in_ipython and _dash_comm:
@_dash_comm.on_msg
def _receive_message(msg):
prev_parent = _caller.get("parent")
if prev_parent and prev_parent != _dash_comm.kernel.get_parent():
_dash_comm.kernel.set_parent(
[prev_parent["header"]["session"]], prev_parent
)
del _caller["parent"]
msg_data = msg.get("content").get("data")
msg_type = msg_data.get("type", None)
if msg_type == "base_url_response":
_jupyter_config.update(msg_data)
# pylint: disable=too-many-locals, too-many-branches, too-many-statements
def run_app(
self,
app,
mode: Optional[JupyterDisplayMode] = None,
width="100%",
height=650,
host="127.0.0.1",
port=8050,
server_url=None,
):
"""
:type app: dash.Dash
:param mode: How to display the app on the notebook. One Of:
``"external"``: The URL of the app will be displayed in the notebook
output cell. Clicking this URL will open the app in the default
web browser.
``"inline"``: The app will be displayed inline in the notebook output cell
in an iframe.
``"jupyterlab"``: The app will be displayed in a dedicate tab in the
JupyterLab interface. Requires JupyterLab and the `jupyterlab-dash`
extension.
:param width: Width of app when displayed using mode="inline"
:param height: Height of app when displayed using mode="inline"
:param host: Host of the server
:param port: Port used by the server
:param server_url: Use if a custom url is required to display the app.
"""
# Validate / infer display mode
if self.in_colab:
valid_display_values = ["inline", "external"]
else:
valid_display_values = ["jupyterlab", "inline", "external", "tab", "_none"]
if mode is None:
mode = self.default_mode
elif not isinstance(mode, str):
raise ValueError(
f"The mode argument must be a string\n"
f" Received value of type {type(mode)}: {repr(mode)}"
)
else:
mode = mode.lower() # type: ignore
if mode not in valid_display_values:
raise ValueError(
f"Invalid display argument {mode}\n"
f" Valid arguments: {valid_display_values}"
)
# Terminate any existing server using this port
old_server = self._servers.get((host, port))
if old_server:
old_server.shutdown()
del self._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 = "/"
routes_pathname_prefix = app.config.get("routes_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)
@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()
self._servers[(host, port)] = server
# Wait for server to start up
alive_url = f"http://{host}:{port}{routes_pathname_prefix}_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."
)
except requests.ConnectionError as err:
_get_error()
raise err
try:
wait_for_app()
if self.in_colab:
JupyterDash._display_in_colab(dashboard_url, port, mode, width, height)
else:
JupyterDash._display_in_jupyter(
dashboard_url, port, mode, width, height
)
except Exception as final_error: # pylint: disable=broad-except
msg = str(final_error)
if msg.startswith("<!"):
display(HTML(msg))
else:
raise final_error
@staticmethod
def _display_in_colab(dashboard_url, port, mode, width, height):
# noinspection PyUnresolvedReferences
from google.colab import output # pylint: disable=E0401,E0611,C0415
if mode == "inline":
output.serve_kernel_port_as_iframe(port, width=width, height=height)
elif mode == "external":
# FIXME there is a 403 on this, maybe it's updated?
# Display a hyperlink that can be clicked to open Dashboard
print("Dash app running on:")
output.serve_kernel_port_as_window(port, anchor_text=dashboard_url)
@staticmethod
def _display_in_jupyter(dashboard_url, port, mode, width, height):
if mode == "inline":
display(IFrame(dashboard_url, width, height))
elif mode in ("external", "tab"):
# Display a hyperlink that can be clicked to open Dashboard
print(f"Dash app running on {dashboard_url}")
if mode == "tab":
display(Javascript(f"window.open('{dashboard_url}')"))
elif mode == "jupyterlab":
# Update front-end extension
# FIXME valid only in jupyterlab but accepted in regular notebooks show nothing.
_dash_comm.send(
{
"type": "show",
"port": port,
"url": dashboard_url,
}
)
@staticmethod
def serve_alive():
return "Alive"
def configure_callback_exception_handling(self, app, dev_tools_prune_errors):
"""Install traceback handling for callbacks"""
@app.server.errorhandler(Exception)
def _wrap_errors(error):
# Compute number of stack frames to skip to get down to callback
skip = _get_skip(error) if dev_tools_prune_errors else 0
# Customized formatargvalues function we can place function parameters
# on separate lines
original_formatargvalues = inspect.formatargvalues
inspect.formatargvalues = _custom_formatargvalues
try:
# Use IPython traceback formatting to build the traceback string.
ostream = io.StringIO()
ipytb = FormattedTB(
tb_offset=skip,
mode="Verbose",
color_scheme="NoColor",
include_vars=True,
ostream=ostream,
)
ipytb()
finally:
# Restore formatargvalues
inspect.formatargvalues = original_formatargvalues
stacktrace = ostream.getvalue()
if self.inline_exceptions:
print(stacktrace)
return stacktrace, 500
@property
def active(self):
_inside_dbx = "DATABRICKS_RUNTIME_VERSION" in os.environ
return _dep_installed and not _inside_dbx and (self.in_ipython or self.in_colab)
jupyter_dash = JupyterDash()
|