From 2af996ad649e59cb64b55123d0e29c13bae0ebc0 Mon Sep 17 00:00:00 2001 From: Justin Ellingwood Date: Fri, 16 Aug 2024 16:19:42 +0000 Subject: [PATCH] Initial commit --- .github/workflows/autocommit.yaml | 28 +++++ .github/workflows/cleanup.yaml | 20 +++ .github/workflows/deploy.yaml | 30 +++++ .gitignore | 162 ++++++++++++++++++++++++ README.md | 66 ++++++++++ app.py | 202 ++++++++++++++++++++++++++++++ requirements.txt | 6 + shared.py | 38 ++++++ 8 files changed, 552 insertions(+) create mode 100644 .github/workflows/autocommit.yaml create mode 100644 .github/workflows/cleanup.yaml create mode 100644 .github/workflows/deploy.yaml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 requirements.txt create mode 100644 shared.py diff --git a/.github/workflows/autocommit.yaml b/.github/workflows/autocommit.yaml new file mode 100644 index 0000000..474e65c --- /dev/null +++ b/.github/workflows/autocommit.yaml @@ -0,0 +1,28 @@ +name: Keep Repository Active + +on: + schedule: + - cron: '0 0 */28 * *' + +jobs: + create-empty-commit: + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: main + + - name: Configure Git + run: | + git config user.name 'Automatic action' + git config user.email 'noreply@koyeb.com' + + - name: Create an empty commit and push + run: | + git commit --allow-empty -m "Automated empty commit to keep repo active" -m "This commit is generated automatically every few weeks to prevent GitHub Actions from being disabled due to inactivity." + git push origin main diff --git a/.github/workflows/cleanup.yaml b/.github/workflows/cleanup.yaml new file mode 100644 index 0000000..491abb4 --- /dev/null +++ b/.github/workflows/cleanup.yaml @@ -0,0 +1,20 @@ +name: Cleanup Koyeb application + +on: + delete: + branches: + - '*' + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - name: Install and configure the Koyeb CLI + uses: koyeb-community/koyeb-actions@v2 + with: + api_token: "${{ secrets.KOYEB_EXAMPLES_APPS_TOKEN }}" + + - name: Cleanup Koyeb application + uses: koyeb/action-git-deploy/cleanup@v1 + with: + app-name: shiny-python-${{ github.event.ref }} diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..f33a1ed --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,30 @@ +name: Build and deploy the application to Koyeb + +on: + schedule: + - cron: '35 9 * * *' + push: + branches: + - '*' + +jobs: + deploy: + concurrency: + group: "${{ github.ref_name }}" + cancel-in-progress: true + runs-on: ubuntu-latest + steps: + - name: Install and configure the Koyeb CLI + uses: koyeb-community/koyeb-actions@v2 + with: + api_token: "${{ secrets.KOYEB_EXAMPLES_APPS_TOKEN }}" + + - name: Build and deploy the application + uses: koyeb/action-git-deploy@v1 + with: + app-name: shiny-python-${{ github.ref_name }} + service-ports: "8000:http" + service-routes: "/:8000" + git-builder: buildpack + git-run-command: "shiny run --host 0.0.0.0 --port 8000 app.py" + skip-cache: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..82f9275 --- /dev/null +++ b/.gitignore @@ -0,0 +1,162 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..86d0206 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +[![example-shiny-python](https://github.com/koyeb/example-shiny-python/actions/workflows/deploy.yaml/badge.svg)](https://github.com/koyeb/example-shiny-python/actions) + +
+ + Logo + +

Koyeb Serverless Platform

+

+ Deploy a Shiny application in Python on Koyeb +
+ Learn more about Koyeb + · + Explore the documentation + · + Discover our tutorials +

+
+ + +## About Koyeb and the Shiny Python example application + +Koyeb is a developer-friendly serverless platform to deploy apps globally. No-ops, servers, or infrastructure management. This repository contains a Shiny application written in Python that you can deploy on Koyeb in a single click. + +This example application is designed to show how web application built with Shiny for Python can be built and deployed on Koyeb. The application is created from the [Shiny map distance template](https://shiny.posit.co/py/templates/map-distance/), allowing you to deploy an interactive, data-driven application to demonstrate Shiny functionality. + +## Getting Started + +Follow the steps below to deploy and run the Shiny for Python application on your Koyeb account. + +### Requirements + +You need a Koyeb account to successfully deploy and run this application. If you don't already have an account, you can sign-up for free [here](https://app.koyeb.com/auth/signup). + +### Deploy using the Koyeb button + +The fastest way to deploy the Shiny for Python application is to click the **Deploy to Koyeb** button below. + +[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?name=shiny-python&type=git&repository=koyeb%2Fexample-shiny-python&branch=main&builder=buildpack&run_command=shiny+run+--host+0.0.0.0+--port+8000+app.py&instance_type=micro&env%5B%5D=&ports=8000%3Bhttp%3B%2F) + +Clicking on this button brings you to the Koyeb App creation page with everything pre-set to launch this application. + +_To modify this application example, you will need to fork this repository. Checkout the [fork and deploy](#fork-and-deploy-to-koyeb) instructions._ + +## Fork and deploy to Koyeb + +If you want to customize and enhance this application, you need to fork this repository. + +If you used the **Deploy to Koyeb** button, you can simply link your service to your forked repository to be able to push changes. Alternatively, you can manually create the application as described below. + +On the [Koyeb Control Panel](https://app.koyeb.com/), on the **Overview** tab, click the **Create Web Service** button to begin. + +1. Select **GitHub** as the deployment method. +2. In the repositories list, select the repository you just forked. +3. Select your preferred region and Instance type. +4. Open the **Builder** section. Click the **Override** toggle associated with the **Run command** and enter `shiny run --host 0.0.0.0 --port 8000 app.py` in the field. +6. Choose a name for your Service, i.e `shiny-python`, and click **Deploy**. + +You will be taken to the deployment page where you can follow the build of your Shiny for Python application. Once the build is completed, your application will be deployed and you will be able to access it via `-.koyeb.app`. + +## Contributing + +If you have any questions, ideas or suggestions regarding this application sample, feel free to open an [issue](https://github.com/koyeb/example-shiny-python/issues) or fork this repository and open a [pull request](https://github.com/koyeb/example-shiny-python/pulls). + +## Contact + +[Koyeb](https://www.koyeb.com) - [@gokoyeb](https://twitter.com/gokoyeb) - [Slack](http://slack.koyeb.com/) diff --git a/app.py b/app.py new file mode 100644 index 0000000..1e184d5 --- /dev/null +++ b/app.py @@ -0,0 +1,202 @@ +import ipyleaflet as L +from faicons import icon_svg +from geopy.distance import geodesic, great_circle +from shared import BASEMAPS, CITIES +from shiny import reactive +from shiny.express import input, render, ui +from shinywidgets import render_widget + +city_names = sorted(list(CITIES.keys())) + +ui.page_opts(title="Location Distance Calculator", fillable=True) +{"class": "bslib-page-dashboard"} + +with ui.sidebar(): + ui.input_selectize("loc1", "Location 1", choices=city_names, selected="New York") + ui.input_selectize("loc2", "Location 2", choices=city_names, selected="London") + ui.input_selectize( + "basemap", + "Choose a basemap", + choices=list(BASEMAPS.keys()), + selected="WorldImagery", + ) + ui.input_dark_mode(mode="dark") + +with ui.layout_column_wrap(fill=False): + with ui.value_box(showcase=icon_svg("globe"), theme="gradient-blue-indigo"): + "Great Circle Distance" + + @render.text + def great_circle_dist(): + circle = great_circle(loc1xy(), loc2xy()) + return f"{circle.kilometers.__round__(1)} km" + + with ui.value_box(showcase=icon_svg("ruler"), theme="gradient-blue-indigo"): + "Geodisic Distance" + + @render.text + def geo_dist(): + dist = geodesic(loc1xy(), loc2xy()) + return f"{dist.kilometers.__round__(1)} km" + + with ui.value_box(showcase=icon_svg("mountain"), theme="gradient-blue-indigo"): + "Altitude Difference" + + @render.text + def altitude(): + try: + return f'{loc1()["altitude"] - loc2()["altitude"]} m' + except TypeError: + return "N/A (altitude lookup failed)" + + +with ui.card(): + ui.card_header("Map (drag the markers to change locations)") + + @render_widget + def map(): + return L.Map(zoom=4, center=(0, 0)) + + +# Reactive values to store location information +loc1 = reactive.value() +loc2 = reactive.value() + + +# Update the reactive values when the selectize inputs change +@reactive.effect +def _(): + loc1.set(CITIES.get(input.loc1(), loc_str_to_coords(input.loc1()))) + loc2.set(CITIES.get(input.loc2(), loc_str_to_coords(input.loc2()))) + + +# When a marker is moved, the input value gets updated to "lat, lon", +# so we decode that into a dict (and also look up the altitude) +def loc_str_to_coords(x: str) -> dict: + latlon = x.split(", ") + if len(latlon) != 2: + return {} + + lat = float(latlon[0]) + lon = float(latlon[1]) + + try: + import requests + + query = f"https://api.open-elevation.com/api/v1/lookup?locations={lat},{lon}" + r = requests.get(query).json() + altitude = r["results"][0]["elevation"] + except Exception: + altitude = None + + return {"latitude": lat, "longitude": lon, "altitude": altitude} + + +# Convenient way to get the lat/lons as a tuple +@reactive.calc +def loc1xy(): + return loc1()["latitude"], loc1()["longitude"] + + +@reactive.calc +def loc2xy(): + return loc2()["latitude"], loc2()["longitude"] + + +# Add marker for first location +@reactive.effect +def _(): + update_marker(map.widget, loc1xy(), on_move1, "loc1") + + +# Add marker for second location +@reactive.effect +def _(): + update_marker(map.widget, loc2xy(), on_move2, "loc2") + + +# Add line and fit bounds when either marker is moved +@reactive.effect +def _(): + update_line(map.widget, loc1xy(), loc2xy()) + + +# If new bounds fall outside of the current view, fit the bounds +@reactive.effect +def _(): + l1 = loc1xy() + l2 = loc2xy() + + lat_rng = [min(l1[0], l2[0]), max(l1[0], l2[0])] + lon_rng = [min(l1[1], l2[1]), max(l1[1], l2[1])] + new_bounds = [ + [lat_rng[0], lon_rng[0]], + [lat_rng[1], lon_rng[1]], + ] + + b = map.widget.bounds + if len(b) == 0: + map.widget.fit_bounds(new_bounds) + elif ( + lat_rng[0] < b[0][0] + or lat_rng[1] > b[1][0] + or lon_rng[0] < b[0][1] + or lon_rng[1] > b[1][1] + ): + map.widget.fit_bounds(new_bounds) + + +# Update the basemap +@reactive.effect +def _(): + update_basemap(map.widget, input.basemap()) + + +# --------------------------------------------------------------- +# Helper functions +# --------------------------------------------------------------- + + +def update_marker(map: L.Map, loc: tuple, on_move: object, name: str): + remove_layer(map, name) + m = L.Marker(location=loc, draggable=True, name=name) + m.on_move(on_move) + map.add_layer(m) + + +def update_line(map: L.Map, loc1: tuple, loc2: tuple): + remove_layer(map, "line") + map.add_layer( + L.Polyline(locations=[loc1, loc2], color="blue", weight=2, name="line") + ) + + +def update_basemap(map: L.Map, basemap: str): + for layer in map.layers: + if isinstance(layer, L.TileLayer): + map.remove_layer(layer) + map.add_layer(L.basemap_to_tiles(BASEMAPS[input.basemap()])) + + +def remove_layer(map: L.Map, name: str): + for layer in map.layers: + if layer.name == name: + map.remove_layer(layer) + + +def on_move1(**kwargs): + return on_move("loc1", **kwargs) + + +def on_move2(**kwargs): + return on_move("loc2", **kwargs) + + +# When the markers are moved, update the selectize inputs to include the new +# location (which results in the locations() reactive value getting updated, +# which invalidates any downstream reactivity that depends on it) +def on_move(id, **kwargs): + loc = kwargs["location"] + loc_str = f"{loc[0]}, {loc[1]}" + choices = city_names + [loc_str] + ui.update_selectize(id, selected=loc_str, choices=choices) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c93caf9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +shiny +shinywidgets +ipyleaflet +geopy +faicons +requests \ No newline at end of file diff --git a/shared.py b/shared.py new file mode 100644 index 0000000..148e8d8 --- /dev/null +++ b/shared.py @@ -0,0 +1,38 @@ +from ipyleaflet import basemaps + +BASEMAPS = { + "WorldImagery": basemaps.Esri.WorldImagery, + "Mapnik": basemaps.OpenStreetMap.Mapnik, + "Positron": basemaps.CartoDB.Positron, + "DarkMatter": basemaps.CartoDB.DarkMatter, + "NatGeoWorldMap": basemaps.Esri.NatGeoWorldMap, + "France": basemaps.OpenStreetMap.France, + "DE": basemaps.OpenStreetMap.DE, +} + + +CITIES = { + "New York": {"latitude": 40.7128, "longitude": -74.0060, "altitude": 33}, + "London": {"latitude": 51.5074, "longitude": -0.1278, "altitude": 36}, + "Paris": {"latitude": 48.8566, "longitude": 2.3522, "altitude": 35}, + "Tokyo": {"latitude": 35.6895, "longitude": 139.6917, "altitude": 44}, + "Sydney": {"latitude": -33.8688, "longitude": 151.2093, "altitude": 39}, + "Los Angeles": {"latitude": 34.0522, "longitude": -118.2437, "altitude": 71}, + "Berlin": {"latitude": 52.5200, "longitude": 13.4050, "altitude": 34}, + "Rome": {"latitude": 41.9028, "longitude": 12.4964, "altitude": 21}, + "Beijing": {"latitude": 39.9042, "longitude": 116.4074, "altitude": 44}, + "Moscow": {"latitude": 55.7558, "longitude": 37.6176, "altitude": 156}, + "Cairo": {"latitude": 30.0444, "longitude": 31.2357, "altitude": 23}, + "Rio de Janeiro": {"latitude": -22.9068, "longitude": -43.1729, "altitude": 8}, + "Toronto": {"latitude": 43.6511, "longitude": -79.3832, "altitude": 76}, + "Dubai": {"latitude": 25.2769, "longitude": 55.2963, "altitude": 52}, + "Mumbai": {"latitude": 19.0760, "longitude": 72.8777, "altitude": 14}, + "Seoul": {"latitude": 37.5665, "longitude": 126.9780, "altitude": 38}, + "Madrid": {"latitude": 40.4168, "longitude": -3.7038, "altitude": 667}, + "Amsterdam": {"latitude": 52.3676, "longitude": 4.9041, "altitude": -2}, + "Buenos Aires": {"latitude": -34.6037, "longitude": -58.3816, "altitude": 25}, + "Stockholm": {"latitude": 59.3293, "longitude": 18.0686, "altitude": 14}, + "Boulder": {"latitude": 40.0150, "longitude": -105.2705, "altitude": 1634}, + "Lhasa": {"latitude": 29.6500, "longitude": 91.1000, "altitude": 3650}, + "Khatmandu": {"latitude": 27.7172, "longitude": 85.3240, "altitude": 1400}, +}