diff --git a/scaffolds/app_engine/.gcloudignore b/scaffolds/app_engine/.gcloudignore new file mode 100644 index 00000000..b30f57d9 --- /dev/null +++ b/scaffolds/app_engine/.gcloudignore @@ -0,0 +1,19 @@ +# This file specifies files that are *not* uploaded to Google Cloud +# using gcloud. It follows the same syntax as .gitignore, with the addition of +# "#!include" directives (which insert the entries of the given .gitignore-style +# file at that point). +# +# For more information, run: +# $ gcloud topic gcloudignore +# +.gcloudignore +# If you would like to upload your .git directory, .gitignore file or files +# from your .gitignore file, remove the corresponding line +# below: +.git +.gitignore + +# Python pycache: +__pycache__/ +# Ignored by the build system +/setup.cfg diff --git a/scaffolds/app_engine/Makefile b/scaffolds/app_engine/Makefile new file mode 100644 index 00000000..1d08efd5 --- /dev/null +++ b/scaffolds/app_engine/Makefile @@ -0,0 +1,31 @@ +all: clean venv + +.PHONY: clean +clean: + @find . -name '*.pyc' -delete + @find . -name '__pycache__' -delete + @find . -name '*egg-info' -type d -exec rm -r {} + + @find . -name '.pytest_cache' -type d -exec rm -r {} + + @rm -rf venv + +.PHONY: venv +venv: + @python3 -m venv venv + @. venv/bin/activate && pip install -U pip && pip install -e . + +.PHONY: auth +auth: + gcloud auth application-default login + +.PHONY: tests +tests: + ./scripts/run_tests.sh + +.PHONY: deploy +deploy: clean + ./scripts/create_app.sh + ./scripts/deploy.sh + +.PHONY: run +run: + ./scripts/run_locally.sh diff --git a/scaffolds/app_engine/README.md b/scaffolds/app_engine/README.md new file mode 100644 index 00000000..6034b68b --- /dev/null +++ b/scaffolds/app_engine/README.md @@ -0,0 +1,50 @@ +# Development Workflow + +## Local development + +The workflow described here works from CloudShell or any node with the [gcloud CLI](https://cloud.google.com/sdk/docs/install) has been properly installed and authenticated. + +This means that you can develop your application fully locally on your laptop for example, as long as you have run `make auth` after installing the [gcloud CLI](https://cloud.google.com/sdk/docs/install) on it. + +The first step is to add your `PROJECT` and `BUCKET` names in the following files: +* `./scripts/config.sh` +* `app.yaml` + +For local development, install then the gcloud CLI following [these instructions](https://cloud.google.com/sdk/docs/install). + +Make sure to accept upgrading Python to 3.10 if prompted, then authenticate for local development by running: + +```bash +make auth +``` + +The second step is to create and populate the virtual environment with + +```bash +make venv +``` +After this step you should find a new folder called `venv` containing the virtual environment. + +At this point you should already be able to run the tests by running +```bash +make tests +``` + +To run the app locally, simply run +```bash +make run +``` + +At last to deploy the application on AppEngine run +```bash +make deploy +``` + +**Note:** `make clean` will remove all the built artifacts as long as the virtual environment created by `make venv`. This target is invoked by `make deploy` so that the built artifacts are not uploaded to AppEngine. The down-side is that the virtual environment will need to be recreated after each deployment. + +## Development workflow + +1. Edit the code +1. Run the tests with `make tests` +1. Test the app local with `make run` +1. Deploy the app on AppEngine with `make deploy` diff --git a/scaffolds/app_engine/app.yaml b/scaffolds/app_engine/app.yaml new file mode 100644 index 00000000..7ae895f5 --- /dev/null +++ b/scaffolds/app_engine/app.yaml @@ -0,0 +1,10 @@ +runtime: python310 +entrypoint: gunicorn -b :$PORT app.server:app + +runtime_config: + operating_system: "ubuntu22" + +env_variables: + BUCKET: "" + LOCATION: "us-central1" + PROJECT: "" diff --git a/scaffolds/app_engine/app/__init__.py b/scaffolds/app_engine/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/scaffolds/app_engine/app/server.py b/scaffolds/app_engine/app/server.py new file mode 100644 index 00000000..2de65092 --- /dev/null +++ b/scaffolds/app_engine/app/server.py @@ -0,0 +1,35 @@ +""" Flask serving API and small demo UI. +""" + +import logging + +from flask import Flask, jsonify, request, send_file + +app = Flask(__name__) + + +@app.route("/") +def _index(): + """Serve index.html in the static directory""" + return send_file("static/index.html") + + +@app.route("/myapp", methods=["GET"]) +def _answernaut(): + return jsonify({"answer": request.args["query"]}) + + +@app.errorhandler(500) +def _server_error(e): + """Serves a formatted message on-error""" + logging.exception("An error occurred during a request.") + return ( + f"An internal error occurred:
{e}

