import asyncio import io import inspect import logging import os import queue import uuid import re import sys import threading import time import traceback 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 ansi2html import Ansi2HTMLConverter 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): tb = traceback.format_exception(type(error), error, error.__traceback__) skip = 0 for i, line in enumerate(tb): if "%% callback invoked %%" in line: skip = i + 1 break 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: 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() 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 = "/" # 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}/_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() 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("", '' ) # Remove explicit background color so Dash dev-tools can set background # color html_str = re.sub("background-color:[^;]+;", "", html_str) return html_str, 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()