diff --git a/Core/Plotly Dash/.gitignore b/Core/Plotly Dash/.gitignore new file mode 100644 index 0000000..4d84f63 --- /dev/null +++ b/Core/Plotly Dash/.gitignore @@ -0,0 +1,7 @@ +/dev_settings.json +/dev_settings.json.disabled +*.zbundle +__pycache__/ +.DS_Store +*.sqlite +*.sqlite-journal diff --git a/Core/Plotly Dash/Dockerfile b/Core/Plotly Dash/Dockerfile new file mode 100644 index 0000000..385146f --- /dev/null +++ b/Core/Plotly Dash/Dockerfile @@ -0,0 +1,59 @@ +# This is the Dockerfile for the Enthought Edge "Plotly Dash" core example. +# +# We use "edm-centos-7" as the base image. This is a Centos-7 based image +# which includes EDM. +# +# EDM dependencies for the app are brought in via a .zbundle file. This avoids +# the need to pass your EDM token and/or a custom edm.yaml into the Dockerfile. +# +# We perform a two-stage build, to avoid including the .zbundle in the layers +# of the published image. + + +# IMPORTANT: Please do not define any of the EDGE_* environment variables, or +# any of the JUPYTERHUB_* variables, directly in this file. They will be set +# automatically when running in Edge, or by the "ci" module when running +# locally. + +# First stage + +ARG BASE_IMAGE=quay.io/enthought/edm-centos-7:3.4.0 + +FROM $BASE_IMAGE as stage_one + +ARG EDGE_BUNDLE=app_environment.zbundle +COPY $EDGE_BUNDLE /tmp/app_environment.zbundle + +RUN adduser app +USER app +WORKDIR /home/app + +# Create a default EDM environment using the enthought_edge bundle +RUN edm env import -f /tmp/app_environment.zbundle edm && edm cache purge --yes + +# Install any 'pip' dependencies +RUN edm run -- pip install --no-cache-dir Flask-Session + + +# Second stage + +FROM $BASE_IMAGE as stage_two + +RUN adduser app +USER app +WORKDIR /home/app + +COPY --from=stage_one --chown=app /home/app/.edm /home/app/.edm + +# Make any global changes (yum install, e.g.) in the second stage. +# Don't change the user, and in particular don't make the user "root". + +# Copy startup script and application. +# Note: the startup script must be placed in /home/app for the base image +# machinery to pick it up. + +COPY --chown=app ./src/startup-script.sh /home/app/startup-script.sh +RUN chmod +x /home/app/startup-script.sh +COPY --chown=app ./src/app.py /home/app/app.py + +CMD ["/home/app/startup-script.sh"] diff --git a/Core/Plotly Dash/README.md b/Core/Plotly Dash/README.md new file mode 100644 index 0000000..0cd37d0 --- /dev/null +++ b/Core/Plotly Dash/README.md @@ -0,0 +1,177 @@ +# Plotly Dash example + +This example shows how to develop an application for Edge using the Plotly Dash +library. You can read more about Plotly Dash at their official site: +https://plotly.com/dash/. + + +## Before you begin + +Before starting, ensure you have the following installed: + +* [Docker](https://docker.com) +* [EDM](https://www.enthought.com/edm/), the Enthought Deployment Manager + +Finally, ensure your ``edm.yaml`` file lists ``enthought/edge`` as an egg +repository, along with ``enthought/free`` and ``enthought/lgpl``. This will be +necessary to use EdgeSession in the example. + + +## Quick start + +1. Run ``python bootstrap.py``. This will create a development environment + and activate it, dropping you into a development shell. + +2. From within the development shell, build the example by running + ``python -m ci build``. This will produce a Docker image. + +3. Run the Docker image via ``python -m ci run``. The app will serve on + http://0.0.0.0:9000 in a local development mode. + + +## Modifying the example for your use case + +Please note these are the minimal changes necessary to make the example your +own. Depending on your use case you may wish to add additional "ci" commands, +install different items in the Dockerfile, etc. + +* In ``ci/__main__.py``, change the constants IMAGE, VERSION, and + APP_DEPENDENCIES as appropriate. +* In ``bootstrap.py``, consider changing the name of the development + environment (ENV_NAME) to avoid conflicts with other examples. + + +## Publishing your app + +1. Ensure you are logged in (via ``docker login``) to DockerHub, Quay.io, or + another Docker registry. + +2. Run ``python -m ci publish`` to push your app's image to the registry. + +Once you have published your Docker image, you are ready to register that +version of your app with Edge. + +As a quick reminder, in Edge, there is a distinction between an _app_ and an +_app version_. Your organization administrator can define an _app_, and then +developers are free to register _app versions_ associated with that app. This +ensures that org administrators have full control over what shows up on the +dashboard, but gives developers freedom to publish new versions by themselves. + +The easiest way to register a new app version is by logging in to Edge, going +to the gear icon in the upper-right corner, and navigating to the app in +question. There is a form to fill out which asks for the version, Quay.io or +DockerHub URL for the image, and some other information. + +It is also possible to register app versions programmatically, for example +from within a GitHub Actions workflow. See the example at the end of this +README. + + +## Getting EdgeSession to work in development + +When you run your app on Edge, you can create an EdgeSession object simply by +calling the constructor (``mysession = EdgeSession()``). This works by +collecting environment variables set by Edge when the container is launched. + +When developing locally, it's also convenient to have an EdgeSession. You +can get the "ci" module to inject the appropriate environment variables, so +that your ``EdgeSession()`` call will work with ``python -m ci run`` and +``python -m ci preflight``. + +To do so, follow this procedure: + +* Copy the "dev_settings.json.example" file to "dev_settings.json". +* Define EDGE_API_SERVICE_URL in that file. The typical value here is + ``"https://edge.enthought.com/services/api"``. +* Define EDGE_API_ORG in that file. This is the "short name" displayed in + the URL bar when you log into an organization, for example, ``"default"``. +* Define EDGE_API_TOKEN. You can get one of these by going to the + ``/hub/token`` endpoint on the Edge server. + +Be sure *not* to check the "dev_settings.json" file into source control, as it +contains your API token. + + +## Viewing console output + +When running with ``python -m ci run``, the app's output will be displayed +on the console where you launched it. + +## Guidelines for your Dockerfile + +Edge will run your app next to a built-in reverse proxy, which allows +you to skip a lot of work in the development process. This includes stripping +the prefix from requests, handling the OAuth2 login flow, pinging JupyterHub +for container activity, and more. But, there are a few guidelines you will +need to follow in your own Dockerfile. + +* Don't change the user to anything other than ``app`` (for example, by the + Dockerfile ``USER`` command). If you need to run ``yum`` for some reason, + use ``sudo``. +* Your app should bind to ``127.0.0.1``, *not* ``0.0.0.0``, and it should serve + on port 9000. The Edge machinery will respond to requests on port 8888 and + forward them to your app. + + +## Publishing versions from CI (e.g. GitHub Actions) + +You can register your app version programmatically. This is particularly +convenient during the development process, for automated builds. A general +example looks like this, for an app whose ID is ``my-app-id``: + + +``` +from edge.api import EdgeSession +from edge.apps.application import Application +from edge.apps.app_version import AppKindEnum, AppVersion +from edge.apps.server_info import ServerInfo + +# Create an Edge session. +edge = EdgeSession( + service_url="https://edge.enthought.com/services/api", + organization="<YOUR_ORGANIZATION>", + api_token="<YOUR_EDGE_API_TOKEN>" +) + +# Register the application version. + +# Icon is a base64-encoded PNG. +# It should be square, any size; 64x64 or 128x128 are common. +ICON = ( + "iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAk1BMVEUAAA" + "AMfIYAdnwAdnwAd3wAc3kAdntYsb8AdnwAdn0AdXwAdXsAd30Ad3xYsb8A" + "d30AdntYsL8Ad30AdnsAd30AdnwAd3wAd30AdnxYssBYsb5Ysr9Zs70Adn" + "tYsr9Ysb9Ysb9Ysr8AdnxXs75Ysb4AdXsAd3wAdnxYsb8AeoAAfYMAf4Vb" + "t8Zfv84Ad31ixtVevMuy+odXAAAAJ3RSTlMABPr88Shd+4pmkXyDbfB0Xy" + "dWTy0h9TLnkYv+IJmX9uigNzOIf5LOQ2WlAAAC/ElEQVR42sXX7VLiMBiG" + "4aTBDwQVEJSPXdkF3dAi1fM/uoXp7DyzIzHEm7bvT6Dc16SlDQbObG5anf" + "nHx4tpcea7q6sdWQPeXyyIgPc3+7nazQwY3N8sWhLM9uu/+Sd4NmBQvxog" + "YH0g4P2qLMFvAwb0WxE8q9+CwJqX96W6muV7U9fB8NfbsRV4u1ubhubHQf" + "C5PzQNjZXg/741dY8EdxKAPhTQPhfQPhfQPheQPhcM9wLa54KG+jYoCPZt" + "M4Llspn+KlRZr0PvrM7Z/7m9DHVCr19ub87Zd4UEX436hZeA9zPnJTix7z" + "IJcN97CU7tey8B70twev9Mgj+HvgQJ/fMIbqq+BCl9CXhfgpS+BLwvQUpf" + "At6XIN7nAvWTBOpDgfppgm6uPhGonyaw00LHAIH6qYLJNA8KeD8usF8Irk" + "E/SdChAvXbEajfjkD9dgTqtyNQv0XB9aFPBRcRAexzQXo/q1MQ77uyqHEN" + "4v2smPby0qUKHoOC1H7eGZtB4VMF47AgtX+hrU4Ngmj/cWzs+QU2rW/qFc" + "T7lSA/o6BvbEq/+rpRuuApiwvi/doE0X72pA/VJYj36xXE+7UL+sf7Tn0s" + "WPncBQTh51/Z0fV3oiDvBtegU/rQ/eC1OIpz5XRirEkQOFfch/8ulEfNru" + "gZcx8QVI+AuECnoGtM8NEc6N8aY0OC7ESB+qdvDdS33xQ8aH9A+0igfj74" + "Zh8K9BMEfSxwPgd9KKj6o8S+V58KaJ8LXJk/gD4WeM/6XMD6XDCC558LYJ" + "8LYJ8LQB8IYJ8IeD9+eG9LBPH7PxTgPhbQPhfwPhek932gDwTRfnjLyAW0" + "zwW8H5+IAPa5gPa5gPa5gPa5gPa5gPa5gPa5gPa5gPa5APa5gPW5YDJBfS" + "6YTkmfC1xZukb6NiiAfS4AfSAAfSQoHOpzwes2Q30+fQla6VsJQB8LeJ8L" + "wv0B6AMB6AMB6HMB7XMB7XMB7XMB7XMB7XMB7XMB7XMB7XMB7XMB7XMB7H" + "NBjvpccH0L+38B6kvWH2wXIe8AAAAASUVORK5CYII=" +) +version = AppVersion( + app_id="my-app-id", # Specified when the app is created + version="1.0.0", # or whatever version you have + title="My Application Title", + description="This Is An Example Edge Application", + icon=ICON, + kind=AppKindEnum.Native, + link="quay.io/<YOUR_ORGANIZATION>/YOUR_IMAGE_NAME_HERE:TAG", + recommended_profile="edge.medium" +) +edge.applications.add_app_version(version) +``` \ No newline at end of file diff --git a/Core/Plotly Dash/bootstrap.py b/Core/Plotly Dash/bootstrap.py new file mode 100644 index 0000000..9b4ad73 --- /dev/null +++ b/Core/Plotly Dash/bootstrap.py @@ -0,0 +1,70 @@ +# Enthought product code +# +# (C) Copyright 2010-2022 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This file and its contents are confidential information and NOT open source. +# Distribution is prohibited. + +""" + Bootstrap file, which builds the local EDM development environment for this + example. +""" + +import argparse +import subprocess + +ENV_NAME = "edge-plotly-dash-core" +EDM_DEPS = ["click", "pip", "setuptools"] +PIP_DEPS = ["sqlalchemy<2", "dockerspawner"] + + +def bootstrap(ci_mode): + """Create and populate dev env. + + Will automatically activate the environment, unless ci_mode is True. + """ + + if ENV_NAME not in _list_edm_envs(): + print(f"Creating development environment {ENV_NAME}...") + cmd = ["edm", "envs", "create", ENV_NAME, "--version", "3.8", "--force"] + subprocess.run(cmd, check=True) + + cmd = ["edm", "install", "-e", ENV_NAME, "-y"] + EDM_DEPS + subprocess.run(cmd, check=True) + + cmd = ["edm", "run", "-e", ENV_NAME, "--", "pip", "install"] + PIP_DEPS + subprocess.run(cmd, check=True) + + print("Bootstrap complete.") + + else: + print("Environment already exists; reusing.") + + if not ci_mode: + print(f"Activating dev environment {ENV_NAME}") + subprocess.run(["edm", "shell", "-e", ENV_NAME]) + + +def _list_edm_envs(): + cmd = ["edm", "envs", "list"] + proc = subprocess.run( + cmd, check=True, capture_output=True, encoding="utf-8", errors="ignore" + ) + envs = [] + for line in proc.stdout.split("\n"): + parts = line.split() + if len(parts) < 6: + continue + if parts[0] == "*": + envs.append(parts[1]) + else: + envs.append(parts[0]) + return envs + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--ci", action="store_true") + args = parser.parse_args() + bootstrap(args.ci) diff --git a/Core/Plotly Dash/ci/__main__.py b/Core/Plotly Dash/ci/__main__.py new file mode 100644 index 0000000..66c3437 --- /dev/null +++ b/Core/Plotly Dash/ci/__main__.py @@ -0,0 +1,123 @@ +# Enthought product code +# +# (C) Copyright 2010-2022 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This file and its contents are confidential information and NOT open source. +# Distribution is prohibited. + +""" + This is the "ci" module for the Core Plotly Dash example. +""" + +import click +import os.path as op +import subprocess +import sys +import os +import json + +SRC_ROOT = op.abspath(op.join(op.dirname(__file__), "..")) + +# Docker image will be tagged "IMAGE:VERSION" +IMAGE = "quay.io/enthought/edge-plotly-dash-core" +VERSION = "1.1.0" + +# These will go into the built Docker image. You may wish to modify this +# minimal example to pin the dependencies, or use a bundle file to define them. +APP_DEPENDENCIES = [ + "enthought_edge>=2.6.0", + "appdirs", + "packaging", + "pip", + "pyparsing", + "setuptools", + "six", + "requests", + "gunicorn", + "pandas", + "flask>2", + "dash", +] + +# This will be used when running locally ("run" or "preflight" commands). +# We just use the last component of the full image URL. +CONTAINER_NAME = IMAGE.split("/")[-1] + + +@click.group() +def cli(): + """Group for Click commands""" + pass + + +@cli.command() +@click.option("--rebuild-zbundle", default=False, is_flag=True) +def build(rebuild_zbundle): + """Build the Docker image""" + + # First, we build a "zbundle" which contains all the eggs needed to + # build the environment within the Docker image. + fname = "app_environment.zbundle" + if rebuild_zbundle or not op.exists(op.join(SRC_ROOT, fname)): + cmd = [ + "edm", + "bundle", + "generate", + "-i", + "--version", + "3.8", + "--platform", + "rh7-x86_64", + "-m", + "2.0", + "-f", + fname, + ] + APP_DEPENDENCIES + subprocess.run(cmd, check=True, cwd=SRC_ROOT) + + # Second, we run Docker. The Dockerfile will copy the zbundle into + # a temp folder and install it. + cmd = ["docker", "build", "-t", f"{IMAGE}:{VERSION}", "."] + subprocess.run(cmd, check=True, cwd=SRC_ROOT) + + +@cli.command() +def run(): + """Run the Docker image for testing""" + + # Get values from the dev settings file (API tokens for testing, etc.) + envs = _load_dev_settings() + + cmd = ["docker", "run", "--rm", "-p", "9000:9000", "--name", CONTAINER_NAME] + for key, value in envs.items(): + cmd += ["--env", f"{key}={value}"] + cmd += ["--env", "HOST_ADDRESS=0.0.0.0"] + cmd += [f"{IMAGE}:{VERSION}"] + + subprocess.run(cmd, check=True, cwd=SRC_ROOT) + + +@cli.command() +def publish(): + """Publish the Docker image for use with Edge""" + cmd = ["docker", "push", f"{IMAGE}:{VERSION}"] + subprocess.run(cmd, check=True) + + +def _load_dev_settings(): + """Load dev_settings.json file. + + Returns a dict with "EDGE_*" key/value pairs, or an empty dict if the + file doesn't exist. Any other keys are filtered out. + """ + fpath = op.join(SRC_ROOT, "dev_settings.json") + if not op.exists(fpath): + return {} + with open(fpath, "r") as f: + data = json.load(f) + return {k: v for k, v in data.items() if k.startswith("EDGE_")} + + +if __name__ == "__main__": + cli() diff --git a/Core/Plotly Dash/dev_settings.json.example b/Core/Plotly Dash/dev_settings.json.example new file mode 100644 index 0000000..96f38f5 --- /dev/null +++ b/Core/Plotly Dash/dev_settings.json.example @@ -0,0 +1,10 @@ +{ + "?0": "This is an *optional* settings file to make development more convenient.", + "?1": "Make a copy of this file, named 'dev_settings.json', and fill in the values.", + "?2": "This will allow EdgeSession() to work locally for testing/development.", + "?3": "DO NOT CHECK THE dev_settings.json FILE INTO SOURCE CONTROL!", + + "EDGE_API_TOKEN": "<YOUR_API_TOKEN>", + "EDGE_API_ORG": "<ORG_ID>", + "EDGE_API_SERVICE_URL": "https://edge.enthought.com/services/api" +} \ No newline at end of file diff --git a/Core/Plotly Dash/src/app.py b/Core/Plotly Dash/src/app.py new file mode 100644 index 0000000..46b8d68 --- /dev/null +++ b/Core/Plotly Dash/src/app.py @@ -0,0 +1,102 @@ +# Enthought product code +# +# (C) Copyright 2010-2022 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This file and its contents are confidential information and NOT open source. +# Distribution is prohibited. + +""" + Example for using Plotly Dash with Edge. +""" + +import os +import sys +from datetime import datetime, timezone +from functools import wraps + +import pandas as pd +import plotly.express as px +import requests +from dash import Dash, dcc, html +from dash.dependencies import Input, Output +from edge.api import EdgeSession +from flask import Flask + +# Your app will be served by Edge under this URL prefix. +# Please note the value will contain a trailing "/" character. +PREFIX = os.environ.get("JUPYTERHUB_SERVICE_PREFIX", "/") + + +def get_edge_session(): + """Helper function to get an EdgeSession object. + + EdgeSession will auto-load environment variables, including the API token, + location of the Edge server, etc. Please note: + + 1. In production, they are set by Edge itself; you don't have to do anything. + 2. In testing, you can set values in "dev_settings.json" + + Returns an EdgeSession object if one can be constructed, or None if the + required information is missing. + """ + + def is_set(name): + return name in os.environ + + if ( + is_set("EDGE_API_SERVICE_URL") + and is_set("EDGE_API_ORG") + and (is_set("EDGE_API_TOKEN") or is_set("JUPYTERHUB_API_TOKEN")) + ): + return EdgeSession() + + return None + + +# Note: in development, you should run "dash_app" (as in the __main__ block at +# the bottom of this file), but in your Dockerfile, gunicorn should point at +# "flask_app" instead. +flask_app = Flask(__name__) + +dash_app = Dash(server=flask_app, url_base_pathname=PREFIX) + + +df = pd.read_csv( + "https://raw.githubusercontent.com/plotly/datasets/master/gapminder_unfiltered.csv" +) + +edge = get_edge_session() +if edge is not None: + whoami = edge.whoami() + greeting = f"Logged in as {whoami.user_name}" +else: + greeting = "No EdgeSession available. See the README." + +dash_app.layout = html.Div( + [ + html.H1( + children="Population by country", + style={"textAlign": "center"}, + ), + html.Div(children=greeting, style={"paddingBottom": "20px"}), + dcc.Dropdown(df.country.unique(), "Canada", id="dropdown-selection"), + dcc.Graph(id="graph-content"), + ] +) + + +# All callback functions for your UI components go here. +# For example, following is the callback for UI components in the previous +# function. +@dash_app.callback( + Output("graph-content", "figure"), Input("dropdown-selection", "value") +) +def update_graph(value): + dff = df[df.country == value] + return px.line(dff, x="year", y="pop") + + +# Run the app +if __name__ == "__main__": + dash_app.run_server(debug=True) diff --git a/Core/Plotly Dash/src/startup-script.sh b/Core/Plotly Dash/src/startup-script.sh new file mode 100755 index 0000000..9499ca8 --- /dev/null +++ b/Core/Plotly Dash/src/startup-script.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +# Your app must bind to 127.0.0.1 on port 9000 for the Edge proxy to work. +# However, for local docker execution without a proxy, we need to bind to 0.0.0.0 + +if [ -z $HOST_ADDRESS ]; then + # Provide a default if not specified explicitly + export HOST_ADDRESS='127.0.0.1'; +fi + +exec edm run -- gunicorn app:flask_app -b ${HOST_ADDRESS}:9000 --workers 1 diff --git a/Plotly Dash/Dockerfile b/Plotly Dash/Dockerfile index 4e38a58..004e189 100644 --- a/Plotly Dash/Dockerfile +++ b/Plotly Dash/Dockerfile @@ -1,4 +1,4 @@ -# This is the Dockerfile for the Enthought Edge "Streamlit" example. +# This is the Dockerfile for the Enthought Edge "Plotly Dash" example. # # We use "edge-native-base" as the base image. This is a Centos-7 based image # which includes EDM, as well as a small proxy server which automatically