", + 500, + ) + + +if __name__ == "__main__": + # This is used when running locally. Gunicorn is used to run the + # application on Google App Engine. See entrypoint in app.yaml. + app.run(host="127.0.0.1", port=8080, debug=True) diff --git a/scaffolds/app_engine/app/static/asl.png b/scaffolds/app_engine/app/static/asl.png new file mode 100644 index 00000000..02a0b901 Binary files /dev/null and b/scaffolds/app_engine/app/static/asl.png differ diff --git a/scaffolds/app_engine/app/static/index.html b/scaffolds/app_engine/app/static/index.html new file mode 100644 index 00000000..d2b93656 --- /dev/null +++ b/scaffolds/app_engine/app/static/index.html @@ -0,0 +1,30 @@ + + + + + + + + + +
+ + + +
+ + +
+ +
+
+ +
+ + + + + + diff --git a/scaffolds/app_engine/app/static/main.css b/scaffolds/app_engine/app/static/main.css new file mode 100644 index 00000000..7efa8f8e --- /dev/null +++ b/scaffolds/app_engine/app/static/main.css @@ -0,0 +1,161 @@ + +*, ::after, ::before { + box-sizing: border-box; + border-width: 0; + border-style: solid; + border-color: #e5e7eb; + font-size: 1rem; + font-family: ui-sans-serif, system-ui, -apple-system, "system-ui", "Segoe UI"; +} + +.body { + padding: 4rem; + justify-content: center; + display: flex; + flex-direction: column; + align-items: center; + width: 100vw; + height: 100vh; +} + +.logo { + margin-bottom: 2.5rem; +} + +.form { + display: flex; + flex-direction: column; + align-items: center; + width: 50%; + margin-bottom: 3rem; +} + +.input { + padding-left: 0.5rem; + padding-right: 0.5rem; + border-width: 1px; + border-radius: 0.5rem; + width: 100%; + height: 2rem; +} + +.ask { + padding-top: 0.25rem; + padding-bottom: 0.25rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + border-width: 1px; + border-color: rgb(191 219 254); + border-radius: 0.5rem; + margin-left: 0.75rem; + margin-top: 2rem; + width: 4rem; + background-color: rgb(219 234 254); +} + +button.ask:hover { + background-color: rgb(3 105 161); +} + +.answer { + display: flex; + padding: 1.25rem; + background-color: rgb(219 234 254); + border-color: rgb(191 219 254); + border-radius: 0.5rem; + width: 50%; + line-height: 1.4rem; + display: none; + overflow-y: auto; +} + +.hide { + display: none !important; +} + +.show { + display: block !important; +} + + +/* Spinner */ + +.lds-spinner { + color: official; + display: inline-block; + position: relative; + width: 80px; + height: 80px; + display: none; +} +.lds-spinner div { + transform-origin: 40px 40px; + animation: lds-spinner 1.2s linear infinite; +} +.lds-spinner div:after { + content: " "; + display: block; + position: absolute; + top: 3px; + left: 37px; + width: 6px; + height: 18px; + border-radius: 20%; + background: rgb(66,132,243); +} +.lds-spinner div:nth-child(1) { + transform: rotate(0deg); + animation-delay: -1.1s; +} +.lds-spinner div:nth-child(2) { + transform: rotate(30deg); + animation-delay: -1s; +} +.lds-spinner div:nth-child(3) { + transform: rotate(60deg); + animation-delay: -0.9s; +} +.lds-spinner div:nth-child(4) { + transform: rotate(90deg); + animation-delay: -0.8s; +} +.lds-spinner div:nth-child(5) { + transform: rotate(120deg); + animation-delay: -0.7s; +} +.lds-spinner div:nth-child(6) { + transform: rotate(150deg); + animation-delay: -0.6s; +} +.lds-spinner div:nth-child(7) { + transform: rotate(180deg); + animation-delay: -0.5s; +} +.lds-spinner div:nth-child(8) { + transform: rotate(210deg); + animation-delay: -0.4s; +} +.lds-spinner div:nth-child(9) { + transform: rotate(240deg); + animation-delay: -0.3s; +} +.lds-spinner div:nth-child(10) { + transform: rotate(270deg); + animation-delay: -0.2s; +} +.lds-spinner div:nth-child(11) { + transform: rotate(300deg); + animation-delay: -0.1s; +} +.lds-spinner div:nth-child(12) { + transform: rotate(330deg); + animation-delay: 0s; +} +@keyframes lds-spinner { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +} diff --git a/scaffolds/app_engine/app/static/main.js b/scaffolds/app_engine/app/static/main.js new file mode 100644 index 00000000..7161f879 --- /dev/null +++ b/scaffolds/app_engine/app/static/main.js @@ -0,0 +1,51 @@ +async function queryApi(prompt) { + const endpoint = `/myapp?query=${prompt}` + const response = await fetch(endpoint) + const answer = await response.json() + return answer +} + +function displayAnswer(answer) { + const queryAnswer = document.getElementById("query-answer") + queryAnswer.innerHTML = ` ${answer} ` +} + +function getPrompt() { + const queryInput = document.getElementById("query-input") + return queryInput.value +} + +function getAnswer() { + const spinner = document.getElementById("spinner") + const queryAnswer = document.getElementById("query-answer") + queryAnswer.classList.remove("show") + spinner.classList.add("show") + const prompt = getPrompt() + queryApi(prompt).then( + response => { + spinner.classList.remove("show") + queryAnswer.classList.add("show") + displayAnswer(response.answer) + } + ) + +} + +function init() { + const queryForm = document.getElementById("query-form") + queryForm.addEventListener("submit", e => { + e.preventDefault() + getAnswer() + }) + + const queryInput = document.getElementById("query-input") + queryInput.addEventListener("keyup", e => { + e.preventDefault() + if (e.KeyCode === 13){ + document.getElementById("ask-button").click() + } + }) + + } + +init() diff --git a/scaffolds/app_engine/requirements.txt b/scaffolds/app_engine/requirements.txt new file mode 100644 index 00000000..dfa62aca --- /dev/null +++ b/scaffolds/app_engine/requirements.txt @@ -0,0 +1,4 @@ +Flask==3.0.0 +gunicorn==20.1.0 +pre-commit==3.7.1 +pytest==7.0.1 diff --git a/scaffolds/app_engine/scripts/config.sh b/scaffolds/app_engine/scripts/config.sh new file mode 100644 index 00000000..802bd7d6 --- /dev/null +++ b/scaffolds/app_engine/scripts/config.sh @@ -0,0 +1,9 @@ +PROJECT="" +LOCATION="us-central1" +BUCKET="" + +# Compute various paths from the ROOT_DIR +ROOT_DIR="$(dirname $(cd $(dirname $BASH_SOURCE) && pwd))" +SCRIPTS_DIR="$ROOT_DIR/scripts" +APP_YAML="$ROOT_DIR/app.yaml" +VENV="$ROOT_DIR/venv" diff --git a/scaffolds/app_engine/scripts/create_app.sh b/scaffolds/app_engine/scripts/create_app.sh new file mode 100755 index 00000000..0e5435ba --- /dev/null +++ b/scaffolds/app_engine/scripts/create_app.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +. $(cd $(dirname $BASH_SOURCE) && pwd)/config.sh + +gcloud app describe --project=$PROJECT 2> /dev/null || { + echo "No App on AppEngine existing. Creating one." + gcloud app create --region=$LOCATION --project=$PROJECT +} diff --git a/scaffolds/app_engine/scripts/deploy.sh b/scaffolds/app_engine/scripts/deploy.sh new file mode 100755 index 00000000..2cbed92d --- /dev/null +++ b/scaffolds/app_engine/scripts/deploy.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +. $(cd $(dirname $BASH_SOURCE) && pwd)/config.sh + +gcloud app deploy $APP_YAML --project=$PROJECT +gcloud app browse --project=$PROJECT diff --git a/scaffolds/app_engine/scripts/run_locally.sh b/scaffolds/app_engine/scripts/run_locally.sh new file mode 100755 index 00000000..fa9bf62c --- /dev/null +++ b/scaffolds/app_engine/scripts/run_locally.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +. $(cd $(dirname $BASH_SOURCE) && pwd)/config.sh + + +export BUCKET=$BUCKET +export LOCATION=$LOCATION +export PROJECT=$PROJECT + +gcloud config set project $PROJECT +. $VENV/bin/activate && python -m app.server diff --git a/scaffolds/app_engine/scripts/run_tests.sh b/scaffolds/app_engine/scripts/run_tests.sh new file mode 100755 index 00000000..eaa0e9ef --- /dev/null +++ b/scaffolds/app_engine/scripts/run_tests.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +. $(cd $(dirname $BASH_SOURCE) && pwd)/config.sh + + +export BUCKET=$BUCKET +export LOCATION=$LOCATION +export PROJECT=$PROJECT + +gcloud config set project $PROJECT +. $VENV/bin/activate && pytest "$ROOT_DIR/tests" diff --git a/scaffolds/app_engine/setup.py b/scaffolds/app_engine/setup.py new file mode 100644 index 00000000..e731f76f --- /dev/null +++ b/scaffolds/app_engine/setup.py @@ -0,0 +1,15 @@ +""" Setup for pip install -e . +""" + +from setuptools import find_packages, setup + +REQS = None +with open("requirements.txt", encoding="utf-8") as fp: + REQS = [req.strip() for req in fp.readlines()] + +setup( + name="app", + version="0.1", + packages=find_packages(), + install_requires=REQS, +) diff --git a/scaffolds/app_engine/tests/server_test.py b/scaffolds/app_engine/tests/server_test.py new file mode 100644 index 00000000..3c7f7213 --- /dev/null +++ b/scaffolds/app_engine/tests/server_test.py @@ -0,0 +1,12 @@ +""" Integration tests for server.py +""" + +from app import server + + +def test_query(): + server.app.testing = True + client = server.app.test_client() + + r = client.get("/myapp?query=if+I+were+a+unicorn") + assert r.status_code == 200