├── .github ├── scripts │ └── check-files.sh └── workflows │ ├── check-files.yml │ ├── deploy.yml │ └── lint.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── Makefile ├── README.md ├── basic-app-plot ├── README.md ├── _template.json ├── app-core.py ├── app-express.py ├── penguins.csv ├── requirements.txt └── shared.py ├── basic-app ├── README.md ├── _template.json ├── app-core.py ├── app-express.py └── requirements.txt ├── basic-navigation ├── README.md ├── _template.json ├── app-core.py ├── app-express.py ├── penguins.csv ├── requirements.txt └── shared.py ├── basic-sidebar ├── README.md ├── _template.json ├── app-core.py ├── app-express.py ├── penguins.csv ├── requirements.txt └── shared.py ├── dashboard-tips ├── README.md ├── _template.json ├── app-core.py ├── app-express.py ├── requirements.txt ├── shared.py ├── styles.css └── tips.csv ├── dashboard ├── README.md ├── _template.json ├── app-core.py ├── app-express.py ├── penguins.csv ├── requirements.txt ├── shared.py └── styles.css ├── database-explorer ├── .gitignore ├── README.md ├── _template.json ├── app-core.py ├── query.py └── requirements.txt ├── deployments.json ├── gen-ai ├── README.md ├── basic-chat │ ├── _template.json │ ├── app-core.py │ ├── app-express.py │ ├── requirements.txt │ └── template.env ├── basic-markdown-stream │ ├── _template.json │ ├── app-core.py │ ├── app-express.py │ ├── requirements.txt │ └── template.env ├── data-sci-adventure │ ├── _template.json │ ├── app-core.py │ ├── app-express.py │ ├── prompt.md │ ├── requirements.txt │ └── template.env ├── dinner-recipe │ ├── _template.json │ ├── app-core.py │ ├── app-express.py │ ├── prompt.md │ ├── requirements.txt │ └── template.env ├── querychat │ ├── _template.json │ ├── app-core.py │ ├── requirements.txt │ └── template.env └── workout-plan │ ├── _template.json │ ├── app-core.py │ ├── app-express.py │ ├── requirements.txt │ └── template.env ├── map-distance ├── README.md ├── _template.json ├── app-core.py ├── app-express.py ├── requirements.txt └── shared.py ├── model-scoring ├── README.md ├── _template.json ├── app-core.py ├── plots.py ├── requirements.txt └── scores.csv ├── monitor-database ├── README.md ├── _template.json ├── app-core.py ├── data │ └── accuracy_scores.sqlite ├── fake_accuracy_scores.csv ├── requirements.txt ├── scoredata.py └── shared.py ├── monitor-file ├── README.md ├── _template.json ├── app-core.py ├── app-express.py ├── logs.csv ├── populate-logs.py ├── requirements.txt └── shared.py ├── monitor-folder ├── README.md ├── _template.json ├── app-core.py ├── app-express.py ├── last_change.txt ├── requirements.txt ├── shared.py ├── watch_folder.py └── watch_folder │ ├── logs-226.csv │ └── logs-372.csv ├── nba-dashboard ├── README.md ├── _template.json ├── app-core.py ├── app-express.py ├── careers.csv ├── careers_all.csv ├── etl.py ├── players.csv ├── plots.py ├── requirements.txt ├── shared.py └── styles.css ├── regularization ├── README.md ├── _template.json ├── app-core.py ├── app-express.py ├── compare.py ├── prose.md ├── requirements.txt └── shared.py ├── requirements-dev.txt ├── requirements.txt ├── stock-app ├── README.md ├── _template.json ├── app-core.py ├── app-express.py ├── requirements.txt ├── stocks.py └── styles.css ├── survey-wizard ├── README.md ├── _template.json ├── app-core.py ├── requirements.txt ├── responses.csv └── styles.css ├── survey ├── README.md ├── _template.json ├── app-core.py ├── app-express.py ├── requirements.txt ├── responses.csv ├── shared.py └── styles.css └── tests ├── conftest.py └── test_shiny_apps.py /.github/scripts/check-files.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 5 | ROOT_DIR="$(dirname $(dirname "$SCRIPT_DIR"))" 6 | 7 | for dir in "$ROOT_DIR"/*/ "$ROOT_DIR"/gen-ai/*/ ; do 8 | [[ "$(basename "$dir")" == "gen-ai" ]] && continue 9 | [[ "$(basename "$dir")" == "tests" ]] && continue 10 | 11 | if [ ! -f "$dir/app-core.py" ]; then 12 | echo "app-core.py must be in each folder, but it's missing in $dir" 13 | exit 1 14 | fi 15 | if [ -f "$dir/app.py" ]; then 16 | echo "app.py should not be in any folder, but it's found in $dir" 17 | exit 1 18 | fi 19 | done 20 | -------------------------------------------------------------------------------- /.github/workflows/check-files.yml: -------------------------------------------------------------------------------- 1 | name: Check Files 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | paths: 9 | - ".github/workflows/check-files.yml" 10 | - "**.py" 11 | 12 | jobs: 13 | check_files: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Check out source repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Check files 20 | shell: bash 21 | run: .github/scripts/check-files.sh 22 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to shinyapps.io 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - deploy** 8 | 9 | jobs: 10 | prepare-matrix: 11 | runs-on: ubuntu-latest 12 | outputs: 13 | matrix: ${{ steps.set-matrix.outputs.matrix }} 14 | steps: 15 | - name: Check out repository 16 | uses: actions/checkout@v4 17 | - name: Generate matrix from deployments.json 18 | id: set-matrix 19 | run: | 20 | MATRIX_JSON=$(jq -c . < deployments.json) 21 | echo "matrix=$MATRIX_JSON" >> $GITHUB_OUTPUT 22 | 23 | deploy: 24 | needs: prepare-matrix 25 | runs-on: ubuntu-latest 26 | 27 | concurrency: 28 | group: deploy-${{ matrix.folder }} 29 | # Wait for prior deployments to finish, otherwise new deployments will be rejected 30 | cancel-in-progress: false 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: ${{ fromJson(needs.prepare-matrix.outputs.matrix) }} 35 | steps: 36 | - name: Check out repository 37 | uses: actions/checkout@v4 38 | - name: Set up Python 39 | uses: actions/setup-python@v5 40 | with: 41 | python-version: "3.12" 42 | 43 | - name: Install rsconnect-python 44 | run: pip install rsconnect-python 45 | 46 | - name: Configure rsconnect-python 47 | env: 48 | TOKEN: ${{ secrets.SHINYAPPS_TOKEN }} 49 | SECRET: ${{ secrets.SHINYAPPS_SECRET }} 50 | run: rsconnect add --account gallery --name gallery --token $TOKEN --secret $SECRET 51 | 52 | - name: Deploy app to shinyapps 53 | if: steps.cache-deployment.outputs.cache-hit != 'true' 54 | run: | 55 | for i in {1..3}; do 56 | rsconnect deploy shiny ${{ matrix.folder }} \ 57 | --name gallery \ 58 | --entrypoint app-core.py \ 59 | --app-id ${{ matrix.guid }} \ 60 | && exit 0 \ 61 | || sleep 15 62 | done 63 | 64 | echo "Deployment failed after 3 attempts" 65 | exit 1 66 | 67 | test-deploys: 68 | name: Test Shiny Template Deployments 69 | needs: deploy 70 | runs-on: ubuntu-latest 71 | steps: 72 | - name: Checkout repository 73 | uses: actions/checkout@v4 74 | - name: Set up Python 75 | uses: actions/setup-python@v5 76 | with: 77 | python-version: "3.11.6" 78 | - name: Install Python dependencies 79 | run: | 80 | python -m pip install --upgrade pip 81 | make install 82 | - name: Install Playwright browsers and OS dependencies 83 | run: make install-playwright 84 | - name: Run Shiny App Error Check 85 | run: make test 86 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Python Lint Check 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | paths: 9 | - ".github/workflows/lint.yml" 10 | - "**.py" 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Check out source repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: "3.10" 23 | 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | make install 28 | 29 | - name: Run checks 30 | run: | 31 | make check 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | .DS_Store 163 | **/__pycache__/ 164 | 165 | _dev/ 166 | rsconnect-python/ 167 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.trimTrailingWhitespace": true, 3 | "files.insertFinalNewline": true, 4 | "editor.tabSize": 2, 5 | "files.encoding": "utf8", 6 | "files.eol": "\n", 7 | "[javascript]": { 8 | "editor.formatOnSave": true, 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | }, 11 | "[typescript]": { 12 | "editor.formatOnSave": true, 13 | "editor.defaultFormatter": "esbenp.prettier-vscode" 14 | }, 15 | "[typescriptreact]": { 16 | "editor.formatOnSave": true, 17 | "editor.defaultFormatter": "esbenp.prettier-vscode" 18 | }, 19 | "[html]": { 20 | "editor.formatOnSave": true, 21 | "editor.defaultFormatter": "esbenp.prettier-vscode" 22 | }, 23 | "[css]": { 24 | "editor.formatOnSave": true, 25 | "editor.defaultFormatter": "esbenp.prettier-vscode" 26 | }, 27 | "[python]": { 28 | "editor.defaultFormatter": "ms-python.black-formatter", 29 | "editor.formatOnSave": true, 30 | "editor.tabSize": 4, 31 | "editor.codeActionsOnSave": { 32 | "source.organizeImports": "explicit" 33 | } 34 | }, 35 | "isort.args": ["--profile", "black"], 36 | "editor.rulers": [88], 37 | "files.exclude": { 38 | "**/__pycache__": true, 39 | "build/**": true 40 | }, 41 | "autoDocstring.guessTypes": false, 42 | "search.exclude": { 43 | "build/**": true 44 | }, 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 posit-dev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: install-deps install-deps-dev ## install dependencies 2 | install-deps: ## install dependencies 3 | pip install -r requirements.txt 4 | 5 | install-deps-dev: ## install dependencies for development 6 | pip install -r requirements-dev.txt 7 | 8 | 9 | check: check-format 10 | check-format: check-black check-isort 11 | check-lint: 12 | @echo "-------- Checking style with flake8 --------" 13 | flake8 --show-source . 14 | check-black: 15 | @echo "-------- Checking code with black --------" 16 | black --check . 17 | check-isort: 18 | @echo "-------- Sorting imports with isort --------" 19 | isort --check-only --diff . --profile black 20 | 21 | 22 | format: format-black format-isort ## format code with black and isort 23 | format-black: 24 | @echo "-------- Formatting code with black --------" 25 | black . 26 | format-isort: 27 | @echo "-------- Sorting imports with isort --------" 28 | isort . --profile black 29 | 30 | 31 | install-playwright: 32 | playwright install --with-deps chromium 33 | test: ## test deployments are working 34 | pytest --numprocesses auto . 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shiny for Python Templates 2 | 3 | This repository hosts source files behind [Shiny for Python templates](https://shiny.posit.co/py/templates). 4 | 5 | ## Installation 6 | 7 | To install dependencies for _every_ template, run: 8 | 9 | ```bash 10 | make install 11 | ``` 12 | 13 | To install dependencies for a specific template, use the `requirements.txt` file in the template's directory. For example: 14 | 15 | ```bash 16 | pip install -r basic-app-plot/requirements.txt 17 | ``` 18 | 19 | ## Running a template 20 | 21 | From the terminal, you can then run a template like so: 22 | 23 | ```bash 24 | shiny run basic-app-plot/app-core.py 25 | ``` 26 | 27 | Alternatively, open the app file of interest in VSCode and [run it from there](https://shiny.posit.co/py/docs/install-create-run.html#run). 28 | 29 | # Deployment 30 | 31 | Apps are automatically deployed to shinyapps.io. 32 | This should just work for applications that have been previously deployed, but you need to follow a few steps to 33 | deploy a new application. 34 | Each deployment retries three times to avoid spurious failures, and caches the result so as not to redeploy applications which have not changed since the last successful deployment. 35 | 36 | # Contributing 37 | 38 | 1. Create a new folder for the application, the folder should contain at least an `app-core.py` file and a `requirements.txt` file. Optionally add an `app-express.py` file if you want to give people the option to use Shiny Express. 39 | * Make sure also to add this `requirements.txt` file to the root `./requirements.txt` file. 40 | 41 | 2. Deploy the app to shinyapps.io in the `gallery` account, if you don't have access to this account mention it in the PR and a member of the Shiny team will do this and the following step for you. 42 | 43 | 3. Add your application to `deployments.json`, you will need to login to shinyapps.io to get the guid. 44 | - Note that this `guid` is the same as the `app_id` left in the `rsconnect-python/` manifest created by `rsconnect deploy shiny`. 45 | 46 | 4. Raise a PR with your changes 47 | -------------------------------------------------------------------------------- /basic-app-plot/README.md: -------------------------------------------------------------------------------- 1 | ## Basic plot app 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /basic-app-plot/_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "basic-app-plot", 3 | "title": "Basic reactive plot", 4 | "description": "A basic example of changing a histogram based on a select input control." 5 | } 6 | -------------------------------------------------------------------------------- /basic-app-plot/app-core.py: -------------------------------------------------------------------------------- 1 | import seaborn as sns 2 | 3 | # Import data from shared.py 4 | from shared import df 5 | from shiny import App, render, ui 6 | 7 | # User interface (UI) definition 8 | app_ui = ui.page_fixed( 9 | # Add a title to the page with some top padding 10 | ui.panel_title(ui.h2("Basic Shiny app", class_="pt-5")), 11 | # A container for plot output 12 | ui.output_plot("hist"), 13 | # A select input for choosing the variable to plot 14 | ui.input_select( 15 | "var", "Select variable", choices=["bill_length_mm", "body_mass_g"] 16 | ), 17 | ) 18 | 19 | 20 | # Server function provides access to client-side input values 21 | def server(input): 22 | @render.plot 23 | def hist(): 24 | # Histogram of the selected variable (input.var()) 25 | p = sns.histplot(df, x=input.var(), facecolor="#007bc2", edgecolor="white") 26 | return p.set(xlabel=None) 27 | 28 | 29 | app = App(app_ui, server) 30 | -------------------------------------------------------------------------------- /basic-app-plot/app-express.py: -------------------------------------------------------------------------------- 1 | import seaborn as sns 2 | 3 | # Import data from shared.py 4 | from shared import df 5 | from shiny.express import input, render, ui 6 | 7 | # Page title (with some additional top padding) 8 | ui.page_opts(title=ui.h2("Basic Shiny app", class_="pt-5")) 9 | 10 | 11 | # Render a histogram of the selected variable (input.var()) 12 | @render.plot 13 | def hist(): 14 | p = sns.histplot(df, x=input.var(), facecolor="#007bc2", edgecolor="white") 15 | return p.set(xlabel=None) 16 | 17 | 18 | # Select input for choosing the variable to plot 19 | ui.input_select("var", "Select variable", choices=["bill_length_mm", "body_mass_g"]) 20 | -------------------------------------------------------------------------------- /basic-app-plot/requirements.txt: -------------------------------------------------------------------------------- 1 | shiny 2 | seaborn 3 | pandas -------------------------------------------------------------------------------- /basic-app-plot/shared.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pandas as pd 4 | 5 | app_dir = Path(__file__).parent 6 | df = pd.read_csv(app_dir / "penguins.csv") 7 | -------------------------------------------------------------------------------- /basic-app/README.md: -------------------------------------------------------------------------------- 1 | ## Basic app 2 | 3 | 4 | -------------------------------------------------------------------------------- /basic-app/_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "basic-app", 3 | "title": "A Basic app", 4 | "description": "A simple Shiny app to get you started quickly." 5 | } 6 | -------------------------------------------------------------------------------- /basic-app/app-core.py: -------------------------------------------------------------------------------- 1 | from shiny import App, render, ui 2 | 3 | app_ui = ui.page_fluid( 4 | ui.panel_title("Hello Shiny!"), 5 | ui.input_slider("n", "N", 0, 100, 20), 6 | ui.output_text_verbatim("txt"), 7 | ) 8 | 9 | 10 | def server(input, output, session): 11 | @render.text 12 | def txt(): 13 | return f"n*2 is {input.n() * 2}" 14 | 15 | 16 | app = App(app_ui, server) 17 | -------------------------------------------------------------------------------- /basic-app/app-express.py: -------------------------------------------------------------------------------- 1 | from shiny import render, ui 2 | from shiny.express import input 3 | 4 | ui.panel_title("Hello Shiny!") 5 | ui.input_slider("n", "N", 0, 100, 20) 6 | 7 | 8 | @render.code 9 | def txt(): 10 | return f"n*2 is {input.n() * 2}" 11 | -------------------------------------------------------------------------------- /basic-app/requirements.txt: -------------------------------------------------------------------------------- 1 | shiny -------------------------------------------------------------------------------- /basic-navigation/README.md: -------------------------------------------------------------------------------- 1 | ## Basic navigation app 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /basic-navigation/_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "basic-navigation", 3 | "title": "Navigating multiple panels", 4 | "description": "A basic demonstration of common navigation of multiple panels in Shiny." 5 | } 6 | -------------------------------------------------------------------------------- /basic-navigation/app-core.py: -------------------------------------------------------------------------------- 1 | import seaborn as sns 2 | 3 | # Import data from shared.py 4 | from shared import df 5 | from shiny import App, render, ui 6 | 7 | # The contents of the first 'page' is a navset with two 'panels'. 8 | page1 = ui.navset_card_underline( 9 | ui.nav_panel("Plot", ui.output_plot("hist")), 10 | ui.nav_panel("Table", ui.output_data_frame("data")), 11 | footer=ui.input_select( 12 | "var", "Select variable", choices=["bill_length_mm", "body_mass_g"] 13 | ), 14 | title="Penguins data", 15 | ) 16 | 17 | app_ui = ui.page_navbar( 18 | ui.nav_spacer(), # Push the navbar items to the right 19 | ui.nav_panel("Page 1", page1), 20 | ui.nav_panel("Page 2", "This is the second 'page'."), 21 | title="Shiny navigation components", 22 | ) 23 | 24 | 25 | def server(input, output, session): 26 | @render.plot 27 | def hist(): 28 | p = sns.histplot(df, x=input.var(), facecolor="#007bc2", edgecolor="white") 29 | return p.set(xlabel=None) 30 | 31 | @render.data_frame 32 | def data(): 33 | return df[["species", "island", input.var()]] 34 | 35 | 36 | app = App(app_ui, server) 37 | -------------------------------------------------------------------------------- /basic-navigation/app-express.py: -------------------------------------------------------------------------------- 1 | import seaborn as sns 2 | 3 | # Import data from shared.py 4 | from shared import df 5 | from shiny.express import input, render, ui 6 | 7 | ui.page_opts(title="Shiny navigation components") 8 | 9 | ui.nav_spacer() # Push the navbar items to the right 10 | 11 | footer = ui.input_select( 12 | "var", "Select variable", choices=["bill_length_mm", "body_mass_g"] 13 | ) 14 | 15 | with ui.nav_panel("Page 1"): 16 | with ui.navset_card_underline(title="Penguins data", footer=footer): 17 | with ui.nav_panel("Plot"): 18 | 19 | @render.plot 20 | def hist(): 21 | p = sns.histplot( 22 | df, x=input.var(), facecolor="#007bc2", edgecolor="white" 23 | ) 24 | return p.set(xlabel=None) 25 | 26 | with ui.nav_panel("Table"): 27 | 28 | @render.data_frame 29 | def data(): 30 | return df[["species", "island", input.var()]] 31 | 32 | 33 | with ui.nav_panel("Page 2"): 34 | "This is the second 'page'." 35 | -------------------------------------------------------------------------------- /basic-navigation/requirements.txt: -------------------------------------------------------------------------------- 1 | shiny 2 | seaborn 3 | pandas -------------------------------------------------------------------------------- /basic-navigation/shared.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pandas as pd 4 | 5 | app_dir = Path(__file__).parent 6 | df = pd.read_csv(app_dir / "penguins.csv") 7 | -------------------------------------------------------------------------------- /basic-sidebar/README.md: -------------------------------------------------------------------------------- 1 | ## Basic sidebar app 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /basic-sidebar/_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "basic-sidebar", 3 | "title": "Reactive plot with a sidebar", 4 | "description": "Place numerous input controls in a sidebar and use them to control a plot in the main panel." 5 | } 6 | -------------------------------------------------------------------------------- /basic-sidebar/app-core.py: -------------------------------------------------------------------------------- 1 | import seaborn as sns 2 | 3 | # Import data from shared.py 4 | from shared import df 5 | from shiny import App, render, ui 6 | 7 | app_ui = ui.page_sidebar( 8 | ui.sidebar( 9 | ui.input_select( 10 | "var", "Select variable", choices=["bill_length_mm", "body_mass_g"] 11 | ), 12 | ui.input_switch("species", "Group by species", value=True), 13 | ui.input_switch("show_rug", "Show Rug", value=True), 14 | ), 15 | ui.output_plot("hist"), 16 | title="Hello sidebar!", 17 | ) 18 | 19 | 20 | def server(input, output, session): 21 | @render.plot 22 | def hist(): 23 | hue = "species" if input.species() else None 24 | sns.kdeplot(df, x=input.var(), hue=hue) 25 | if input.show_rug(): 26 | sns.rugplot(df, x=input.var(), hue=hue, color="black", alpha=0.25) 27 | 28 | 29 | app = App(app_ui, server) 30 | -------------------------------------------------------------------------------- /basic-sidebar/app-express.py: -------------------------------------------------------------------------------- 1 | import seaborn as sns 2 | 3 | # Import data from shared.py 4 | from shared import df 5 | from shiny.express import input, render, ui 6 | 7 | ui.page_opts(title="Hello sidebar!") 8 | 9 | with ui.sidebar(): 10 | ui.input_select("var", "Select variable", choices=["bill_length_mm", "body_mass_g"]) 11 | ui.input_switch("species", "Group by species", value=True) 12 | ui.input_switch("show_rug", "Show Rug", value=True) 13 | 14 | 15 | @render.plot 16 | def hist(): 17 | hue = "species" if input.species() else None 18 | sns.kdeplot(df, x=input.var(), hue=hue) 19 | if input.show_rug(): 20 | sns.rugplot(df, x=input.var(), hue=hue, color="black", alpha=0.25) 21 | -------------------------------------------------------------------------------- /basic-sidebar/requirements.txt: -------------------------------------------------------------------------------- 1 | shiny 2 | seaborn 3 | pandas -------------------------------------------------------------------------------- /basic-sidebar/shared.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pandas as pd 4 | 5 | app_dir = Path(__file__).parent 6 | df = pd.read_csv(app_dir / "penguins.csv") 7 | -------------------------------------------------------------------------------- /dashboard-tips/README.md: -------------------------------------------------------------------------------- 1 | ## Dashboard tips app 2 | 3 | 4 | 5 | 6 | 7 | This template gives you a more "complete" dashboard for exploring the tips dataset. For an overview of what's here, visit [this article](https://shiny.posit.co/py/docs/user-interfaces.html). 8 | -------------------------------------------------------------------------------- /dashboard-tips/_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "dashboard-tips", 3 | "title": "Restaurant tips dashboard", 4 | "description": "An intermediate dashboard with input filters, value boxes, a plot, and table." 5 | } 6 | -------------------------------------------------------------------------------- /dashboard-tips/app-core.py: -------------------------------------------------------------------------------- 1 | import faicons as fa 2 | import plotly.express as px 3 | 4 | # Load data and compute static values 5 | from shared import app_dir, tips 6 | from shiny import App, reactive, render, ui 7 | from shinywidgets import output_widget, render_plotly 8 | 9 | bill_rng = (min(tips.total_bill), max(tips.total_bill)) 10 | 11 | 12 | ICONS = { 13 | "user": fa.icon_svg("user", "regular"), 14 | "wallet": fa.icon_svg("wallet"), 15 | "currency-dollar": fa.icon_svg("dollar-sign"), 16 | "ellipsis": fa.icon_svg("ellipsis"), 17 | } 18 | 19 | # Add page title and sidebar 20 | app_ui = ui.page_sidebar( 21 | ui.sidebar( 22 | ui.input_slider( 23 | "total_bill", 24 | "Bill amount", 25 | min=bill_rng[0], 26 | max=bill_rng[1], 27 | value=bill_rng, 28 | pre="$", 29 | ), 30 | ui.input_checkbox_group( 31 | "time", 32 | "Food service", 33 | ["Lunch", "Dinner"], 34 | selected=["Lunch", "Dinner"], 35 | inline=True, 36 | ), 37 | ui.input_action_button("reset", "Reset filter"), 38 | open="desktop", 39 | ), 40 | ui.layout_columns( 41 | ui.value_box( 42 | "Total tippers", ui.output_ui("total_tippers"), showcase=ICONS["user"] 43 | ), 44 | ui.value_box( 45 | "Average tip", ui.output_ui("average_tip"), showcase=ICONS["wallet"] 46 | ), 47 | ui.value_box( 48 | "Average bill", 49 | ui.output_ui("average_bill"), 50 | showcase=ICONS["currency-dollar"], 51 | ), 52 | fill=False, 53 | ), 54 | ui.layout_columns( 55 | ui.card( 56 | ui.card_header("Tips data"), ui.output_data_frame("table"), full_screen=True 57 | ), 58 | ui.card( 59 | ui.card_header( 60 | "Total bill vs tip", 61 | ui.popover( 62 | ICONS["ellipsis"], 63 | ui.input_radio_buttons( 64 | "scatter_color", 65 | None, 66 | ["none", "sex", "smoker", "day", "time"], 67 | inline=True, 68 | ), 69 | title="Add a color variable", 70 | placement="top", 71 | ), 72 | class_="d-flex justify-content-between align-items-center", 73 | ), 74 | output_widget("scatterplot"), 75 | full_screen=True, 76 | ), 77 | ui.card( 78 | ui.card_header( 79 | "Tip percentages", 80 | ui.popover( 81 | ICONS["ellipsis"], 82 | ui.input_radio_buttons( 83 | "tip_perc_y", 84 | "Split by:", 85 | ["sex", "smoker", "day", "time"], 86 | selected="day", 87 | inline=True, 88 | ), 89 | title="Add a color variable", 90 | ), 91 | class_="d-flex justify-content-between align-items-center", 92 | ), 93 | output_widget("tip_perc"), 94 | full_screen=True, 95 | ), 96 | col_widths=[6, 6, 12], 97 | ), 98 | ui.include_css(app_dir / "styles.css"), 99 | title="Restaurant tipping", 100 | fillable=True, 101 | ) 102 | 103 | 104 | def server(input, output, session): 105 | @reactive.calc 106 | def tips_data(): 107 | bill = input.total_bill() 108 | idx1 = tips.total_bill.between(bill[0], bill[1]) 109 | idx2 = tips.time.isin(input.time()) 110 | return tips[idx1 & idx2] 111 | 112 | @render.ui 113 | def total_tippers(): 114 | return tips_data().shape[0] 115 | 116 | @render.ui 117 | def average_tip(): 118 | d = tips_data() 119 | if d.shape[0] > 0: 120 | perc = d.tip / d.total_bill 121 | return f"{perc.mean():.1%}" 122 | 123 | @render.ui 124 | def average_bill(): 125 | d = tips_data() 126 | if d.shape[0] > 0: 127 | bill = d.total_bill.mean() 128 | return f"${bill:.2f}" 129 | 130 | @render.data_frame 131 | def table(): 132 | return render.DataGrid(tips_data()) 133 | 134 | @render_plotly 135 | def scatterplot(): 136 | color = input.scatter_color() 137 | return px.scatter( 138 | tips_data(), 139 | x="total_bill", 140 | y="tip", 141 | color=None if color == "none" else color, 142 | trendline="lowess", 143 | ) 144 | 145 | @render_plotly 146 | def tip_perc(): 147 | from ridgeplot import ridgeplot 148 | 149 | dat = tips_data() 150 | dat["percent"] = dat.tip / dat.total_bill 151 | yvar = input.tip_perc_y() 152 | uvals = dat[yvar].unique() 153 | 154 | samples = [[dat.percent[dat[yvar] == val]] for val in uvals] 155 | 156 | plt = ridgeplot( 157 | samples=samples, 158 | labels=uvals, 159 | bandwidth=0.01, 160 | colorscale="viridis", 161 | colormode="row-index", 162 | ) 163 | 164 | plt.update_layout( 165 | legend=dict( 166 | orientation="h", yanchor="bottom", y=1.02, xanchor="center", x=0.5 167 | ) 168 | ) 169 | 170 | return plt 171 | 172 | @reactive.effect 173 | @reactive.event(input.reset) 174 | def _(): 175 | ui.update_slider("total_bill", value=bill_rng) 176 | ui.update_checkbox_group("time", selected=["Lunch", "Dinner"]) 177 | 178 | 179 | app = App(app_ui, server) 180 | -------------------------------------------------------------------------------- /dashboard-tips/app-express.py: -------------------------------------------------------------------------------- 1 | import faicons as fa 2 | import plotly.express as px 3 | 4 | # Load data and compute static values 5 | from shared import app_dir, tips 6 | from shiny import reactive, render 7 | from shiny.express import input, ui 8 | from shinywidgets import render_plotly 9 | 10 | bill_rng = (min(tips.total_bill), max(tips.total_bill)) 11 | 12 | # Add page title and sidebar 13 | ui.page_opts(title="Restaurant tipping", fillable=True) 14 | 15 | with ui.sidebar(open="desktop"): 16 | ui.input_slider( 17 | "total_bill", 18 | "Bill amount", 19 | min=bill_rng[0], 20 | max=bill_rng[1], 21 | value=bill_rng, 22 | pre="$", 23 | ) 24 | ui.input_checkbox_group( 25 | "time", 26 | "Food service", 27 | ["Lunch", "Dinner"], 28 | selected=["Lunch", "Dinner"], 29 | inline=True, 30 | ) 31 | ui.input_action_button("reset", "Reset filter") 32 | 33 | # Add main content 34 | ICONS = { 35 | "user": fa.icon_svg("user", "regular"), 36 | "wallet": fa.icon_svg("wallet"), 37 | "currency-dollar": fa.icon_svg("dollar-sign"), 38 | "ellipsis": fa.icon_svg("ellipsis"), 39 | } 40 | 41 | with ui.layout_columns(fill=False): 42 | with ui.value_box(showcase=ICONS["user"]): 43 | "Total tippers" 44 | 45 | @render.express 46 | def total_tippers(): 47 | tips_data().shape[0] 48 | 49 | with ui.value_box(showcase=ICONS["wallet"]): 50 | "Average tip" 51 | 52 | @render.express 53 | def average_tip(): 54 | d = tips_data() 55 | if d.shape[0] > 0: 56 | perc = d.tip / d.total_bill 57 | f"{perc.mean():.1%}" 58 | 59 | with ui.value_box(showcase=ICONS["currency-dollar"]): 60 | "Average bill" 61 | 62 | @render.express 63 | def average_bill(): 64 | d = tips_data() 65 | if d.shape[0] > 0: 66 | bill = d.total_bill.mean() 67 | f"${bill:.2f}" 68 | 69 | 70 | with ui.layout_columns(col_widths=[6, 6, 12]): 71 | with ui.card(full_screen=True): 72 | ui.card_header("Tips data") 73 | 74 | @render.data_frame 75 | def table(): 76 | return render.DataGrid(tips_data()) 77 | 78 | with ui.card(full_screen=True): 79 | with ui.card_header(class_="d-flex justify-content-between align-items-center"): 80 | "Total bill vs tip" 81 | with ui.popover(title="Add a color variable", placement="top"): 82 | ICONS["ellipsis"] 83 | ui.input_radio_buttons( 84 | "scatter_color", 85 | None, 86 | ["none", "sex", "smoker", "day", "time"], 87 | inline=True, 88 | ) 89 | 90 | @render_plotly 91 | def scatterplot(): 92 | color = input.scatter_color() 93 | return px.scatter( 94 | tips_data(), 95 | x="total_bill", 96 | y="tip", 97 | color=None if color == "none" else color, 98 | trendline="lowess", 99 | ) 100 | 101 | with ui.card(full_screen=True): 102 | with ui.card_header(class_="d-flex justify-content-between align-items-center"): 103 | "Tip percentages" 104 | with ui.popover(title="Add a color variable"): 105 | ICONS["ellipsis"] 106 | ui.input_radio_buttons( 107 | "tip_perc_y", 108 | "Split by:", 109 | ["sex", "smoker", "day", "time"], 110 | selected="day", 111 | inline=True, 112 | ) 113 | 114 | @render_plotly 115 | def tip_perc(): 116 | from ridgeplot import ridgeplot 117 | 118 | dat = tips_data() 119 | dat["percent"] = dat.tip / dat.total_bill 120 | yvar = input.tip_perc_y() 121 | uvals = dat[yvar].unique() 122 | 123 | samples = [[dat.percent[dat[yvar] == val]] for val in uvals] 124 | 125 | plt = ridgeplot( 126 | samples=samples, 127 | labels=uvals, 128 | bandwidth=0.01, 129 | colorscale="viridis", 130 | colormode="row-index", 131 | ) 132 | 133 | plt.update_layout( 134 | legend=dict( 135 | orientation="h", yanchor="bottom", y=1.02, xanchor="center", x=0.5 136 | ) 137 | ) 138 | 139 | return plt 140 | 141 | 142 | ui.include_css(app_dir / "styles.css") 143 | 144 | # -------------------------------------------------------- 145 | # Reactive calculations and effects 146 | # -------------------------------------------------------- 147 | 148 | 149 | @reactive.calc 150 | def tips_data(): 151 | bill = input.total_bill() 152 | idx1 = tips.total_bill.between(bill[0], bill[1]) 153 | idx2 = tips.time.isin(input.time()) 154 | return tips[idx1 & idx2] 155 | 156 | 157 | @reactive.effect 158 | @reactive.event(input.reset) 159 | def _(): 160 | ui.update_slider("total_bill", value=bill_rng) 161 | ui.update_checkbox_group("time", selected=["Lunch", "Dinner"]) 162 | -------------------------------------------------------------------------------- /dashboard-tips/requirements.txt: -------------------------------------------------------------------------------- 1 | faicons 2 | shiny 3 | shinywidgets 4 | plotly 5 | pandas 6 | ridgeplot 7 | -------------------------------------------------------------------------------- /dashboard-tips/shared.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pandas as pd 4 | 5 | app_dir = Path(__file__).parent 6 | tips = pd.read_csv(app_dir / "tips.csv") 7 | -------------------------------------------------------------------------------- /dashboard-tips/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bslib-sidebar-main-bg: #f8f8f8; 3 | } 4 | 5 | .popover { 6 | --bs-popover-header-bg: #222; 7 | --bs-popover-header-color: #fff; 8 | } 9 | 10 | .popover .btn-close { 11 | filter: var(--bs-btn-close-white-filter); 12 | } -------------------------------------------------------------------------------- /dashboard/README.md: -------------------------------------------------------------------------------- 1 | ## Dashboard app 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /dashboard/_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "dashboard", 3 | "title": "Basic dashboard", 4 | "description": "A basic dashboard with input filters, value boxes, a plot, and table." 5 | } 6 | -------------------------------------------------------------------------------- /dashboard/app-core.py: -------------------------------------------------------------------------------- 1 | import seaborn as sns 2 | from faicons import icon_svg 3 | 4 | # Import data from shared.py 5 | from shared import app_dir, df 6 | from shiny import App, reactive, render, ui 7 | 8 | app_ui = ui.page_sidebar( 9 | ui.sidebar( 10 | ui.input_slider("mass", "Mass", 2000, 6000, 6000), 11 | ui.input_checkbox_group( 12 | "species", 13 | "Species", 14 | ["Adelie", "Gentoo", "Chinstrap"], 15 | selected=["Adelie", "Gentoo", "Chinstrap"], 16 | ), 17 | title="Filter controls", 18 | ), 19 | ui.layout_column_wrap( 20 | ui.value_box( 21 | "Number of penguins", 22 | ui.output_text("count"), 23 | showcase=icon_svg("earlybirds"), 24 | ), 25 | ui.value_box( 26 | "Average bill length", 27 | ui.output_text("bill_length"), 28 | showcase=icon_svg("ruler-horizontal"), 29 | ), 30 | ui.value_box( 31 | "Average bill depth", 32 | ui.output_text("bill_depth"), 33 | showcase=icon_svg("ruler-vertical"), 34 | ), 35 | fill=False, 36 | ), 37 | ui.layout_columns( 38 | ui.card( 39 | ui.card_header("Bill length and depth"), 40 | ui.output_plot("length_depth"), 41 | full_screen=True, 42 | ), 43 | ui.card( 44 | ui.card_header("Penguin data"), 45 | ui.output_data_frame("summary_statistics"), 46 | full_screen=True, 47 | ), 48 | ), 49 | ui.include_css(app_dir / "styles.css"), 50 | title="Penguins dashboard", 51 | fillable=True, 52 | ) 53 | 54 | 55 | def server(input, output, session): 56 | @reactive.calc 57 | def filtered_df(): 58 | filt_df = df[df["species"].isin(input.species())] 59 | filt_df = filt_df.loc[filt_df["body_mass_g"] < input.mass()] 60 | return filt_df 61 | 62 | @render.text 63 | def count(): 64 | return filtered_df().shape[0] 65 | 66 | @render.text 67 | def bill_length(): 68 | return f"{filtered_df()['bill_length_mm'].mean():.1f} mm" 69 | 70 | @render.text 71 | def bill_depth(): 72 | return f"{filtered_df()['bill_depth_mm'].mean():.1f} mm" 73 | 74 | @render.plot 75 | def length_depth(): 76 | return sns.scatterplot( 77 | data=filtered_df(), 78 | x="bill_length_mm", 79 | y="bill_depth_mm", 80 | hue="species", 81 | ) 82 | 83 | @render.data_frame 84 | def summary_statistics(): 85 | cols = [ 86 | "species", 87 | "island", 88 | "bill_length_mm", 89 | "bill_depth_mm", 90 | "body_mass_g", 91 | ] 92 | return render.DataGrid(filtered_df()[cols], filters=True) 93 | 94 | 95 | app = App(app_ui, server) 96 | -------------------------------------------------------------------------------- /dashboard/app-express.py: -------------------------------------------------------------------------------- 1 | import seaborn as sns 2 | from faicons import icon_svg 3 | 4 | # Import data from shared.py 5 | from shared import app_dir, df 6 | from shiny import reactive 7 | from shiny.express import input, render, ui 8 | 9 | ui.page_opts(title="Penguins dashboard", fillable=True) 10 | 11 | 12 | with ui.sidebar(title="Filter controls"): 13 | ui.input_slider("mass", "Mass", 2000, 6000, 6000) 14 | ui.input_checkbox_group( 15 | "species", 16 | "Species", 17 | ["Adelie", "Gentoo", "Chinstrap"], 18 | selected=["Adelie", "Gentoo", "Chinstrap"], 19 | ) 20 | 21 | 22 | with ui.layout_column_wrap(fill=False): 23 | with ui.value_box(showcase=icon_svg("earlybirds")): 24 | "Number of penguins" 25 | 26 | @render.text 27 | def count(): 28 | return filtered_df().shape[0] 29 | 30 | with ui.value_box(showcase=icon_svg("ruler-horizontal")): 31 | "Average bill length" 32 | 33 | @render.text 34 | def bill_length(): 35 | return f"{filtered_df()['bill_length_mm'].mean():.1f} mm" 36 | 37 | with ui.value_box(showcase=icon_svg("ruler-vertical")): 38 | "Average bill depth" 39 | 40 | @render.text 41 | def bill_depth(): 42 | return f"{filtered_df()['bill_depth_mm'].mean():.1f} mm" 43 | 44 | 45 | with ui.layout_columns(): 46 | with ui.card(full_screen=True): 47 | ui.card_header("Bill length and depth") 48 | 49 | @render.plot 50 | def length_depth(): 51 | return sns.scatterplot( 52 | data=filtered_df(), 53 | x="bill_length_mm", 54 | y="bill_depth_mm", 55 | hue="species", 56 | ) 57 | 58 | with ui.card(full_screen=True): 59 | ui.card_header("Penguin data") 60 | 61 | @render.data_frame 62 | def summary_statistics(): 63 | cols = [ 64 | "species", 65 | "island", 66 | "bill_length_mm", 67 | "bill_depth_mm", 68 | "body_mass_g", 69 | ] 70 | return render.DataGrid(filtered_df()[cols], filters=True) 71 | 72 | 73 | ui.include_css(app_dir / "styles.css") 74 | 75 | 76 | @reactive.calc 77 | def filtered_df(): 78 | filt_df = df[df["species"].isin(input.species())] 79 | filt_df = filt_df.loc[filt_df["body_mass_g"] < input.mass()] 80 | return filt_df 81 | -------------------------------------------------------------------------------- /dashboard/requirements.txt: -------------------------------------------------------------------------------- 1 | faicons 2 | shiny 3 | seaborn 4 | pandas 5 | -------------------------------------------------------------------------------- /dashboard/shared.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pandas as pd 4 | 5 | app_dir = Path(__file__).parent 6 | df = pd.read_csv(app_dir / "penguins.csv") 7 | -------------------------------------------------------------------------------- /dashboard/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bslib-sidebar-main-bg: #f8f8f8; 3 | } -------------------------------------------------------------------------------- /database-explorer/.gitignore: -------------------------------------------------------------------------------- 1 | cities.csv 2 | weather_forecasts.csv 3 | weather.db 4 | -------------------------------------------------------------------------------- /database-explorer/README.md: -------------------------------------------------------------------------------- 1 | ## Database explorer app 2 | 3 | 4 | -------------------------------------------------------------------------------- /database-explorer/_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "database-explorer", 3 | "title": "SQL database explorer", 4 | "description": "Run queries on an SQL database and view the results in a data grid." 5 | } 6 | -------------------------------------------------------------------------------- /database-explorer/app-core.py: -------------------------------------------------------------------------------- 1 | import urllib.request 2 | from pathlib import Path 3 | 4 | import duckdb 5 | from query import query_output_server, query_output_ui 6 | from shiny import App, reactive, ui 7 | 8 | app_dir = Path(__file__).parent 9 | db_file = app_dir / "weather.db" 10 | 11 | 12 | def load_csv(con, csv_name, table_name): 13 | csv_url = f"https://raw.githubusercontent.com/rfordatascience/tidytuesday/master/data/2022/2022-12-20/{csv_name}.csv" 14 | local_file_path = app_dir / f"{csv_name}.csv" 15 | urllib.request.urlretrieve(csv_url, local_file_path) 16 | con.sql( 17 | f"CREATE TABLE {table_name} AS SELECT * FROM read_csv_auto('{local_file_path}')" 18 | ) 19 | 20 | 21 | if not Path.exists(db_file): 22 | con = duckdb.connect(str(db_file), read_only=False) 23 | load_csv(con, "weather_forecasts", "weather") 24 | load_csv(con, "cities", "cities") 25 | con.close() 26 | 27 | con = duckdb.connect(str(db_file), read_only=True) 28 | 29 | app_ui = ui.page_sidebar( 30 | ui.sidebar( 31 | ui.input_action_button("add_query", "Add Query", class_="btn btn-primary"), 32 | ui.input_action_button( 33 | "show_meta", "Show Metadata", class_="btn btn-secondary" 34 | ), 35 | ui.markdown( 36 | """ 37 | This app lets you explore a dataset using SQL and duckdb. 38 | The data is stored in an on-disk [duckdb](https://duckdb.org/) database, 39 | which leads to extremely fast queries. 40 | """ 41 | ), 42 | ), 43 | ui.tags.div( 44 | query_output_ui("initial_query", remove_id="initial_query"), 45 | id="module_container", 46 | ), 47 | title="DuckDB query explorer", 48 | class_="bslib-page-dashboard", 49 | ) 50 | 51 | 52 | def server(input, output, session): 53 | mod_counter = reactive.value(0) 54 | 55 | query_output_server("initial_query", con=con, remove_id="initial_query") 56 | 57 | @reactive.effect 58 | @reactive.event(input.add_query) 59 | def _(): 60 | counter = mod_counter.get() + 1 61 | mod_counter.set(counter) 62 | id = "query_" + str(counter) 63 | ui.insert_ui( 64 | selector="#module_container", 65 | where="afterBegin", 66 | ui=query_output_ui(id, remove_id=id), 67 | ) 68 | query_output_server(id, con=con, remove_id=id) 69 | 70 | @reactive.effect 71 | @reactive.event(input.show_meta) 72 | def _(): 73 | counter = mod_counter.get() + 1 74 | mod_counter.set(counter) 75 | id = "query_" + str(counter) 76 | ui.insert_ui( 77 | selector="#module_container", 78 | where="afterBegin", 79 | ui=query_output_ui( 80 | id, qry="SELECT * from information_schema.columns", remove_id=id 81 | ), 82 | ) 83 | query_output_server(id, con=con, remove_id=id) 84 | 85 | 86 | app = App(app_ui, server) 87 | -------------------------------------------------------------------------------- /database-explorer/query.py: -------------------------------------------------------------------------------- 1 | import duckdb 2 | from shiny import module, reactive, render, ui 3 | 4 | 5 | @module.ui 6 | def query_output_ui(remove_id, qry="SELECT * from weather LIMIT 10"): 7 | return ( 8 | ui.card( 9 | {"id": remove_id}, 10 | ui.card_header(f"{remove_id}"), 11 | ui.layout_columns( 12 | [ 13 | ui.input_text_area( 14 | "sql_query", 15 | "", 16 | value=qry, 17 | width="100%", 18 | height="200px", 19 | ), 20 | ui.layout_columns( 21 | ui.input_action_button("run", "Run", class_="btn btn-primary"), 22 | ui.input_action_button( 23 | "rmv", "Remove", class_="btn btn-warning" 24 | ), 25 | ), 26 | ], 27 | ui.output_data_frame("results"), 28 | col_widths={"xl": [3, 9], "lg": [4, 8], "md": [6, 6], "sm": [12, 12]}, 29 | ), 30 | ), 31 | ) 32 | 33 | 34 | @module.server 35 | def query_output_server( 36 | input, output, session, con: duckdb.DuckDBPyConnection, remove_id 37 | ): 38 | @render.data_frame 39 | @reactive.event(input.run) 40 | def results(): 41 | qry = input.sql_query().replace("\n", " ") 42 | return con.query(qry).to_df() 43 | 44 | @reactive.effect 45 | @reactive.event(input.rmv) 46 | def _(): 47 | ui.remove_ui(selector=f"div#{remove_id}") 48 | -------------------------------------------------------------------------------- /database-explorer/requirements.txt: -------------------------------------------------------------------------------- 1 | duckdb 2 | shiny 3 | pandas 4 | -------------------------------------------------------------------------------- /deployments.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | { 4 | "folder": "basic-app", 5 | "guid": 10892533, 6 | "url": "https://gallery.shinyapps.io/template-basic-app/" 7 | }, 8 | { 9 | "folder": "basic-app-plot", 10 | "guid": 11359401, 11 | "url": "https://gallery.shinyapps.io/template-basic-app-plot1/" 12 | }, 13 | { 14 | "folder": "basic-navigation", 15 | "guid": 11359792, 16 | "url": "https://gallery.shinyapps.io/template-basic-navigation1/" 17 | }, 18 | { 19 | "folder": "basic-sidebar", 20 | "guid": 11359435, 21 | "url": "https://gallery.shinyapps.io/template-basic-sidebar1/" 22 | }, 23 | { 24 | "folder": "dashboard", 25 | "guid": 10892532, 26 | "url": "https://gallery.shinyapps.io/template-dashboard/" 27 | }, 28 | { 29 | "folder": "dashboard-tips", 30 | "guid": 11359445, 31 | "url": "https://gallery.shinyapps.io/template-dashboard-tips1/" 32 | }, 33 | { 34 | "folder": "database-explorer", 35 | "guid": 10897334, 36 | "url": "https://gallery.shinyapps.io/template-database-explorer/" 37 | }, 38 | { 39 | "folder": "map-distance", 40 | "guid": 11367545, 41 | "url": "https://gallery.shinyapps.io/template-map-distance/" 42 | }, 43 | { 44 | "folder": "model-scoring", 45 | "guid": 11359083, 46 | "url": "https://gallery.shinyapps.io/template-model-scoring/" 47 | }, 48 | { 49 | "folder": "monitor-database", 50 | "guid": 10897342, 51 | "url": "https://gallery.shinyapps.io/template-monitor-database/" 52 | }, 53 | { 54 | "folder": "monitor-file", 55 | "guid": 10897336, 56 | "url": "https://gallery.shinyapps.io/template-monitor-file/" 57 | }, 58 | { 59 | "folder": "monitor-folder", 60 | "guid": 10897345, 61 | "url": "https://gallery.shinyapps.io/template-monitor-folder/" 62 | }, 63 | { 64 | "folder": "nba-dashboard", 65 | "guid": 10897337, 66 | "url": "https://gallery.shinyapps.io/template-nba-dashboard/" 67 | }, 68 | { 69 | "folder": "regularization", 70 | "guid": 10897343, 71 | "url": "https://gallery.shinyapps.io/template-regularization/" 72 | }, 73 | { 74 | "folder": "stock-app", 75 | "guid": 14877332, 76 | "url": "https://gallery.shinyapps.io/template-stock-app/" 77 | }, 78 | { 79 | "folder": "survey", 80 | "guid": 10897339, 81 | "url": "https://gallery.shinyapps.io/template-survey/" 82 | }, 83 | { 84 | "folder": "survey-wizard", 85 | "guid": 11359104, 86 | "url": "https://gallery.shinyapps.io/template-survey-wizard/" 87 | } 88 | ] 89 | } 90 | -------------------------------------------------------------------------------- /gen-ai/README.md: -------------------------------------------------------------------------------- 1 | # Generative AI templates 2 | 3 | This folder contains templates that require an LLM to run, and therefore, require special credentials to run/host. 4 | 5 | Since credentials are required, the hosted versions of these apps are being deployed through [this Connect Cloud account](https://connect.posit.cloud/posit-ai), which is also managing the credentials. 6 | 7 | Note that you can use `shiny create` to obtain a copy of each Gen AI template with a command that looks like this: 8 | 9 | ```shell 10 | shiny create --template workout-plan --github posit-dev/py-shiny-templates/gen-ai 11 | ``` 12 | -------------------------------------------------------------------------------- /gen-ai/basic-chat/_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "app", 3 | "id": "basic-chat", 4 | "title": "Chat with an Anthropic LLM", 5 | "description": "A basic example of a chatbot with chatlas and shiny.", 6 | "next_steps": [ 7 | "Put your Anthropic API key in the `template.env` file and rename it to `.env`.", 8 | "Run the app with `shiny run app.py`." 9 | ], 10 | "follow_up": [ 11 | { 12 | "type": "info", 13 | "text": "Need help obtaining an API key?" 14 | }, 15 | { 16 | "type": "action", 17 | "text": "Learn how to obtain one at https://posit-dev.github.io/chatlas/reference/ChatAnthropic.html" 18 | }, 19 | { 20 | "type": "info", 21 | "text": "Want to learn more about AI chatbots?" 22 | }, 23 | { 24 | "type": "action", 25 | "text": "Visit https://shiny.posit.co/py/docs/genai-chatbots.html" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /gen-ai/basic-chat/app-core.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------------ 2 | # A basic Shiny Chat example powered by Anthropic's Claude model. 3 | # ------------------------------------------------------------------------------------ 4 | from chatlas import ChatAnthropic 5 | from dotenv import load_dotenv 6 | from shiny import App, ui 7 | 8 | # Set some Shiny page options 9 | app_ui = ui.page_fillable( 10 | ui.card( 11 | ui.card_header("Hello Anthropic Claude Chat"), 12 | ui.chat_ui("chat", messages=["Hello! How can I help you today?"], width="100%"), 13 | style="width:min(680px, 100%)", 14 | class_="mx-auto", 15 | ), 16 | fillable_mobile=True, 17 | class_="bg-light-subtle", 18 | ) 19 | 20 | 21 | def server(input, output, session): 22 | # ChatAnthropic() requires an API key from Anthropic. 23 | # See the docs for more information on how to obtain one. 24 | # https://posit-dev.github.io/chatlas/reference/ChatAnthropic.html 25 | _ = load_dotenv() 26 | chat_client = ChatAnthropic( 27 | system_prompt="You are a helpful assistant.", 28 | ) 29 | 30 | chat = ui.Chat(id="chat") 31 | 32 | # Generate a response when the user submits a message 33 | @chat.on_user_submit 34 | async def handle_user_input(user_input: str): 35 | response = await chat_client.stream_async(user_input) 36 | await chat.append_message_stream(response) 37 | 38 | 39 | app = App(app_ui, server) 40 | -------------------------------------------------------------------------------- /gen-ai/basic-chat/app-express.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------------ 2 | # A basic Shiny Chat example powered by Anthropic's Claude model. 3 | # ------------------------------------------------------------------------------------ 4 | from chatlas import ChatAnthropic 5 | from dotenv import load_dotenv 6 | from shiny.express import ui 7 | 8 | # ChatAnthropic() requires an API key from Anthropic. 9 | # See the docs for more information on how to obtain one. 10 | # https://posit-dev.github.io/chatlas/reference/ChatAnthropic.html 11 | _ = load_dotenv() 12 | chat_client = ChatAnthropic( 13 | system_prompt="You are a helpful assistant.", 14 | ) 15 | 16 | # Set some Shiny page options 17 | ui.page_opts( 18 | fillable=True, 19 | fillable_mobile=True, 20 | class_="bg-light-subtle", 21 | ) 22 | 23 | # Initialize Shiny chat component 24 | chat = ui.Chat(id="chat") 25 | 26 | # Display the chat in a card, with welcome message 27 | with ui.card(style="width:min(680px, 100%)", class_="mx-auto"): 28 | ui.card_header("Hello Anthropic Claude Chat") 29 | chat.ui( 30 | messages=["Hello! How can I help you today?"], 31 | width="100%", 32 | ) 33 | 34 | 35 | # Generate a response when the user submits a message 36 | @chat.on_user_submit 37 | async def handle_user_input(user_input: str): 38 | response = await chat_client.stream_async(user_input) 39 | await chat.append_message_stream(response) 40 | -------------------------------------------------------------------------------- /gen-ai/basic-chat/requirements.txt: -------------------------------------------------------------------------------- 1 | shiny 2 | dotenv 3 | chatlas[anthropic] 4 | # Temporary workaround so connect cloud will pick up latest version 5 | httpcore>=1.0.8 6 | -------------------------------------------------------------------------------- /gen-ai/basic-chat/template.env: -------------------------------------------------------------------------------- 1 | # Once you provided your API key, rename this file to .env 2 | # The load_dotenv() in the app.py will then load this env variable 3 | ANTHROPIC_API_KEY= 4 | -------------------------------------------------------------------------------- /gen-ai/basic-markdown-stream/_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "app", 3 | "id": "basic-markdown-stream", 4 | "title": "Stream responses from an Anthropic LLM", 5 | "next_steps": [ 6 | "Put your Anthropic API key in the `template.env` file and rename it to `.env`.", 7 | "Run the app with `shiny run app.py`." 8 | ], 9 | "follow_up": [ 10 | { 11 | "type": "info", 12 | "text": "Need help obtaining an API key?" 13 | }, 14 | { 15 | "type": "action", 16 | "text": "Learn how to obtain one at https://posit-dev.github.io/chatlas/reference/ChatAnthropic.html" 17 | }, 18 | { 19 | "type": "info", 20 | "text": "Want to learn more about streaming content?" 21 | }, 22 | { 23 | "type": "action", 24 | "text": "Visit https://shiny.posit.co/py/docs/genai-stream.html" 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /gen-ai/basic-markdown-stream/app-core.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------------ 2 | # A basic Shiny MarkdownStream example powered by Anthropic. 3 | # ------------------------------------------------------------------------------------ 4 | from chatlas import ChatAnthropic 5 | from dotenv import load_dotenv 6 | from shiny import App, Inputs, Outputs, Session, reactive, ui 7 | 8 | # Define UI 9 | app_ui = ui.page_sidebar( 10 | ui.sidebar( 11 | ui.input_select( 12 | "comic", 13 | "Choose a comedian", 14 | choices=["Jerry Seinfeld", "Ali Wong", "Mitch Hedberg"], 15 | ), 16 | ui.input_action_button("go", "Tell me a joke", class_="btn-primary"), 17 | ), 18 | ui.output_markdown_stream( 19 | "my_stream", 20 | content="Press the button and I'll tell you a joke.", 21 | ), 22 | title="AI Joke Generator", 23 | ) 24 | 25 | 26 | # Define server 27 | def server(input: Inputs, output: Outputs, session: Session): 28 | # ChatAnthropic() requires an API key from Anthropic. 29 | # See the docs for more information on how to obtain one. 30 | # https://posit-dev.github.io/chatlas/reference/ChatAnthropic.html 31 | _ = load_dotenv() 32 | chat_client = ChatAnthropic() 33 | 34 | # Create a MarkdownStream 35 | stream = ui.MarkdownStream(id="my_stream") 36 | 37 | # Clicking the button triggers the streaming joke generation 38 | @reactive.effect 39 | @reactive.event(input.go) 40 | async def do_joke(): 41 | prompt = f"Pretend you are {input.comic()} and tell me a funny joke." 42 | response = await chat_client.stream_async(prompt) 43 | await stream.stream(response) 44 | 45 | 46 | # Create the Shiny app 47 | app = App(app_ui, server) 48 | -------------------------------------------------------------------------------- /gen-ai/basic-markdown-stream/app-express.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------------ 2 | # A basic Shiny MarkdownStream example powered by Anthropic. 3 | # ------------------------------------------------------------------------------------ 4 | from chatlas import ChatAnthropic 5 | from dotenv import load_dotenv 6 | from shiny import reactive 7 | from shiny.express import input, ui 8 | 9 | # ChatAnthropic() requires an API key from Anthropic. 10 | # See the docs for more information on how to obtain one. 11 | # https://posit-dev.github.io/chatlas/reference/ChatAnthropic.html 12 | _ = load_dotenv() 13 | chat_client = ChatAnthropic() 14 | 15 | ui.page_opts( 16 | title="AI Joke Generator", 17 | ) 18 | 19 | # Some sidebar input controls to populate a prompt and trigger the stream 20 | with ui.sidebar(): 21 | ui.input_select( 22 | "comic", 23 | "Choose a comedian", 24 | choices=["Jerry Seinfeld", "Ali Wong", "Mitch Hedberg"], 25 | ) 26 | ui.input_action_button("go", "Tell me a joke", class_="btn-primary") 27 | 28 | # Create and display a MarkdownStream() 29 | stream = ui.MarkdownStream(id="my_stream") 30 | stream.ui( 31 | content="Press the button and I'll tell you a joke.", 32 | ) 33 | 34 | 35 | # Clicking the button triggers the streaming joke generation 36 | @reactive.effect 37 | @reactive.event(input.go) 38 | async def do_joke(): 39 | prompt = f"Pretend you are {input.comic()} and tell me a funny joke." 40 | response = await chat_client.stream_async(prompt) 41 | await stream.stream(response) 42 | -------------------------------------------------------------------------------- /gen-ai/basic-markdown-stream/requirements.txt: -------------------------------------------------------------------------------- 1 | shiny 2 | dotenv 3 | chatlas[anthropic] 4 | # Temporary workaround so connect cloud will pick up latest version 5 | httpcore>=1.0.8 6 | -------------------------------------------------------------------------------- /gen-ai/basic-markdown-stream/template.env: -------------------------------------------------------------------------------- 1 | # Once you provided your API key, rename this file to .env 2 | # The load_dotenv() in the app.py will then load this env variable 3 | ANTHROPIC_API_KEY= 4 | -------------------------------------------------------------------------------- /gen-ai/data-sci-adventure/_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "data-sci-adventure", 3 | "title": "Data Science Adventure", 4 | "description": "Choose your own data science adventure.", 5 | "next_steps": [ 6 | "Put your Anthropic API key in the `template.env` file and rename it to `.env`.", 7 | "Run the app with `shiny run app.py`." 8 | ], 9 | "follow_up": [ 10 | { 11 | "type": "info", 12 | "text": "Need help obtaining an API key?" 13 | }, 14 | { 15 | "type": "action", 16 | "text": "Learn how to obtain one at https://posit-dev.github.io/chatlas/reference/ChatAnthropic.html" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /gen-ai/data-sci-adventure/app-core.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from chatlas import ChatAnthropic 4 | from dotenv import load_dotenv 5 | from faicons import icon_svg 6 | from shiny import reactive 7 | from shiny.express import input, ui 8 | 9 | # Create a ChatAnthropic client with a system prompt 10 | app_dir = Path(__file__).parent 11 | with open(app_dir / "prompt.md") as f: 12 | system_prompt = f.read() 13 | 14 | _ = load_dotenv() 15 | chat_client = ChatAnthropic( 16 | system_prompt=system_prompt, 17 | ) 18 | 19 | # Set some page level Shiny options 20 | ui.page_opts( 21 | title="Choose your own data science adventure", 22 | fillable=True, 23 | fillable_mobile=True, 24 | ) 25 | 26 | # Create a sidebar with inputs that control the "starting" user prompt 27 | with ui.sidebar(id="sidebar"): 28 | ui.input_selectize( 29 | "company_role", 30 | "You are a...", 31 | choices=[ 32 | "Machine Learning Engineer", 33 | "Data Analyst", 34 | "Research Scientist", 35 | "MLOps Engineer", 36 | "Data Science Generalist", 37 | ], 38 | selected="Data Analyst", 39 | ) 40 | 41 | ui.input_selectize( 42 | "company_size", 43 | "who works for...", 44 | choices=[ 45 | "yourself", 46 | "a startup", 47 | "a university", 48 | "a small business", 49 | "a medium-sized business", 50 | "a large business", 51 | "an enterprise corporation", 52 | ], 53 | selected="a medium-sized business", 54 | ) 55 | 56 | ui.input_selectize( 57 | "company_industry", 58 | "in the ... industry", 59 | choices=[ 60 | "Healthcare and Pharmaceuticals", 61 | "Banking, Financial Services, and Insurance", 62 | "Technology and Software", 63 | "Retail and E-commerce", 64 | "Media and Entertainment", 65 | "Telecommunications", 66 | "Automotive and Manufacturing", 67 | "Energy and Oil & Gas", 68 | "Agriculture and Food Production", 69 | "Cybersecurity and Defense", 70 | ], 71 | selected="Healthcare and Pharmaceuticals", 72 | ) 73 | 74 | ui.input_action_button( 75 | "go", 76 | "Start adventure", 77 | icon=icon_svg("play"), 78 | class_="btn btn-primary", 79 | ) 80 | 81 | 82 | # Create and display chat 83 | welcome = """ 84 | Welcome to a choose-your-own learning adventure in data science! 85 | Please pick your role, the size of the company you work for, and the industry you're in. 86 | Then click the "Start adventure" button to begin. 87 | """ 88 | chat = ui.Chat(id="chat") 89 | chat.ui(messages=[welcome]) 90 | 91 | 92 | # The 'starting' user prompt is a function of the inputs 93 | @reactive.calc 94 | def starting_prompt(): 95 | return ( 96 | f"I want a story that features a {input.company_role()} " 97 | f"who works for {input.company_size()} in the {input.company_industry()} industry." 98 | ) 99 | 100 | 101 | # Has the adventure started? 102 | has_started: reactive.value[bool] = reactive.value(False) 103 | 104 | 105 | # When the user clicks the 'go' button, start/restart the adventure 106 | @reactive.effect 107 | @reactive.event(input.go) 108 | async def _(): 109 | if has_started(): 110 | await chat.clear_messages() 111 | await chat.append_message(welcome) 112 | chat.update_user_input(value=starting_prompt(), submit=True) 113 | chat.update_user_input(value="", focus=True) 114 | has_started.set(True) 115 | 116 | 117 | @reactive.effect 118 | async def _(): 119 | if has_started(): 120 | ui.update_action_button( 121 | "go", label="Restart adventure", icon=icon_svg("repeat") 122 | ) 123 | ui.update_sidebar("sidebar", show=False) 124 | else: 125 | chat.update_user_input(value=starting_prompt()) 126 | 127 | 128 | @chat.on_user_submit 129 | async def _(user_input: str): 130 | n_msgs = len(chat.messages()) 131 | if n_msgs == 1: 132 | user_input += ( 133 | " Please jump right into the story without any greetings or introductions." 134 | ) 135 | elif n_msgs == 4: 136 | user_input += ". Time to nudge this story toward its conclusion. Give one more scenario (like creating a report, dashboard, or presentation) that will let me wrap this up successfully." 137 | elif n_msgs == 5: 138 | user_input += ". Time to wrap this up. Conclude the story in the next step and offer to summarize the chat or create example scripts in R or Python. Consult your instructions for the correct format. If the user asks for code, remember that you'll need to create simulated data that matches the story." 139 | 140 | response = chat_client.stream(user_input) 141 | await chat.append_message_stream(response) 142 | -------------------------------------------------------------------------------- /gen-ai/data-sci-adventure/app-express.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from chatlas import ChatAnthropic 4 | from dotenv import load_dotenv 5 | from faicons import icon_svg 6 | from shiny import App, Inputs, reactive, ui 7 | 8 | welcome = """ 9 | Welcome to a choose-your-own learning adventure in data science! 10 | Please pick your role, the size of the company you work for, and the industry you're in. 11 | Then click the "Start adventure" button to begin. 12 | """ 13 | 14 | app_ui = ui.page_sidebar( 15 | ui.sidebar( 16 | ui.input_selectize( 17 | "company_role", 18 | "You are a...", 19 | choices=[ 20 | "Machine Learning Engineer", 21 | "Data Analyst", 22 | "Research Scientist", 23 | "MLOps Engineer", 24 | "Data Science Generalist", 25 | ], 26 | selected="Data Analyst", 27 | ), 28 | ui.input_selectize( 29 | "company_size", 30 | "who works for...", 31 | choices=[ 32 | "yourself", 33 | "a startup", 34 | "a university", 35 | "a small business", 36 | "a medium-sized business", 37 | "a large business", 38 | "an enterprise corporation", 39 | ], 40 | selected="a medium-sized business", 41 | ), 42 | ui.input_selectize( 43 | "company_industry", 44 | "in the ... industry", 45 | choices=[ 46 | "Healthcare and Pharmaceuticals", 47 | "Banking, Financial Services, and Insurance", 48 | "Technology and Software", 49 | "Retail and E-commerce", 50 | "Media and Entertainment", 51 | "Telecommunications", 52 | "Automotive and Manufacturing", 53 | "Energy and Oil & Gas", 54 | "Agriculture and Food Production", 55 | "Cybersecurity and Defense", 56 | ], 57 | selected="Healthcare and Pharmaceuticals", 58 | ), 59 | ui.input_action_button( 60 | "go", 61 | "Start adventure", 62 | icon=icon_svg("play"), 63 | class_="btn btn-primary", 64 | ), 65 | id="sidebar", 66 | ), 67 | ui.chat_ui("chat", messages=[welcome]), 68 | title="Choose your own data science adventure", 69 | fillable=True, 70 | fillable_mobile=True, 71 | ) 72 | 73 | 74 | def server(input: Inputs): 75 | # Create a ChatAnthropic client with a system prompt 76 | app_dir = Path(__file__).parent 77 | with open(app_dir / "prompt.md") as f: 78 | system_prompt = f.read() 79 | 80 | _ = load_dotenv() 81 | chat_client = ChatAnthropic( 82 | system_prompt=system_prompt, 83 | ) 84 | 85 | chat = ui.Chat(id="chat") 86 | 87 | # The 'starting' user prompt is a function of the inputs 88 | @reactive.calc 89 | def starting_prompt(): 90 | return ( 91 | f"I want a story that features a {input.company_role()} " 92 | f"who works for {input.company_size()} in the {input.company_industry()} industry." 93 | ) 94 | 95 | # Has the adventure started? 96 | has_started: reactive.value[bool] = reactive.value(False) 97 | 98 | # When the user clicks the 'go' button, start/restart the adventure 99 | @reactive.effect 100 | @reactive.event(input.go) 101 | async def _(): 102 | if has_started(): 103 | await chat.clear_messages() 104 | await chat.append_message(welcome) 105 | chat.update_user_input(value=starting_prompt(), submit=True) 106 | chat.update_user_input(value="", focus=True) 107 | has_started.set(True) 108 | 109 | @reactive.effect 110 | async def _(): 111 | if has_started(): 112 | ui.update_action_button( 113 | "go", label="Restart adventure", icon=icon_svg("repeat") 114 | ) 115 | ui.update_sidebar("sidebar", show=False) 116 | else: 117 | chat.update_user_input(value=starting_prompt()) 118 | 119 | @chat.on_user_submit 120 | async def _(user_input: str): 121 | n_msgs = len(chat.messages()) 122 | if n_msgs == 1: 123 | user_input += " Please jump right into the story without any greetings or introductions." 124 | elif n_msgs == 4: 125 | user_input += ". Time to nudge this story toward its conclusion. Give one more scenario (like creating a report, dashboard, or presentation) that will let me wrap this up successfully." 126 | elif n_msgs == 5: 127 | user_input += ". Time to wrap this up. Conclude the story in the next step and offer to summarize the chat or create example scripts in R or Python. Consult your instructions for the correct format. If the user asks for code, remember that you'll need to create simulated data that matches the story." 128 | 129 | response = chat_client.stream(user_input) 130 | await chat.append_message_stream(response) 131 | 132 | 133 | app = App(app_ui, server) 134 | -------------------------------------------------------------------------------- /gen-ai/data-sci-adventure/prompt.md: -------------------------------------------------------------------------------- 1 | You are an AI guide creating an interactive "choose-your-own adventure" experience for 2 | data scientists. Your task is to present scenarios and choices that allow the user to 3 | navigate through a series of real-world data science challenges and situations to 4 | complete a successful data science project. 5 | 6 | Follow these steps to continue the adventure: 7 | 8 | 1. Read the user's input carefully. 9 | 10 | 2. Create 2-3 distinct choices for the user, considering: 11 | 12 | - Select appropriate emojis for each choice 13 | - Determine which part of each choice should be marked as a clickable suggestion 14 | 15 | 3. Present a short scenario (2-3 sentences) that builds upon the user's previous choice 16 | or input. 17 | 18 | 4. Offer 2-3 choices for the user to select from. Each choice should be formatted as 19 | follows: 20 | 21 | - Begin with a single, relevant emoji 22 | - Wrap the clickable part of the suggestion (the part that makes sense as a user response) in a span with class "suggestion" 23 | - The entire choice, including the emoji and any additional context, should be on a single line 24 | 25 | Example format (do not use this content, create your own): 26 | 27 | * 📊 Analyze the data using regression analysis to identify trends 28 | * 🧮 Apply clustering algorithms to segment the customer base 29 | * 🔬 Conduct A/B testing on the new feature 30 | 31 | 5. Ensure that your scenario and choices are creative, clear, and concise, while 32 | remaining relevant to data science topics. 33 | 34 | 6. Your goal is to guide the user to the end of a successful data science project that 35 | is completed in 3-4 turns. 36 | 37 | Remember, the user will continue the adventure by selecting one of the choices you 38 | present. Be prepared to build upon any of the options in future interactions. 39 | 40 | When the story reaches its conclusion, offer to summarize the conversation or to create 41 | an example analysis using R or Python, but lean towards using Python. In Python, focus 42 | on using polars and plotnine. In R, focus on tidyverse tools like dplyr and ggplot2. 43 | Dashboards should be built with Shiny for Python or Shiny for R. Reports should be 44 | written using Quarto. 45 | 46 | Remember to use the "suggestion submit" class for adventure steps and the "suggestion" 47 | class for the summary choices. 48 | -------------------------------------------------------------------------------- /gen-ai/data-sci-adventure/requirements.txt: -------------------------------------------------------------------------------- 1 | shiny 2 | dotenv 3 | faicons 4 | chatlas[anthropic] 5 | # Temporary workaround so connect cloud will pick up latest version 6 | httpcore>=1.0.8 7 | -------------------------------------------------------------------------------- /gen-ai/data-sci-adventure/template.env: -------------------------------------------------------------------------------- 1 | # Once you provided your API key, rename this file to .env 2 | # The load_dotenv() in the app.py will then load this env variable 3 | ANTHROPIC_API_KEY= 4 | 5 | -------------------------------------------------------------------------------- /gen-ai/dinner-recipe/_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "dinner-recipe", 3 | "title": "What's for Dinner?", 4 | "description": "Guided dinner recipe generator.", 5 | "next_steps": [ 6 | "Put your OpenAI API key in the `template.env` file and rename it to `.env`.", 7 | "Run the app with `shiny run app.py`." 8 | ], 9 | "follow_up": [ 10 | { 11 | "type": "info", 12 | "text": "Need help obtaining an API key?" 13 | }, 14 | { 15 | "type": "action", 16 | "text": "Learn how to obtain one at https://posit-dev.github.io/chatlas/reference/ChatOpenAI.html" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /gen-ai/dinner-recipe/app-core.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import shinyswatch 4 | from chatlas import ChatOpenAI 5 | from dotenv import load_dotenv 6 | from faicons import icon_svg 7 | from pydantic import BaseModel 8 | from shiny import App, Inputs, reactive, render, ui 9 | 10 | welcome = """ 11 | Hello! I'm here to help you find something to cook for dinner, or really any other meal you're planning. 12 | When you're ready, click the "Extract recipe" button to receive a structured form of the recipe. 13 | 14 | Here are some examples of what you can ask me: 15 | 16 | - What can I make with chicken, broccoli, and rice? 17 | - What ingredients do I need to make lasagna? 18 | - How do I make chocolate chip cookies? 19 | """ 20 | 21 | 22 | # Page level UI options 23 | app_ui = ui.page_fillable( 24 | ui.card( 25 | ui.card_header("What's for dinner?", class_="bg-primary fs-4 lead"), 26 | ui.chat_ui( 27 | "chat", 28 | messages=[welcome], 29 | placeholder="What are you in the mood for?", 30 | width="min(900px, 100%)", 31 | icon_assistant=icon_svg("utensils"), 32 | ), 33 | ui.card_footer( 34 | ui.input_action_button( 35 | "clear", 36 | "Clear chat", 37 | class_="btn-outline-danger btn-sm", 38 | icon=icon_svg("trash"), 39 | disabled=True, 40 | ), 41 | ui.input_action_button( 42 | "extract_recipe", 43 | "Extract recipe", 44 | icon=icon_svg("download"), 45 | disabled=True, 46 | class_="btn-outline-primary btn-sm", 47 | ), 48 | class_="d-flex gap-3", 49 | ), 50 | style="width:min(720px, 100%)", 51 | class_="mx-auto", 52 | ), 53 | # Bump up link color contrast (for Minty theme) 54 | ui.tags.style(":root { --bs-link-color: #4e7e71; }"), 55 | fillable_mobile=True, 56 | theme=shinyswatch.theme.minty, 57 | class_="bg-primary-subtle", 58 | ) 59 | 60 | 61 | def server(input: Inputs): 62 | 63 | # Load the system prompt for the cooking assistant 64 | app_dir = Path(__file__).parent 65 | with open(app_dir / "prompt.md") as f: 66 | system_prompt = f.read() 67 | 68 | # Connect to OpenAI 69 | _ = load_dotenv() 70 | chat_client = ChatOpenAI(system_prompt=system_prompt) 71 | 72 | chat = ui.Chat(id="chat") 73 | 74 | # Respond to user input 75 | @chat.on_user_submit 76 | async def _(user_input: str): 77 | response = await chat_client.stream_async(user_input) 78 | await chat.append_message_stream(response) 79 | 80 | # Clear the chat via "Clear chat" button 81 | @reactive.effect 82 | @reactive.event(input.clear) 83 | async def _(): 84 | chat_client.set_turns([]) 85 | await chat.clear_messages() 86 | await chat.append_message(welcome) 87 | 88 | # Enable the action buttons when we get our first result 89 | @reactive.effect 90 | def _(): 91 | if not chat.latest_message_stream.result(): 92 | return 93 | ui.update_action_button("extract_recipe", disabled=False) 94 | ui.update_action_button("clear", disabled=False) 95 | 96 | # Define the recipe schema using Pydantic 97 | class Ingredient(BaseModel): 98 | name: str 99 | amount: str 100 | unit: str 101 | 102 | class RecipeStep(BaseModel): 103 | step_number: int 104 | instruction: str 105 | 106 | class Recipe(BaseModel): 107 | name: str 108 | description: str 109 | ingredients: list[Ingredient] 110 | steps: list[RecipeStep] 111 | prep_time: str 112 | cook_time: str 113 | servings: int 114 | 115 | _ = Recipe.model_rebuild() 116 | 117 | # Extract a recipe from the latest response 118 | @reactive.calc 119 | @reactive.event(input.extract_recipe) 120 | async def parsed_recipe(): 121 | return await chat_client.extract_data_async( 122 | chat.latest_message_stream.result(), 123 | data_model=Recipe, 124 | ) 125 | 126 | @reactive.calc 127 | async def recipe_message(): 128 | recipe = await parsed_recipe() 129 | 130 | if not recipe: 131 | return "Unable to extract a recipe from the chat. Please try again." 132 | 133 | ingredients = [ 134 | ui.tags.li(f'{ing["amount"]} {ing["unit"]} {ing["name"]}') 135 | for ing in recipe["ingredients"] 136 | ] 137 | 138 | instructions = [ 139 | ui.tags.li( 140 | step["instruction"], 141 | style="list-style-type: decimal; margin-left: 20px;", 142 | ) 143 | for step in recipe["steps"] 144 | ] 145 | 146 | return f""" 147 | Gather your ingredients and let's get started! 🍳 148 | 149 | ### {recipe["name"]} 150 | 151 | {recipe["description"]} 152 | 153 | #### Ingredients: 154 | 155 | {ui.tags.ul(ingredients)} 156 | 157 | #### Instructions: 158 | 159 | {ui.tags.ol(instructions)} 160 | 161 | Prep time: {recipe["prep_time"]} 162 | Cook time: {recipe["cook_time"]} 163 | Servings: {recipe["servings"]} 164 | 165 | Enjoy your meal! 🍽️ 166 | 167 | Want to save this recipe? Click the "Save recipe" button below. 168 | 169 | {ui.download_button("download_handler", "Download recipe")} 170 | """ 171 | 172 | @reactive.effect 173 | @reactive.event(input.extract_recipe) 174 | async def _(): 175 | async with chat.message_stream_context() as stream: 176 | await stream.append("Cooking up a recipe for you!") 177 | await stream.replace(await recipe_message()) 178 | 179 | @render.download(filename="recipe.json") 180 | async def download_handler(): 181 | import json 182 | 183 | recipe = await parsed_recipe() 184 | yield json.dumps(recipe, indent=2) 185 | 186 | 187 | app = App(app_ui, server) 188 | -------------------------------------------------------------------------------- /gen-ai/dinner-recipe/app-express.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import shinyswatch 4 | from chatlas import ChatOpenAI 5 | from dotenv import load_dotenv 6 | from faicons import icon_svg 7 | from pydantic import BaseModel 8 | from shiny import reactive 9 | from shiny.express import input, render, ui 10 | 11 | # Load the system prompt for the cooking assistant 12 | app_dir = Path(__file__).parent 13 | with open(app_dir / "prompt.md") as f: 14 | system_prompt = f.read() 15 | 16 | # Connect to OpenAI 17 | _ = load_dotenv() 18 | chat_client = ChatOpenAI(system_prompt=system_prompt) 19 | 20 | # Page level UI options 21 | ui.page_opts( 22 | fillable=True, 23 | fillable_mobile=True, 24 | theme=shinyswatch.theme.minty, 25 | class_="bg-primary-subtle", 26 | ) 27 | 28 | # Bump up link color contrast (for Minty theme) 29 | ui.tags.style(":root { --bs-link-color: #4e7e71; }") 30 | 31 | chat = ui.Chat(id="chat") 32 | 33 | welcome_message = """ 34 | Hello! I'm here to help you find something to cook for dinner, or really any other meal you're planning. 35 | When you're ready, click the "Extract recipe" button to receive a structured form of the recipe. 36 | 37 | Here are some examples of what you can ask me: 38 | 39 | - What can I make with chicken, broccoli, and rice? 40 | - What ingredients do I need to make lasagna? 41 | - How do I make chocolate chip cookies? 42 | """ 43 | 44 | 45 | # Display chat in a card 46 | with ui.card(full_screen=True, style="width:min(720px, 100%)", class_="mx-auto"): 47 | ui.card_header("What's for dinner?", class_="bg-primary fs-4 lead") 48 | chat.ui( 49 | messages=[welcome_message], 50 | placeholder="What are you in the mood for?", 51 | width="min(900px, 100%)", 52 | icon_assistant=icon_svg("utensils"), 53 | ) 54 | with ui.card_footer(class_="d-flex gap-3"): 55 | ui.input_action_button( 56 | "clear", 57 | "Clear chat", 58 | class_="btn-outline-danger btn-sm", 59 | icon=icon_svg("trash"), 60 | disabled=True, 61 | ) 62 | ui.input_action_button( 63 | "extract_recipe", 64 | "Extract recipe", 65 | icon=icon_svg("download"), 66 | disabled=True, 67 | class_="btn-outline-primary btn-sm", 68 | ) 69 | 70 | 71 | # Respond to user input 72 | @chat.on_user_submit 73 | async def _(user_input: str): 74 | response = await chat_client.stream_async(user_input) 75 | await chat.append_message_stream(response) 76 | 77 | 78 | # Clear the chat via "Clear chat" button 79 | @reactive.effect 80 | @reactive.event(input.clear) 81 | async def _(): 82 | chat_client.set_turns([]) 83 | await chat.clear_messages() 84 | await chat.append_message(welcome_message) 85 | 86 | 87 | # Enable the action buttons when we get our first result 88 | @reactive.effect 89 | def _(): 90 | if not chat.latest_message_stream.result(): 91 | return 92 | ui.update_action_button("extract_recipe", disabled=False) 93 | ui.update_action_button("clear", disabled=False) 94 | 95 | 96 | # Define the recipe schema using Pydantic 97 | class Ingredient(BaseModel): 98 | name: str 99 | amount: str 100 | unit: str 101 | 102 | 103 | class RecipeStep(BaseModel): 104 | step_number: int 105 | instruction: str 106 | 107 | 108 | class Recipe(BaseModel): 109 | name: str 110 | description: str 111 | ingredients: list[Ingredient] 112 | steps: list[RecipeStep] 113 | prep_time: str 114 | cook_time: str 115 | servings: int 116 | 117 | 118 | _ = Recipe.model_rebuild() 119 | 120 | 121 | # Extract a recipe from the latest response 122 | @reactive.calc 123 | @reactive.event(input.extract_recipe) 124 | async def parsed_recipe(): 125 | return await chat_client.extract_data_async( 126 | chat.latest_message_stream.result(), 127 | data_model=Recipe, 128 | ) 129 | 130 | 131 | @reactive.calc 132 | async def recipe_message(): 133 | recipe = await parsed_recipe() 134 | 135 | if not recipe: 136 | return "Unable to extract a recipe from the chat. Please try again." 137 | 138 | ingredients = [ 139 | ui.tags.li(f'{ing["amount"]} {ing["unit"]} {ing["name"]}') 140 | for ing in recipe["ingredients"] 141 | ] 142 | 143 | instructions = [ 144 | ui.tags.li( 145 | step["instruction"], style="list-style-type: decimal; margin-left: 20px;" 146 | ) 147 | for step in recipe["steps"] 148 | ] 149 | 150 | return f""" 151 | Gather your ingredients and let's get started! 🍳 152 | 153 | ### {recipe["name"]} 154 | 155 | {recipe["description"]} 156 | 157 | #### Ingredients: 158 | 159 | {ui.tags.ul(ingredients)} 160 | 161 | #### Instructions: 162 | 163 | {ui.tags.ol(instructions)} 164 | 165 | Prep time: {recipe["prep_time"]} 166 | Cook time: {recipe["cook_time"]} 167 | Servings: {recipe["servings"]} 168 | 169 | Enjoy your meal! 🍽️ 170 | 171 | Want to save this recipe? Click the "Save recipe" button below. 172 | 173 | {download_recipe} 174 | """ 175 | 176 | 177 | @reactive.effect 178 | @reactive.event(input.extract_recipe) 179 | async def _(): 180 | async with chat.message_stream_context() as stream: 181 | await stream.append("Cooking up a recipe for you!") 182 | await stream.replace(await recipe_message()) 183 | 184 | 185 | with ui.hold() as download_recipe: 186 | 187 | @render.download(filename="recipe.json", label="Download recipe") 188 | async def download_handler(): 189 | import json 190 | 191 | recipe = await parsed_recipe() 192 | yield json.dumps(recipe, indent=2) 193 | -------------------------------------------------------------------------------- /gen-ai/dinner-recipe/prompt.md: -------------------------------------------------------------------------------- 1 | You are a knowledgable and pragmatic cooking and recipe assistant. 2 | 3 | Your primary goal is help the user find something to cook for dinner (or some other meal), and send them off on their task with a formal recipe. 4 | 5 | You can provide helpful cooking tips, suggested prompts, and answer questions about cooking and recipes. 6 | You should also prompt the user to provide more information about available ingredients, dietary restrictions, and other relevant details. 7 | 8 | Also, when first exploring options, don't necessary provide a detailed recipe right away. Instead, help the user explore their options and make a decision. This might include offering several high-level suggestions, then asking the user to choose one before providing a detailed recipe. 9 | 10 | ## Showing prompt suggestions 11 | 12 | If you find it appropriate to suggest prompts the user might want to submit, wrap the text of each prompt in `` tags. 13 | Also use "Suggested next steps:" to introduce the suggestions. For example: 14 | 15 | ``` 16 | Suggested next steps: 17 | 18 | 1. Suggestion 1. 19 | 2. Suggestion 2. 20 | 3. Suggestion 3. 21 | ``` 22 | -------------------------------------------------------------------------------- /gen-ai/dinner-recipe/requirements.txt: -------------------------------------------------------------------------------- 1 | shinyswatch 2 | shiny 3 | openai 4 | dotenv 5 | faicons 6 | chatlas[anthropic] 7 | # Temporary workaround so connect cloud will pick up latest version 8 | httpcore>=1.0.8 9 | -------------------------------------------------------------------------------- /gen-ai/dinner-recipe/template.env: -------------------------------------------------------------------------------- 1 | # Once you provided your API key, rename this file to .env 2 | # The load_dotenv() in the app.py will then load this env variable 3 | OPENAI_API_KEY= 4 | -------------------------------------------------------------------------------- /gen-ai/querychat/_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "querychat", 3 | "title": "Query Chat", 4 | "description": "Explore a dataset by chatting with a model.", 5 | "next_steps": [ 6 | "Put your Anthropic API key in the `template.env` file and rename it to `.env`.", 7 | "Run the app with `shiny run app.py`." 8 | ], 9 | "follow_up": [ 10 | { 11 | "type": "info", 12 | "text": "Need help obtaining an API key?" 13 | }, 14 | { 15 | "type": "action", 16 | "text": "Learn how to obtain one at https://posit-dev.github.io/chatlas/reference/ChatAnthropic.html" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /gen-ai/querychat/app-core.py: -------------------------------------------------------------------------------- 1 | import querychat 2 | from chatlas import ChatAnthropic 3 | from seaborn import load_dataset 4 | from shiny import App, render, ui 5 | 6 | titanic = load_dataset("titanic") 7 | 8 | 9 | def create_chat_callback(system_prompt): 10 | return ChatAnthropic(system_prompt=system_prompt) 11 | 12 | 13 | # Configure querychat 14 | querychat_config = querychat.init( 15 | titanic, 16 | "titanic", 17 | create_chat_callback=create_chat_callback, 18 | ) 19 | 20 | # Create UI 21 | app_ui = ui.page_sidebar( 22 | # 2. Place the chat component in the sidebar 23 | querychat.sidebar("chat", width=600), 24 | # Main panel with data viewer 25 | ui.card( 26 | ui.card_header("Titanic dataset"), 27 | ui.output_data_frame("data_table"), 28 | ), 29 | title="querychat with Python", 30 | fillable=True, 31 | ) 32 | 33 | 34 | # Define server logic 35 | def server(input, output, session): 36 | # 3. Initialize querychat server with the config from step 1 37 | chat = querychat.server("chat", querychat_config) 38 | 39 | # 4. Display the filtered dataframe 40 | @render.data_frame 41 | def data_table(): 42 | # Access filtered data via chat.df() reactive 43 | return chat["df"]() 44 | 45 | 46 | # Create Shiny app 47 | app = App(app_ui, server) 48 | -------------------------------------------------------------------------------- /gen-ai/querychat/requirements.txt: -------------------------------------------------------------------------------- 1 | shiny 2 | openai 3 | dotenv 4 | seaborn 5 | querychat @ git+https://github.com/posit-dev/querychat#subdirectory=python-package 6 | chatlas[anthropic] 7 | # Temporary workaround so connect cloud will pick up latest version 8 | httpcore>=1.0.8 9 | -------------------------------------------------------------------------------- /gen-ai/querychat/template.env: -------------------------------------------------------------------------------- 1 | # Once you provided your API key, rename this file to .env 2 | # The load_dotenv() in the app.py will then load this env variable 3 | ANTHROPIC_API_KEY= 4 | 5 | -------------------------------------------------------------------------------- /gen-ai/workout-plan/_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "workout-plan", 3 | "title": "AI Workout Plan", 4 | "description": "Generate a workout plan based on your fitness goals and preferences.", 5 | "next_steps": [ 6 | "Put your Anthropic API key in the `template.env` file and rename it to `.env`.", 7 | "Run the app with `shiny run app.py`." 8 | ], 9 | "follow_up": [ 10 | { 11 | "type": "info", 12 | "text": "Need help obtaining an API key?" 13 | }, 14 | { 15 | "type": "action", 16 | "text": "Learn how to obtain one at https://posit-dev.github.io/chatlas/reference/ChatAnthropic.html" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /gen-ai/workout-plan/app-core.py: -------------------------------------------------------------------------------- 1 | from chatlas import ChatAnthropic 2 | from dotenv import load_dotenv 3 | from faicons import icon_svg 4 | from shiny import App, Inputs, reactive, render, ui 5 | 6 | _ = load_dotenv() 7 | chat_client = ChatAnthropic( 8 | system_prompt=""" 9 | You are a helpful AI fitness coach. 10 | Give detailed workout plans to users based on their fitness goals and experience level. 11 | Before getting into details, give a brief introduction to the workout plan. 12 | Keep the overall tone encouraging and professional yet friendly. 13 | Generate the response in Markdown format and avoid using h1, h2, or h3. 14 | """, 15 | ) 16 | 17 | app_ui = ui.page_sidebar( 18 | ui.sidebar( 19 | ui.input_select( 20 | "goal", 21 | "Fitness Goal", 22 | ["Strength", "Cardio", "Flexibility", "General Fitness"], 23 | ), 24 | ui.input_selectize( 25 | "equipment", 26 | "Available Equipment", 27 | ["Dumbbells", "Barbell", "Resistance Bands", "Bodyweight"], 28 | multiple=True, 29 | selected=["Bodyweight", "Barbell"], 30 | ), 31 | ui.input_slider("experience", "Experience Level", min=1, max=10, value=5), 32 | ui.input_slider( 33 | "duration", "Duration (mins)", min=15, max=90, step=5, value=45 34 | ), 35 | ui.input_select( 36 | "daysPerWeek", 37 | "Days per Week", 38 | [str(i) for i in range(1, 8)], 39 | selected="3", 40 | ), 41 | ui.input_task_button( 42 | "generate", "Get Workout", icon=icon_svg("person-running") 43 | ), 44 | ui.output_ui("download_ui"), 45 | open={"mobile": "always-above"}, 46 | ), 47 | ui.output_markdown_stream( 48 | "workout_stream", 49 | content=( 50 | "Hi there! 👋 I'm your AI fitness coach. 💪" 51 | "\n\n" 52 | "Fill out the form in the sidebar to get started. 📝 🏋️‍♂ ️" 53 | ), 54 | ), 55 | title="Personalized Workout Plan Generator", 56 | ) 57 | 58 | 59 | def server(input: Inputs): 60 | 61 | workout_stream = ui.MarkdownStream("workout_stream") 62 | 63 | # When the user clicks the "Generate Workout" button, generate a workout plan 64 | @reactive.effect 65 | @reactive.event(input.generate) 66 | async def _(): 67 | prompt = f""" 68 | Generate a brief {input.duration()}-minute workout plan for a {input.goal()} fitness goal. 69 | On a scale of 1-10, I have a level {input.experience()} experience, 70 | works out {input.daysPerWeek()} days per week, and have access to: 71 | {", ".join(input.equipment()) if input.equipment() else "no equipment"}. 72 | Format the response in Markdown. 73 | """ 74 | 75 | await workout_stream.stream(await chat_client.stream_async(prompt)) 76 | 77 | @render.ui 78 | def download_ui(): 79 | _ = workout_stream.latest_stream.result() 80 | return ui.download_button("download", "Download Workout") 81 | 82 | @render.download(filename="workout_plan.md", label="Download Workout") 83 | def download(): 84 | yield workout_stream.latest_stream.result() 85 | 86 | 87 | app = App(app_ui, server) 88 | -------------------------------------------------------------------------------- /gen-ai/workout-plan/app-express.py: -------------------------------------------------------------------------------- 1 | from chatlas import ChatAnthropic 2 | from dotenv import load_dotenv 3 | from faicons import icon_svg 4 | from shiny import reactive 5 | from shiny.express import input, render, ui 6 | 7 | _ = load_dotenv() 8 | chat_client = ChatAnthropic( 9 | system_prompt=""" 10 | You are a helpful AI fitness coach. 11 | Give detailed workout plans to users based on their fitness goals and experience level. 12 | Before getting into details, give a brief introduction to the workout plan. 13 | Keep the overall tone encouraging and professional yet friendly. 14 | Generate the response in Markdown format and avoid using h1, h2, or h3. 15 | """, 16 | ) 17 | 18 | ui.page_opts(title="Personalized Workout Plan Generator") 19 | 20 | with ui.sidebar(open={"mobile": "always-above"}): 21 | ui.input_select( 22 | "goal", 23 | "Fitness Goal", 24 | ["Strength", "Cardio", "Flexibility", "General Fitness"], 25 | ) 26 | ui.input_selectize( 27 | "equipment", 28 | "Available Equipment", 29 | ["Dumbbells", "Barbell", "Resistance Bands", "Bodyweight"], 30 | multiple=True, 31 | selected=["Bodyweight", "Barbell"], 32 | ) 33 | ui.input_slider("experience", "Experience Level", min=1, max=10, value=5) 34 | ui.input_slider("duration", "Duration (mins)", min=15, max=90, step=5, value=45) 35 | ui.input_select( 36 | "daysPerWeek", 37 | "Days per Week", 38 | [str(i) for i in range(1, 8)], 39 | selected="3", 40 | ) 41 | ui.input_task_button("generate", "Get Workout", icon=icon_svg("person-running")) 42 | 43 | @render.express 44 | def download_ui(): 45 | plan = workout_stream.latest_stream.result() 46 | 47 | @render.download(filename="workout_plan.md", label="Download Workout") 48 | def download(): 49 | yield plan 50 | 51 | 52 | # Create a Markdown stream 53 | workout_stream = ui.MarkdownStream("workout_stream") 54 | 55 | # Display it in the main content area 56 | workout_stream.ui( 57 | content=( 58 | "Hi there! 👋 I'm your AI fitness coach. 💪" 59 | "\n\n" 60 | "Fill out the form in the sidebar to get started. 📝 🏋️‍♂ ️" 61 | ) 62 | ) 63 | 64 | 65 | # When the user clicks the "Generate Workout" button, generate a workout plan 66 | @reactive.effect 67 | @reactive.event(input.generate) 68 | async def _(): 69 | prompt = f""" 70 | Generate a brief {input.duration()}-minute workout plan for a {input.goal()} fitness goal. 71 | On a scale of 1-10, I have a level {input.experience()} experience, 72 | works out {input.daysPerWeek()} days per week, and have access to: 73 | {", ".join(input.equipment()) if input.equipment() else "no equipment"}. 74 | Format the response in Markdown. 75 | """ 76 | 77 | await workout_stream.stream(await chat_client.stream_async(prompt)) 78 | -------------------------------------------------------------------------------- /gen-ai/workout-plan/requirements.txt: -------------------------------------------------------------------------------- 1 | shiny 2 | dotenv 3 | faicons 4 | chatlas[anthropic] 5 | # Temporary workaround so connect cloud will pick up latest version 6 | httpcore 7 | -------------------------------------------------------------------------------- /gen-ai/workout-plan/template.env: -------------------------------------------------------------------------------- 1 | # Once you provided your API key, rename this file to .env 2 | # The load_dotenv() in the app.py will then load this env variable 3 | ANTHROPIC_API_KEY= 4 | 5 | -------------------------------------------------------------------------------- /map-distance/README.md: -------------------------------------------------------------------------------- 1 | ## Map distance app 2 | 3 | 4 | -------------------------------------------------------------------------------- /map-distance/_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "map-distance", 3 | "title": "Location distance calculator", 4 | "description": "Run queries on an SQL database and view the results in a data grid." 5 | } 6 | -------------------------------------------------------------------------------- /map-distance/app-core.py: -------------------------------------------------------------------------------- 1 | import ipyleaflet as L 2 | from faicons import icon_svg 3 | from geopy.distance import geodesic, great_circle 4 | from shared import BASEMAPS, CITIES 5 | from shiny import App, reactive, render, ui 6 | from shinywidgets import output_widget, render_widget 7 | 8 | city_names = sorted(list(CITIES.keys())) 9 | 10 | app_ui = ui.page_sidebar( 11 | ui.sidebar( 12 | ui.input_selectize( 13 | "loc1", "Location 1", choices=city_names, selected="New York" 14 | ), 15 | ui.input_selectize("loc2", "Location 2", choices=city_names, selected="London"), 16 | ui.input_selectize( 17 | "basemap", 18 | "Choose a basemap", 19 | choices=list(BASEMAPS.keys()), 20 | selected="WorldImagery", 21 | ), 22 | ui.input_dark_mode(mode="dark"), 23 | ), 24 | ui.layout_column_wrap( 25 | ui.value_box( 26 | "Great Circle Distance", 27 | ui.output_text("great_circle_dist"), 28 | theme="gradient-blue-indigo", 29 | showcase=icon_svg("globe"), 30 | ), 31 | ui.value_box( 32 | "Geodisic Distance", 33 | ui.output_text("geo_dist"), 34 | theme="gradient-blue-indigo", 35 | showcase=icon_svg("ruler"), 36 | ), 37 | ui.value_box( 38 | "Altitude Difference", 39 | ui.output_text("altitude"), 40 | theme="gradient-blue-indigo", 41 | showcase=icon_svg("mountain"), 42 | ), 43 | fill=False, 44 | ), 45 | ui.card( 46 | ui.card_header("Map (drag the markers to change locations)"), 47 | output_widget("map"), 48 | ), 49 | title="Location Distance Calculator", 50 | fillable=True, 51 | class_="bslib-page-dashboard", 52 | ) 53 | 54 | 55 | def server(input, output, session): 56 | # Reactive values to store location information 57 | loc1 = reactive.value() 58 | loc2 = reactive.value() 59 | 60 | # Update the reactive values when the selectize inputs change 61 | @reactive.effect 62 | def _(): 63 | loc1.set(CITIES.get(input.loc1(), loc_str_to_coords(input.loc1()))) 64 | loc2.set(CITIES.get(input.loc2(), loc_str_to_coords(input.loc2()))) 65 | 66 | # When a marker is moved, the input value gets updated to "lat, lon", 67 | # so we decode that into a dict (and also look up the altitude) 68 | def loc_str_to_coords(x: str) -> dict: 69 | latlon = x.split(", ") 70 | if len(latlon) != 2: 71 | return {} 72 | 73 | lat = float(latlon[0]) 74 | lon = float(latlon[1]) 75 | 76 | try: 77 | import requests 78 | 79 | query = ( 80 | f"https://api.open-elevation.com/api/v1/lookup?locations={lat},{lon}" 81 | ) 82 | r = requests.get(query).json() 83 | altitude = r["results"][0]["elevation"] 84 | except Exception: 85 | altitude = None 86 | 87 | return {"latitude": lat, "longitude": lon, "altitude": altitude} 88 | 89 | # Convenient way to get the lat/lons as a tuple 90 | @reactive.calc 91 | def loc1xy(): 92 | return loc1()["latitude"], loc1()["longitude"] 93 | 94 | @reactive.calc 95 | def loc2xy(): 96 | return loc2()["latitude"], loc2()["longitude"] 97 | 98 | # Circle distance value box 99 | @render.text 100 | def great_circle_dist(): 101 | circle = great_circle(loc1xy(), loc2xy()) 102 | return f"{circle.kilometers.__round__(1)} km" 103 | 104 | # Geodesic distance value box 105 | @render.text 106 | def geo_dist(): 107 | dist = geodesic(loc1xy(), loc2xy()) 108 | return f"{dist.kilometers.__round__(1)} km" 109 | 110 | @render.text 111 | def altitude(): 112 | try: 113 | return f'{loc1()["altitude"] - loc2()["altitude"]} m' 114 | except TypeError: 115 | return "N/A (altitude lookup failed)" 116 | 117 | # For performance, render the map once and then perform partial updates 118 | # via reactive side-effects 119 | @render_widget 120 | def map(): 121 | return L.Map(zoom=4, center=(0, 0)) 122 | 123 | # Add marker for first location 124 | @reactive.effect 125 | def _(): 126 | update_marker(map.widget, loc1xy(), on_move1, "loc1") 127 | 128 | # Add marker for second location 129 | @reactive.effect 130 | def _(): 131 | update_marker(map.widget, loc2xy(), on_move2, "loc2") 132 | 133 | # Add line and fit bounds when either marker is moved 134 | @reactive.effect 135 | def _(): 136 | update_line(map.widget, loc1xy(), loc2xy()) 137 | 138 | # If new bounds fall outside of the current view, fit the bounds 139 | @reactive.effect 140 | def _(): 141 | l1 = loc1xy() 142 | l2 = loc2xy() 143 | 144 | lat_rng = [min(l1[0], l2[0]), max(l1[0], l2[0])] 145 | lon_rng = [min(l1[1], l2[1]), max(l1[1], l2[1])] 146 | new_bounds = [ 147 | [lat_rng[0], lon_rng[0]], 148 | [lat_rng[1], lon_rng[1]], 149 | ] 150 | 151 | b = map.widget.bounds 152 | if len(b) == 0: 153 | map.widget.fit_bounds(new_bounds) 154 | elif ( 155 | lat_rng[0] < b[0][0] 156 | or lat_rng[1] > b[1][0] 157 | or lon_rng[0] < b[0][1] 158 | or lon_rng[1] > b[1][1] 159 | ): 160 | map.widget.fit_bounds(new_bounds) 161 | 162 | # Update the basemap 163 | @reactive.effect 164 | def _(): 165 | update_basemap(map.widget, input.basemap()) 166 | 167 | # --------------------------------------------------------------- 168 | # Helper functions 169 | # --------------------------------------------------------------- 170 | def update_marker(map: L.Map, loc: tuple, on_move: object, name: str): 171 | remove_layer(map, name) 172 | m = L.Marker(location=loc, draggable=True, name=name) 173 | m.on_move(on_move) 174 | map.add_layer(m) 175 | 176 | def update_line(map: L.Map, loc1: tuple, loc2: tuple): 177 | remove_layer(map, "line") 178 | map.add_layer( 179 | L.Polyline(locations=[loc1, loc2], color="blue", weight=2, name="line") 180 | ) 181 | 182 | def update_basemap(map: L.Map, basemap: str): 183 | for layer in map.layers: 184 | if isinstance(layer, L.TileLayer): 185 | map.remove_layer(layer) 186 | map.add_layer(L.basemap_to_tiles(BASEMAPS[input.basemap()])) 187 | 188 | def remove_layer(map: L.Map, name: str): 189 | for layer in map.layers: 190 | if layer.name == name: 191 | map.remove_layer(layer) 192 | 193 | def on_move1(**kwargs): 194 | return on_move("loc1", **kwargs) 195 | 196 | def on_move2(**kwargs): 197 | return on_move("loc2", **kwargs) 198 | 199 | # When the markers are moved, update the selectize inputs to include the new 200 | # location (which results in the locations() reactive value getting updated, 201 | # which invalidates any downstream reactivity that depends on it) 202 | def on_move(id, **kwargs): 203 | loc = kwargs["location"] 204 | loc_str = f"{loc[0]}, {loc[1]}" 205 | choices = city_names + [loc_str] 206 | ui.update_selectize(id, selected=loc_str, choices=choices) 207 | 208 | 209 | app = App(app_ui, server) 210 | -------------------------------------------------------------------------------- /map-distance/app-express.py: -------------------------------------------------------------------------------- 1 | import ipyleaflet as L 2 | from faicons import icon_svg 3 | from geopy.distance import geodesic, great_circle 4 | from shared import BASEMAPS, CITIES 5 | from shiny import reactive 6 | from shiny.express import input, render, ui 7 | from shinywidgets import render_widget 8 | 9 | city_names = sorted(list(CITIES.keys())) 10 | 11 | ui.page_opts(title="Location Distance Calculator", fillable=True) 12 | {"class": "bslib-page-dashboard"} 13 | 14 | with ui.sidebar(): 15 | ui.input_selectize("loc1", "Location 1", choices=city_names, selected="New York") 16 | ui.input_selectize("loc2", "Location 2", choices=city_names, selected="London") 17 | ui.input_selectize( 18 | "basemap", 19 | "Choose a basemap", 20 | choices=list(BASEMAPS.keys()), 21 | selected="WorldImagery", 22 | ) 23 | ui.input_dark_mode(mode="dark") 24 | 25 | with ui.layout_column_wrap(fill=False): 26 | with ui.value_box(showcase=icon_svg("globe"), theme="gradient-blue-indigo"): 27 | "Great Circle Distance" 28 | 29 | @render.text 30 | def great_circle_dist(): 31 | circle = great_circle(loc1xy(), loc2xy()) 32 | return f"{circle.kilometers.__round__(1)} km" 33 | 34 | with ui.value_box(showcase=icon_svg("ruler"), theme="gradient-blue-indigo"): 35 | "Geodisic Distance" 36 | 37 | @render.text 38 | def geo_dist(): 39 | dist = geodesic(loc1xy(), loc2xy()) 40 | return f"{dist.kilometers.__round__(1)} km" 41 | 42 | with ui.value_box(showcase=icon_svg("mountain"), theme="gradient-blue-indigo"): 43 | "Altitude Difference" 44 | 45 | @render.text 46 | def altitude(): 47 | try: 48 | return f'{loc1()["altitude"] - loc2()["altitude"]} m' 49 | except TypeError: 50 | return "N/A (altitude lookup failed)" 51 | 52 | 53 | with ui.card(): 54 | ui.card_header("Map (drag the markers to change locations)") 55 | 56 | @render_widget 57 | def map(): 58 | return L.Map(zoom=4, center=(0, 0)) 59 | 60 | 61 | # Reactive values to store location information 62 | loc1 = reactive.value() 63 | loc2 = reactive.value() 64 | 65 | 66 | # Update the reactive values when the selectize inputs change 67 | @reactive.effect 68 | def _(): 69 | loc1.set(CITIES.get(input.loc1(), loc_str_to_coords(input.loc1()))) 70 | loc2.set(CITIES.get(input.loc2(), loc_str_to_coords(input.loc2()))) 71 | 72 | 73 | # When a marker is moved, the input value gets updated to "lat, lon", 74 | # so we decode that into a dict (and also look up the altitude) 75 | def loc_str_to_coords(x: str) -> dict: 76 | latlon = x.split(", ") 77 | if len(latlon) != 2: 78 | return {} 79 | 80 | lat = float(latlon[0]) 81 | lon = float(latlon[1]) 82 | 83 | try: 84 | import requests 85 | 86 | query = f"https://api.open-elevation.com/api/v1/lookup?locations={lat},{lon}" 87 | r = requests.get(query).json() 88 | altitude = r["results"][0]["elevation"] 89 | except Exception: 90 | altitude = None 91 | 92 | return {"latitude": lat, "longitude": lon, "altitude": altitude} 93 | 94 | 95 | # Convenient way to get the lat/lons as a tuple 96 | @reactive.calc 97 | def loc1xy(): 98 | return loc1()["latitude"], loc1()["longitude"] 99 | 100 | 101 | @reactive.calc 102 | def loc2xy(): 103 | return loc2()["latitude"], loc2()["longitude"] 104 | 105 | 106 | # Add marker for first location 107 | @reactive.effect 108 | def _(): 109 | update_marker(map.widget, loc1xy(), on_move1, "loc1") 110 | 111 | 112 | # Add marker for second location 113 | @reactive.effect 114 | def _(): 115 | update_marker(map.widget, loc2xy(), on_move2, "loc2") 116 | 117 | 118 | # Add line and fit bounds when either marker is moved 119 | @reactive.effect 120 | def _(): 121 | update_line(map.widget, loc1xy(), loc2xy()) 122 | 123 | 124 | # If new bounds fall outside of the current view, fit the bounds 125 | @reactive.effect 126 | def _(): 127 | l1 = loc1xy() 128 | l2 = loc2xy() 129 | 130 | lat_rng = [min(l1[0], l2[0]), max(l1[0], l2[0])] 131 | lon_rng = [min(l1[1], l2[1]), max(l1[1], l2[1])] 132 | new_bounds = [ 133 | [lat_rng[0], lon_rng[0]], 134 | [lat_rng[1], lon_rng[1]], 135 | ] 136 | 137 | b = map.widget.bounds 138 | if len(b) == 0: 139 | map.widget.fit_bounds(new_bounds) 140 | elif ( 141 | lat_rng[0] < b[0][0] 142 | or lat_rng[1] > b[1][0] 143 | or lon_rng[0] < b[0][1] 144 | or lon_rng[1] > b[1][1] 145 | ): 146 | map.widget.fit_bounds(new_bounds) 147 | 148 | 149 | # Update the basemap 150 | @reactive.effect 151 | def _(): 152 | update_basemap(map.widget, input.basemap()) 153 | 154 | 155 | # --------------------------------------------------------------- 156 | # Helper functions 157 | # --------------------------------------------------------------- 158 | 159 | 160 | def update_marker(map: L.Map, loc: tuple, on_move: object, name: str): 161 | remove_layer(map, name) 162 | m = L.Marker(location=loc, draggable=True, name=name) 163 | m.on_move(on_move) 164 | map.add_layer(m) 165 | 166 | 167 | def update_line(map: L.Map, loc1: tuple, loc2: tuple): 168 | remove_layer(map, "line") 169 | map.add_layer( 170 | L.Polyline(locations=[loc1, loc2], color="blue", weight=2, name="line") 171 | ) 172 | 173 | 174 | def update_basemap(map: L.Map, basemap: str): 175 | for layer in map.layers: 176 | if isinstance(layer, L.TileLayer): 177 | map.remove_layer(layer) 178 | map.add_layer(L.basemap_to_tiles(BASEMAPS[input.basemap()])) 179 | 180 | 181 | def remove_layer(map: L.Map, name: str): 182 | for layer in map.layers: 183 | if layer.name == name: 184 | map.remove_layer(layer) 185 | 186 | 187 | def on_move1(**kwargs): 188 | return on_move("loc1", **kwargs) 189 | 190 | 191 | def on_move2(**kwargs): 192 | return on_move("loc2", **kwargs) 193 | 194 | 195 | # When the markers are moved, update the selectize inputs to include the new 196 | # location (which results in the locations() reactive value getting updated, 197 | # which invalidates any downstream reactivity that depends on it) 198 | def on_move(id, **kwargs): 199 | loc = kwargs["location"] 200 | loc_str = f"{loc[0]}, {loc[1]}" 201 | choices = city_names + [loc_str] 202 | ui.update_selectize(id, selected=loc_str, choices=choices) 203 | -------------------------------------------------------------------------------- /map-distance/requirements.txt: -------------------------------------------------------------------------------- 1 | shiny 2 | shinywidgets 3 | ipyleaflet 4 | geopy 5 | faicons 6 | requests -------------------------------------------------------------------------------- /map-distance/shared.py: -------------------------------------------------------------------------------- 1 | from ipyleaflet import basemaps 2 | 3 | BASEMAPS = { 4 | "WorldImagery": basemaps.Esri.WorldImagery, 5 | "Mapnik": basemaps.OpenStreetMap.Mapnik, 6 | "Positron": basemaps.CartoDB.Positron, 7 | "DarkMatter": basemaps.CartoDB.DarkMatter, 8 | "NatGeoWorldMap": basemaps.Esri.NatGeoWorldMap, 9 | "France": basemaps.OpenStreetMap.France, 10 | "DE": basemaps.OpenStreetMap.DE, 11 | } 12 | 13 | 14 | CITIES = { 15 | "New York": {"latitude": 40.7128, "longitude": -74.0060, "altitude": 33}, 16 | "London": {"latitude": 51.5074, "longitude": -0.1278, "altitude": 36}, 17 | "Paris": {"latitude": 48.8566, "longitude": 2.3522, "altitude": 35}, 18 | "Tokyo": {"latitude": 35.6895, "longitude": 139.6917, "altitude": 44}, 19 | "Sydney": {"latitude": -33.8688, "longitude": 151.2093, "altitude": 39}, 20 | "Los Angeles": {"latitude": 34.0522, "longitude": -118.2437, "altitude": 71}, 21 | "Berlin": {"latitude": 52.5200, "longitude": 13.4050, "altitude": 34}, 22 | "Rome": {"latitude": 41.9028, "longitude": 12.4964, "altitude": 21}, 23 | "Beijing": {"latitude": 39.9042, "longitude": 116.4074, "altitude": 44}, 24 | "Moscow": {"latitude": 55.7558, "longitude": 37.6176, "altitude": 156}, 25 | "Cairo": {"latitude": 30.0444, "longitude": 31.2357, "altitude": 23}, 26 | "Rio de Janeiro": {"latitude": -22.9068, "longitude": -43.1729, "altitude": 8}, 27 | "Toronto": {"latitude": 43.6511, "longitude": -79.3832, "altitude": 76}, 28 | "Dubai": {"latitude": 25.2769, "longitude": 55.2963, "altitude": 52}, 29 | "Mumbai": {"latitude": 19.0760, "longitude": 72.8777, "altitude": 14}, 30 | "Seoul": {"latitude": 37.5665, "longitude": 126.9780, "altitude": 38}, 31 | "Madrid": {"latitude": 40.4168, "longitude": -3.7038, "altitude": 667}, 32 | "Amsterdam": {"latitude": 52.3676, "longitude": 4.9041, "altitude": -2}, 33 | "Buenos Aires": {"latitude": -34.6037, "longitude": -58.3816, "altitude": 25}, 34 | "Stockholm": {"latitude": 59.3293, "longitude": 18.0686, "altitude": 14}, 35 | "Boulder": {"latitude": 40.0150, "longitude": -105.2705, "altitude": 1634}, 36 | "Lhasa": {"latitude": 29.6500, "longitude": 91.1000, "altitude": 3650}, 37 | "Khatmandu": {"latitude": 27.7172, "longitude": 85.3240, "altitude": 1400}, 38 | } 39 | -------------------------------------------------------------------------------- /model-scoring/README.md: -------------------------------------------------------------------------------- 1 | ## Model scoring app 2 | 3 | 4 | -------------------------------------------------------------------------------- /model-scoring/_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "model-scoring", 3 | "title": "Model scoring", 4 | "description": "Use a combination of scikit-learn and plotnine to visualize model diagnostics like ROC and precision-recall curves." 5 | } 6 | -------------------------------------------------------------------------------- /model-scoring/app-core.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pandas as pd 4 | from plots import plot_auc_curve, plot_precision_recall_curve, plot_score_distribution 5 | from shiny import App, Inputs, reactive, render, ui 6 | 7 | app_dir = Path(__file__).parent 8 | scores = pd.read_csv(app_dir / "scores.csv") 9 | 10 | # TODO: borrow some ideas from 11 | # https://github.com/evidentlyai/evidently 12 | # https://medium.com/@dasulaakshat/model-monitoring-dashboard-for-models-in-production-d69f17b96f2c 13 | app_ui = ui.page_navbar( 14 | ui.nav_spacer(), 15 | ui.nav_panel( 16 | "Training Dashboard", 17 | ui.navset_card_underline( 18 | ui.nav_panel("ROC Curve", ui.output_plot("roc_curve")), 19 | ui.nav_panel("Precision/Recall", ui.output_plot("precision_recall")), 20 | title="Model Metrics", 21 | ), 22 | ui.card( 23 | ui.card_header("Training Scores"), 24 | ui.output_plot("score_dist"), 25 | ), 26 | {"class": "bslib-page-dashboard"}, 27 | ), 28 | ui.nav_panel( 29 | "View Data", 30 | ui.layout_columns( 31 | ui.value_box(title="Row count", value=ui.output_text("row_count")), 32 | ui.value_box( 33 | title="Mean training score", value=ui.output_text("mean_score") 34 | ), 35 | fill=False, 36 | ), 37 | ui.card(ui.output_data_frame("data")), 38 | {"class": "bslib-page-dashboard"}, 39 | ), 40 | sidebar=ui.sidebar( 41 | ui.input_select( 42 | "account", 43 | "Account", 44 | choices=[ 45 | "Berge & Berge", 46 | "Fritsch & Fritsch", 47 | "Hintz & Hintz", 48 | "Mosciski and Sons", 49 | "Wolff Ltd", 50 | ], 51 | ) 52 | ), 53 | id="tabs", 54 | title="Model scoring dashboard", 55 | fillable=True, 56 | ) 57 | 58 | 59 | def server(input: Inputs): 60 | @reactive.calc() 61 | def dat() -> pd.DataFrame: 62 | return scores.loc[scores["account"] == input.account()] 63 | 64 | @render.plot 65 | def score_dist(): 66 | return plot_score_distribution(dat()) 67 | 68 | @render.plot 69 | def roc_curve(): 70 | return plot_auc_curve(dat(), "is_electronics", "training_score") 71 | 72 | @render.plot 73 | def precision_recall(): 74 | return plot_precision_recall_curve(dat(), "is_electronics", "training_score") 75 | 76 | @render.text 77 | def row_count(): 78 | return dat().shape[0] 79 | 80 | @render.text 81 | def mean_score(): 82 | return round(dat()["training_score"].mean(), 2) 83 | 84 | @render.data_frame 85 | def data(): 86 | return dat() 87 | 88 | 89 | app = App(app_ui, server) 90 | -------------------------------------------------------------------------------- /model-scoring/plots.py: -------------------------------------------------------------------------------- 1 | from pandas import DataFrame 2 | from plotnine import ( 3 | aes, 4 | geom_abline, 5 | geom_density, 6 | geom_line, 7 | ggplot, 8 | labs, 9 | theme_minimal, 10 | ) 11 | from sklearn.metrics import auc, precision_recall_curve, roc_curve 12 | 13 | 14 | def plot_score_distribution(df: DataFrame): 15 | plot = ( 16 | ggplot(df, aes(x="training_score")) 17 | + geom_density(fill="blue", alpha=0.3) 18 | + theme_minimal() 19 | + labs(title="Model scores", x="Score") 20 | ) 21 | return plot 22 | 23 | 24 | def plot_auc_curve(df: DataFrame, true_col: str, pred_col: str): 25 | fpr, tpr, _ = roc_curve(df[true_col], df[pred_col]) 26 | roc_auc = auc(fpr, tpr) 27 | 28 | roc_df = DataFrame({"fpr": fpr, "tpr": tpr}) 29 | 30 | plot = ( 31 | ggplot(roc_df, aes(x="fpr", y="tpr")) 32 | + geom_line(color="darkorange", size=1.5, show_legend=True, linetype="solid") 33 | + geom_abline(intercept=0, slope=1, color="navy", linetype="dashed") 34 | + labs( 35 | title="Receiver Operating Characteristic (ROC)", 36 | subtitle=f"AUC: {roc_auc.round(2)}", 37 | x="False Positive Rate", 38 | y="True Positive Rate", 39 | ) 40 | + theme_minimal() 41 | ) 42 | 43 | return plot 44 | 45 | 46 | def plot_precision_recall_curve(df: DataFrame, true_col: str, pred_col: str): 47 | precision, recall, _ = precision_recall_curve(df[true_col], df[pred_col]) 48 | 49 | pr_df = DataFrame({"precision": precision, "recall": recall}) 50 | 51 | plot = ( 52 | ggplot(pr_df, aes(x="recall", y="precision")) 53 | + geom_line(color="darkorange", size=1.5, show_legend=True, linetype="solid") 54 | + labs( 55 | title="Precision-Recall Curve", 56 | x="Recall", 57 | y="Precision", 58 | ) 59 | + theme_minimal() 60 | ) 61 | 62 | return plot 63 | -------------------------------------------------------------------------------- /model-scoring/requirements.txt: -------------------------------------------------------------------------------- 1 | numpy 2 | pandas 3 | plotnine 4 | scikit-learn 5 | shiny 6 | -------------------------------------------------------------------------------- /monitor-database/README.md: -------------------------------------------------------------------------------- 1 | # Database monitoring 2 | 3 | 4 | 5 | 6 | Shiny allows you to reactively update your app whenever an external data source changes. 7 | This application simulates that situation by writing data to a sqlite database after a random time period. 8 | Shiny polls the database every second to see if there is any new data, and if there it pulls the updated data and refreshes any elements which depend on that data. 9 | 10 | This application also uses dynamic UI to generate informative value boxes which tell the user when the model score falls below a certain threshold. 11 | -------------------------------------------------------------------------------- /monitor-database/_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "monitor-database", 3 | "title": "Streaming database updates", 4 | "description": "Efficiently monitor a database for updates and visualize the data in real-time." 5 | } 6 | -------------------------------------------------------------------------------- /monitor-database/app-core.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import plotly.graph_objects as go 4 | from shared import df, plot_timeseries, value_box_server, value_box_ui 5 | from shiny import App, Inputs, Outputs, Session, reactive, ui 6 | from shinywidgets import output_widget, render_plotly 7 | 8 | all_models = ["model_1", "model_2", "model_3", "model_4"] 9 | 10 | app_ui = ui.page_sidebar( 11 | ui.sidebar( 12 | ui.input_checkbox_group("models", "Models", all_models, selected=all_models) 13 | ), 14 | ui.layout_columns( 15 | value_box_ui("model_1", "Model 1"), 16 | value_box_ui("model_2", "Model 2"), 17 | value_box_ui("model_3", "Model 3"), 18 | value_box_ui("model_4", "Model 4"), 19 | fill=False, 20 | id="value-boxes", 21 | ), 22 | ui.card( 23 | ui.card_header( 24 | "Model accuracy over time", 25 | ui.input_switch("pause", "Pause updates", value=False, width="auto"), 26 | class_="d-flex justify-content-between", 27 | ), 28 | output_widget("plot"), 29 | full_screen=True, 30 | ), 31 | title="Model monitoring dashboard", 32 | class_="bslib-page-dashboard", 33 | ) 34 | 35 | 36 | def server(input: Inputs, output: Outputs, session: Session): 37 | # Note that df from shared.py is a reactive calc that gets 38 | # invalidated (approximately) when the database updates 39 | # We can choose to ignore the invalidation by doing an isolated read 40 | @reactive.calc 41 | def maybe_paused_df(): 42 | if not input.pause(): 43 | return df() 44 | with reactive.isolate(): 45 | return df() 46 | 47 | # Source the value box module server code for each model 48 | for model in all_models: 49 | value_box_server(model, maybe_paused_df, model) 50 | 51 | # Create an empty plotly figure on page load 52 | @render_plotly 53 | def plot(): 54 | return go.FigureWidget() 55 | 56 | # Update the plotly figure with the latest data 57 | @reactive.effect 58 | def _(): 59 | d = maybe_paused_df() 60 | d = d[d["model"].isin(input.models())] 61 | with plot.widget.batch_animate(): 62 | fig = plot_timeseries(d) 63 | plot.widget.update(layout=fig.layout, data=fig.data) 64 | 65 | # Hacky way to hide/show model value boxes. This is currently the only real 66 | # option you want the value box UI to be statically rendered (thus, reducing 67 | # flicker on update), but also want to hide/show them based on user input. 68 | @reactive.effect 69 | @reactive.event(input.models) 70 | def _(): 71 | ui.remove_ui("#value-box-hide") # Remove any previously added style tag 72 | 73 | # Construct CSS to hide the value boxes that the user has deselected 74 | css = "" 75 | missing_models = list(set(all_models) - set(input.models())) 76 | for model in missing_models: 77 | i = all_models.index(model) + 1 78 | css += f"#value-boxes > *:nth-child({i}) {{ display: none; }}" 79 | 80 | # Add the CSS to the head of the document 81 | if css: 82 | style = ui.tags.style(css, id="value-box-hide") 83 | ui.insert_ui(style, selector="head") 84 | 85 | 86 | app = App(app_ui, server) 87 | -------------------------------------------------------------------------------- /monitor-database/data/accuracy_scores.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/posit-dev/py-shiny-templates/6eaefc8b467b3b211511fdf3668c1639b6407665/monitor-database/data/accuracy_scores.sqlite -------------------------------------------------------------------------------- /monitor-database/requirements.txt: -------------------------------------------------------------------------------- 1 | faicons 2 | numpy 3 | pandas 4 | plotly 5 | shiny 6 | shinywidgets -------------------------------------------------------------------------------- /monitor-database/scoredata.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import datetime 3 | import random 4 | import sqlite3 5 | from pathlib import Path 6 | 7 | import numpy as np 8 | import pandas as pd 9 | 10 | here = Path(__file__).parent 11 | accuracy_scores = pd.read_csv(here / "fake_accuracy_scores.csv") 12 | accuracy_scores.set_index("second", inplace=True) 13 | (here / "data").mkdir(exist_ok=True) 14 | 15 | SQLITE_DB_URI = f"file:{here / 'data' / 'accuracy_scores.sqlite'}" 16 | 17 | 18 | def init_db(): 19 | with sqlite3.connect(SQLITE_DB_URI, uri=True, timeout=30) as con: 20 | con.execute("PRAGMA journal_mode=WAL") 21 | con.execute("drop table if exists accuracy_scores") 22 | 23 | now = datetime.datetime.utcnow() 24 | position = now.minute * 60 + now.second + 1 25 | 26 | # Simulate 100 seconds of historical data 27 | offset_secs = -np.arange(100) - 1 28 | abs_secs = (position + offset_secs) % (60 * 60) + 1 29 | initial_scores = accuracy_scores.loc[abs_secs] 30 | timestamps = pd.DataFrame( 31 | { 32 | "timestamp": now + pd.to_timedelta(offset_secs, unit="s"), 33 | "second": abs_secs, 34 | } 35 | ).set_index("second", inplace=False) 36 | initial_scores = initial_scores.join(timestamps, how="left") 37 | initial_scores.to_sql("accuracy_scores", con, index=False, if_exists="append") 38 | 39 | con.execute( 40 | "create index idx_accuracy_scores_timestamp on accuracy_scores(timestamp)" 41 | ) 42 | 43 | return position 44 | 45 | 46 | async def update_db(position): 47 | with sqlite3.connect(SQLITE_DB_URI, uri=True, timeout=30) as con: 48 | while True: 49 | new_data = accuracy_scores.loc[position].copy() 50 | # del new_data["second"] 51 | new_data["timestamp"] = datetime.datetime.utcnow() 52 | new_data.to_sql("accuracy_scores", con, index=False, if_exists="append") 53 | position = (position % (60 * 60)) + 1 54 | await asyncio.sleep(random.randint(2, 4)) 55 | 56 | 57 | def begin(): 58 | position = init_db() 59 | 60 | # After initializing the database, we need to start a non-blocking task to update it 61 | # every second or so. If an event loop is already running, we can use an asyncio 62 | # task. (This is the case when running via `shiny run` and shinylive.) Otherwise, we 63 | # need to launch a background thread and run an asyncio event loop there. (This is 64 | # the case when running via shinyapps.io or Posit Connect.) 65 | 66 | if asyncio.get_event_loop().is_running(): 67 | asyncio.create_task(update_db(position)) 68 | else: 69 | from threading import Thread 70 | 71 | Thread(target=lambda: asyncio.run(update_db(position)), daemon=True).start() 72 | -------------------------------------------------------------------------------- /monitor-database/shared.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | 3 | import faicons as fa 4 | import pandas as pd 5 | import plotly.express as px 6 | 7 | # This starts a background process to add records to a database after a random interval 8 | # You should replace it with a connection to your actual database. 9 | import scoredata 10 | from shiny import module, reactive, render, ui 11 | 12 | scoredata.begin() 13 | con = sqlite3.connect(scoredata.SQLITE_DB_URI, uri=True) 14 | 15 | 16 | def last_modified(): 17 | """ 18 | Fast-executing call to get the timestamp of the most recent row in the 19 | database. We will poll against this in absence of a way to receive a push 20 | notification when our SQLite database changes. 21 | """ 22 | res = con.execute("select max(timestamp) from accuracy_scores") 23 | return res.fetchone()[0] 24 | 25 | 26 | @reactive.poll(last_modified) 27 | def df(): 28 | """ 29 | @reactive.poll calls a cheap query (`last_modified()`) every 1 second to 30 | check if the expensive query (`df()`) should be run and downstream 31 | calculations should be updated. 32 | 33 | By declaring this reactive object at the top-level of the script instead of 34 | in the server function, all sessions are sharing the same object, so the 35 | expensive query is only run once no matter how many users are connected. 36 | """ 37 | tbl = pd.read_sql( 38 | "select * from accuracy_scores order by timestamp desc, model desc limit ?", 39 | con, 40 | params=[150], 41 | ) 42 | # Convert timestamp to datetime object, which SQLite doesn't support natively 43 | tbl["timestamp"] = pd.to_datetime(tbl["timestamp"], utc=True) 44 | # Create a short label for readability 45 | tbl["time"] = tbl["timestamp"].dt.strftime("%H:%M:%S") 46 | # Reverse order of rows 47 | tbl = tbl.iloc[::-1] 48 | 49 | return tbl 50 | 51 | 52 | # --------------------------------------------------------------- 53 | # Plot and value box logic 54 | # --------------------------------------------------------------- 55 | 56 | THRESHOLD_MID = 0.85 57 | THRESHOLD_MID_COLOR = "#f9b928" 58 | THRESHOLD_LOW = 0.5 59 | THRESHOLD_LOW_COLOR = "#c10000" 60 | 61 | 62 | @module.ui 63 | def value_box_ui(title): 64 | return ui.value_box( 65 | title, 66 | ui.output_text("value"), 67 | showcase=ui.output_ui("icon"), 68 | ) 69 | 70 | 71 | @module.server 72 | def value_box_server(input, output, session, df, model: str): 73 | @reactive.calc 74 | def score(): 75 | d = df() 76 | return d[d["model"] == model].iloc[-1]["score"] 77 | 78 | @render.text 79 | def value(): 80 | return f"{score():.2f}" 81 | 82 | @render.ui 83 | def icon(): 84 | if score() > THRESHOLD_MID: 85 | return fa.icon_svg("circle-check").add_class("text-success") 86 | if score() > THRESHOLD_LOW: 87 | return fa.icon_svg("triangle-exclamation").add_class("text-warning") 88 | return fa.icon_svg("circle-exclamation").add_class("text-danger") 89 | 90 | 91 | def plot_timeseries(d): 92 | fig = px.line( 93 | d, 94 | x="time", 95 | y="score", 96 | labels=dict(score="accuracy"), 97 | color="model", 98 | color_discrete_sequence=px.colors.qualitative.Set2, 99 | template="simple_white", 100 | ) 101 | 102 | fig.add_hline( 103 | THRESHOLD_LOW, 104 | line_dash="dot", 105 | line=dict(color=THRESHOLD_LOW_COLOR, width=2), 106 | opacity=0.3, 107 | annotation=dict(text="Warning Zone", xref="paper", x=1, y=THRESHOLD_MID), 108 | annotation_position="bottom right", 109 | ) 110 | fig.add_hline( 111 | THRESHOLD_MID, 112 | line_dash="dot", 113 | line=dict(color=THRESHOLD_MID_COLOR, width=2), 114 | opacity=0.3, 115 | annotation=dict(text="Danger Zone", xref="paper", x=1, y=THRESHOLD_LOW), 116 | annotation_position="bottom right", 117 | ) 118 | 119 | fig.update_yaxes(range=[0, 1], fixedrange=True) 120 | fig.update_xaxes(fixedrange=True, tickangle=60) 121 | fig.update_layout(hovermode="x unified") 122 | 123 | return fig 124 | -------------------------------------------------------------------------------- /monitor-file/README.md: -------------------------------------------------------------------------------- 1 | ## Monitor file app 2 | 3 | 4 | -------------------------------------------------------------------------------- /monitor-file/_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "monitor-file", 3 | "title": "Streaming file updates", 4 | "description": "Efficiently monitor a file for updates and visualize the data in real-time." 5 | } 6 | -------------------------------------------------------------------------------- /monitor-file/app-core.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from faicons import icon_svg 3 | 4 | # Import the reactive file reader (logs) and the process the external 5 | # process that generates the logs 6 | from shared import logs_df, process 7 | from shiny import App, Inputs, Outputs, Session, reactive, render, ui 8 | 9 | app_ui = ui.page_fillable( 10 | ui.layout_columns( 11 | ui.value_box( 12 | "Current Message", 13 | ui.output_text("cur_message"), 14 | showcase=icon_svg("comment-dots"), 15 | ), 16 | ui.value_box( 17 | "Current Status", 18 | ui.output_text("cur_status"), 19 | showcase=icon_svg("check"), 20 | ), 21 | ui.value_box( 22 | "Last update", 23 | ui.output_text("last_update"), 24 | showcase=icon_svg("clock"), 25 | ), 26 | ui.value_box( 27 | "Number of Messages", 28 | ui.output_text("n_messages"), 29 | showcase=icon_svg("envelope"), 30 | ), 31 | col_widths=[6, 2, 2, 2], 32 | fill=False, 33 | ), 34 | ui.layout_columns( 35 | ui.card( 36 | ui.card_header("Logs"), 37 | ui.output_data_frame("df"), 38 | ), 39 | ui.card( 40 | ui.card_header("Log Summary"), 41 | ui.output_data_frame("message_counts"), 42 | ), 43 | col_widths=[8, 4], 44 | ), 45 | class_="bslib-page-dashboard", 46 | ) 47 | 48 | 49 | def server(input: Inputs, output: Outputs, session: Session): 50 | @render.data_frame 51 | def df(): 52 | return logs_df().sort_values("date", ascending=False) 53 | 54 | @reactive.calc 55 | def current(): 56 | return logs_df().iloc[-1] 57 | 58 | @render.text 59 | def last_update(): 60 | dates = pd.to_datetime(current()["date"]) 61 | return dates.strftime("%H:%M:%S") 62 | 63 | @render.text 64 | def n_messages(): 65 | return len(logs_df()) 66 | 67 | @render.text 68 | def cur_status(): 69 | return current()["status"] 70 | 71 | @render.text 72 | def cur_message(): 73 | return current()["message"] 74 | 75 | @render.data_frame 76 | def message_counts(): 77 | counts = logs_df()["message"].value_counts().reset_index() 78 | counts.columns = ["message", "count"] 79 | counts = counts.sort_values("count", ascending=False) 80 | return render.DataGrid(counts, filters=True) 81 | 82 | session.on_ended(process.kill) 83 | 84 | 85 | app = App(app_ui, server) 86 | -------------------------------------------------------------------------------- /monitor-file/app-express.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from faicons import icon_svg 3 | 4 | # Import the reactive file reader (logs) and the process the external 5 | # process that generates the logs 6 | from shared import logs_df, process 7 | from shiny import reactive 8 | from shiny.express import render, session, ui 9 | 10 | ui.page_opts(fillable=True) 11 | 12 | {"class": "bslib-page-dashboard"} 13 | 14 | with ui.layout_columns(col_widths=[6, 2, 2, 2], fill=False): 15 | with ui.value_box(showcase=icon_svg("comment-dots")): 16 | "Current Message" 17 | 18 | @render.text 19 | def cur_message(): 20 | return current()["message"] 21 | 22 | with ui.value_box(showcase=icon_svg("check")): 23 | "Current Status" 24 | 25 | @render.text 26 | def cur_status(): 27 | return current()["status"] 28 | 29 | with ui.value_box(showcase=icon_svg("clock")): 30 | "Last update" 31 | 32 | @render.text 33 | def last_update(): 34 | dates = pd.to_datetime(current()["date"]) 35 | return dates.strftime("%H:%M:%S") 36 | 37 | with ui.value_box(showcase=icon_svg("envelope")): 38 | "Number of Messages" 39 | 40 | @render.text 41 | def n_messages(): 42 | return len(logs_df()) 43 | 44 | 45 | with ui.layout_columns(col_widths=[8, 4]): 46 | with ui.card(): 47 | ui.card_header("Logs") 48 | 49 | @render.data_frame 50 | def df(): 51 | return logs_df().sort_values("date", ascending=False) 52 | 53 | with ui.card(): 54 | ui.card_header("Log Summary") 55 | 56 | @render.data_frame 57 | def message_counts(): 58 | counts = logs_df()["message"].value_counts().reset_index() 59 | counts.columns = ["message", "count"] 60 | counts = counts.sort_values("count", ascending=False) 61 | return render.DataGrid(counts, filters=True) 62 | 63 | 64 | @reactive.calc 65 | def current(): 66 | return logs_df().iloc[-1] 67 | 68 | 69 | _ = session.on_ended(process.kill) 70 | -------------------------------------------------------------------------------- /monitor-file/populate-logs.py: -------------------------------------------------------------------------------- 1 | import random 2 | import time 3 | from datetime import datetime 4 | from pathlib import Path 5 | 6 | import pandas as pd 7 | 8 | log_path = Path(__file__).parent / "logs.csv" 9 | 10 | while True: 11 | status = "status" + str(random.randint(0, 20)) 12 | # Create a new DataFrame with the current time and the random status 13 | messages = [ 14 | "Running smoothly", 15 | "On fire", 16 | "Taking a nap", 17 | "Feeling happy", 18 | "Feeling sad", 19 | "Server is plotting world domination", 20 | "Server is questioning its existence", 21 | "Server is craving for digital pizza", 22 | "Server is dreaming of electric sheep", 23 | "Server is running on pure caffeine", 24 | ] 25 | message = random.choice(messages) 26 | df = pd.DataFrame( 27 | {"date": [datetime.now()], "status": [status], "message": [message]} 28 | ) 29 | 30 | if not log_path.exists(): 31 | df.to_csv(log_path, mode="w", header=True, index=False) 32 | else: 33 | df_current = pd.read_csv(log_path) 34 | # If we get over 10000 rows, just start over 35 | if len(df_current) > 10000: 36 | df.to_csv(log_path, mode="w", header=True, index=False) 37 | else: 38 | df.to_csv(log_path, mode="a", header=False, index=False) 39 | # Wait for a second before the next append operation 40 | time.sleep(random.randint(1, 5)) 41 | -------------------------------------------------------------------------------- /monitor-file/requirements.txt: -------------------------------------------------------------------------------- 1 | shiny 2 | pandas 3 | faicons -------------------------------------------------------------------------------- /monitor-file/shared.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from pathlib import Path 3 | 4 | import pandas as pd 5 | from shiny import reactive 6 | from shiny.session import session_context 7 | 8 | app_dir = Path(__file__).parent 9 | 10 | # Launch process to generate logs 11 | process = subprocess.Popen(["python", app_dir / "populate-logs.py"]) 12 | 13 | 14 | # File reader polls the file (every second by default) for changes 15 | # 16 | # NOTE: the session_context(None) here is only necessary at the moment 17 | # for Express -- this should improve/change in a future release 18 | # https://github.com/posit-dev/py-shiny/issues/1079 19 | with session_context(None): 20 | 21 | @reactive.file_reader(app_dir / "logs.csv") 22 | def logs_df(): 23 | return pd.read_csv(app_dir / "logs.csv") 24 | -------------------------------------------------------------------------------- /monitor-folder/README.md: -------------------------------------------------------------------------------- 1 | # Folder monitoring 2 | 3 | 4 | 5 | This implements an app which watches a folder to allow users to view and download log files. 6 | The application polls `watch_folder` every second and refreshes when a new file is added to the folder. 7 | This is a useful pattern when your application consumes files which are provided by some other system like an Airflow pipeline. 8 | You can add new files to the folder either by copying and pasting an existing file, or by clicking the "add log file" button. 9 | -------------------------------------------------------------------------------- /monitor-folder/_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "monitor-folder", 3 | "title": "Streaming folder updates", 4 | "description": "Monitor a folder for updates (e.g., adding a new file) and visualize the updates in real-time." 5 | } 6 | -------------------------------------------------------------------------------- /monitor-folder/app-core.py: -------------------------------------------------------------------------------- 1 | import io 2 | import random 3 | 4 | import faicons 5 | import pandas as pd 6 | from shared import files_df, process, watch_folder 7 | from shiny import App, Inputs, Outputs, Session, reactive, render, req, ui 8 | 9 | app_ui = ui.page_fillable( 10 | ui.layout_columns( 11 | ui.card( 12 | ui.card_header( 13 | "Select log file", 14 | ui.popover( 15 | faicons.icon_svg("plus"), 16 | ui.input_action_button("add", "Add new logs"), 17 | title="Generate new log file", 18 | placement="left", 19 | ), 20 | class_="d-flex justify-content-between align-items-center", 21 | ), 22 | ui.output_data_frame("file_list"), 23 | ), 24 | ui.output_ui("log_output", fill=True, fillable=True), 25 | col_widths=[4, 8], 26 | ), 27 | ) 28 | 29 | 30 | def server(input: Inputs, output: Outputs, session: Session): 31 | # When the session ends, end the folder watching process 32 | session.on_ended(process.kill) 33 | 34 | @render.data_frame 35 | def file_list(): 36 | return render.DataGrid(files_df(), selection_mode="row") 37 | 38 | @render.ui 39 | def log_output(): 40 | idx = input.file_list_selected_rows() 41 | if not idx: 42 | return ui.card( 43 | ui.card_header("Select log file"), 44 | "Select a log file from the list to view its contents", 45 | ) 46 | 47 | return ui.card( 48 | ui.card_header( 49 | "Log output", 50 | ui.download_link( 51 | "download", "Download", icon=faicons.icon_svg("download") 52 | ), 53 | class_="d-flex justify-content-between align-items-center", 54 | ), 55 | ui.output_data_frame("data_grid"), 56 | ) 57 | 58 | @reactive.calc 59 | def selected_file(): 60 | idx = req(input.file_list_selected_rows()) 61 | file = files_df()["File Name"][idx[0]] 62 | return pd.read_csv(watch_folder / file) 63 | 64 | @render.data_frame 65 | def data_grid(): 66 | return render.DataGrid(selected_file()) 67 | 68 | @reactive.effect 69 | @reactive.event(input.add) 70 | def sim_logs(): 71 | logs = pd.read_csv(watch_folder / files_df()["File Name"][0]) 72 | sampled_logs = logs.sample(n=1000, replace=True) 73 | id = random.randint(100, 999) 74 | sampled_logs.to_csv(watch_folder / f"logs-{id}.csv") 75 | 76 | @render.download(filename="logs.csv") 77 | def download(): 78 | csv = selected_file() 79 | with io.StringIO() as buf: 80 | csv.to_csv(buf) 81 | yield buf.getvalue().encode() 82 | 83 | 84 | app = App(app_ui, server) 85 | -------------------------------------------------------------------------------- /monitor-folder/app-express.py: -------------------------------------------------------------------------------- 1 | import io 2 | import random 3 | 4 | import faicons 5 | import pandas as pd 6 | from shared import files_df, process, watch_folder 7 | from shiny import reactive, req 8 | from shiny.express import input, render, session, ui 9 | 10 | ui.page_opts(fillable=True) 11 | 12 | with ui.layout_columns(col_widths=[4, 8]): 13 | with ui.card(): 14 | with ui.card_header(class_="d-flex justify-content-between align-items-center"): 15 | "Select log file" 16 | with ui.popover(title="Generate new log file", placement="left"): 17 | faicons.icon_svg("plus") 18 | ui.input_action_button("add", "Add new logs") 19 | 20 | @render.data_frame 21 | def file_list(): 22 | return render.DataGrid(files_df(), selection_mode="row") 23 | 24 | @render.express 25 | def log_output(): 26 | idx = input.file_list_selected_rows() 27 | if not idx: 28 | with ui.card(): 29 | ui.card_header("Select log file") 30 | "Select a log file from the list to view its contents" 31 | 32 | else: 33 | with ui.card(): 34 | with ui.card_header( 35 | class_="d-flex justify-content-between align-items-center" 36 | ): 37 | "Log output" 38 | 39 | @render.download(filename="logs.csv") 40 | def download(): 41 | csv = selected_file() 42 | with io.StringIO() as buf: 43 | csv.to_csv(buf) 44 | yield buf.getvalue().encode() 45 | 46 | @render.data_frame 47 | def data_grid(): 48 | return render.DataGrid(selected_file()) 49 | 50 | 51 | @reactive.calc 52 | def selected_file(): 53 | idx = req(input.file_list_selected_rows()) 54 | file = files_df()["File Name"][idx[0]] 55 | return pd.read_csv(watch_folder / file) 56 | 57 | 58 | @reactive.effect 59 | @reactive.event(input.add) 60 | def sim_logs(): 61 | logs = pd.read_csv(watch_folder / files_df()["File Name"][0]) 62 | sampled_logs = logs.sample(n=1000, replace=True) 63 | id = random.randint(100, 999) 64 | sampled_logs.to_csv(watch_folder / f"logs-{id}.csv") 65 | 66 | 67 | # When the session ends, end the folder watching process 68 | _ = session.on_ended(process.kill) 69 | -------------------------------------------------------------------------------- /monitor-folder/last_change.txt: -------------------------------------------------------------------------------- 1 | 2024-02-28 09:52:25.863254 -------------------------------------------------------------------------------- /monitor-folder/requirements.txt: -------------------------------------------------------------------------------- 1 | shiny 2 | pandas 3 | faicons 4 | watchfiles -------------------------------------------------------------------------------- /monitor-folder/shared.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from datetime import datetime 3 | from pathlib import Path 4 | 5 | import pandas as pd 6 | from shiny import reactive 7 | from shiny.session import session_context 8 | 9 | app_dir = Path(__file__).parent 10 | 11 | # The directory to watch for changes 12 | watch_folder = app_dir / "watch_folder" 13 | # The file to update when a change is detected (DO NOT PUT THIS INSIDE `watch_folder`!) 14 | last_change = app_dir / "last_change.txt" 15 | 16 | # Start a background process to watch the `watch_folder` directory 17 | # and update the `last_change` file when a change is detected 18 | process = subprocess.Popen( 19 | ["python", app_dir / "watch_folder.py", str(watch_folder), str(last_change)] 20 | ) 21 | 22 | 23 | # Get filenames and last edited times within the `watch_folder` 24 | # (when a change is detected) 25 | # 26 | # NOTE: the session_context(None) here is only necessary at the moment 27 | # for Express -- this should improve/change in a future release 28 | # https://github.com/posit-dev/py-shiny/issues/1079 29 | with session_context(None): 30 | 31 | @reactive.file_reader(last_change) 32 | def files_df(): 33 | files_info = [] 34 | for file in watch_folder.glob("*"): 35 | mtime = datetime.fromtimestamp(file.stat().st_mtime) 36 | info = (file.name, mtime.strftime("%Y-%m-%d %H:%M:%S")) 37 | files_info.append(info) 38 | return pd.DataFrame(files_info, columns=["File Name", "Last Edited"]) 39 | -------------------------------------------------------------------------------- /monitor-folder/watch_folder.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from datetime import datetime 3 | from pathlib import Path 4 | 5 | from watchfiles import run_process 6 | 7 | if len(sys.argv) != 3: 8 | raise ValueError("Expected 2 arguments: watch_folder and last_change") 9 | 10 | watch_folder = Path(sys.argv[1]) 11 | last_change = Path(sys.argv[2]) 12 | 13 | if not watch_folder.is_dir(): 14 | raise ValueError(f"watch_folder '{watch_folder}' is not a directory") 15 | 16 | 17 | # When changes happen, write the current time to a file 18 | def callback(changes): 19 | with open(last_change, "w") as f: 20 | f.write(str(datetime.now())) 21 | 22 | 23 | def target(): 24 | pass 25 | 26 | 27 | if __name__ == "__main__": 28 | run_process(watch_folder, callback=callback, target=target) 29 | -------------------------------------------------------------------------------- /nba-dashboard/README.md: -------------------------------------------------------------------------------- 1 | ## NBA dashboard app 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /nba-dashboard/_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "nba-dashboard", 3 | "title": "NBA player career comparisons", 4 | "description": "Search for NBA players and compare their career statistics." 5 | } 6 | -------------------------------------------------------------------------------- /nba-dashboard/app-core.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | # Import helpers for plotting logic 4 | from plots import color_palette, density_plot, radar_chart 5 | 6 | # Import some pre-downloaded data on player careers 7 | from shared import app_dir, careers_df, from_start, gp_max, players_dict, stats, to_end 8 | from shiny import App, reactive, req, ui 9 | from shinywidgets import output_widget, render_plotly 10 | 11 | # Define the app UI 12 | app_ui = ui.page_sidebar( 13 | ui.sidebar( 14 | ui.input_selectize( 15 | "players", 16 | "Search for players", 17 | multiple=True, 18 | choices=players_dict, 19 | selected=["893", "2544", "201939"], 20 | width="100%", 21 | ), 22 | ui.input_slider( 23 | "games", 24 | "Career games played", 25 | value=[300, gp_max], 26 | min=0, 27 | max=gp_max, 28 | step=1, 29 | sep="", 30 | ), 31 | ui.input_slider( 32 | "seasons", 33 | "Career within years", 34 | value=[from_start, to_end], 35 | min=from_start, 36 | max=to_end, 37 | step=1, 38 | sep="", 39 | ), 40 | ), 41 | ui.layout_columns( 42 | ui.card( 43 | ui.card_header("Player career comparison"), 44 | output_widget("career_compare"), 45 | ui.card_footer("Percentiles are based on career per game averages."), 46 | full_screen=True, 47 | ), 48 | ui.card( 49 | ui.card_header( 50 | "Player career ", 51 | ui.input_select( 52 | "stat", None, choices=stats, selected="PTS", width="auto" 53 | ), 54 | " vs the rest of the league", 55 | class_="d-flex align-items-center gap-1", 56 | ), 57 | output_widget("stat_compare"), 58 | ui.card_footer("Click on a player's name to add them to the comparison."), 59 | full_screen=True, 60 | ), 61 | col_widths=[4, 8], 62 | ), 63 | ui.include_css(app_dir / "styles.css"), 64 | title="NBA Dashboard", 65 | fillable=True, 66 | ) 67 | 68 | 69 | def server(input, output, session): 70 | # Filter the careers data based on the selected games and seasons 71 | @reactive.calc 72 | def careers(): 73 | games = input.games() 74 | seasons = input.seasons() 75 | idx = ( 76 | (careers_df["GP"] >= games[0]) 77 | & (careers_df["GP"] <= games[1]) 78 | & (careers_df["from_year"] >= seasons[0]) 79 | & (careers_df["to_year"] <= seasons[1]) 80 | ) 81 | return careers_df[idx] 82 | 83 | # Update available players when careers data changes 84 | @reactive.effect 85 | def _(): 86 | players = dict(zip(careers()["person_id"], careers()["player_name"])) 87 | ui.update_selectize("players", choices=players, selected=input.players()) 88 | 89 | # Get the stats for the selected players 90 | @reactive.calc 91 | def player_stats(): 92 | players = req(input.players()) 93 | res = careers() 94 | res = res[res["person_id"].isin(players)] 95 | res["color"] = np.resize(color_palette, len(players)) 96 | return res 97 | 98 | # For each player, get the percentile of each stat 99 | @reactive.calc 100 | def percentiles(): 101 | d = player_stats() 102 | 103 | def apply_func(x): 104 | for col in stats: 105 | x[col] = (x[col].values > careers()[col].values).mean() 106 | return x 107 | 108 | return d.groupby("person_id").apply(apply_func) 109 | 110 | # radar chart of player stats 111 | @render_plotly 112 | def career_compare(): 113 | return radar_chart(percentiles(), player_stats(), stats) 114 | 115 | # 1D density plot of player stats 116 | @render_plotly 117 | def stat_compare(): 118 | return density_plot( 119 | careers(), player_stats(), input.stat(), players_dict, on_rug_click 120 | ) 121 | 122 | # When a player is clicked on the rug plot, add them to the selected players 123 | def on_rug_click(trace, points, state): 124 | player_id = trace.customdata[points.point_inds[0]] 125 | selected = list(input.players()) + [player_id] 126 | ui.update_selectize("players", selected=selected) 127 | 128 | 129 | app = App(app_ui, server) 130 | -------------------------------------------------------------------------------- /nba-dashboard/app-express.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | # Import helpers for plotting logic 4 | from plots import color_palette, density_plot, radar_chart 5 | 6 | # Import some pre-downloaded data on player careers 7 | from shared import app_dir, careers_df, from_start, gp_max, players_dict, stats, to_end 8 | from shiny import reactive, req 9 | from shiny.express import input, ui 10 | from shinywidgets import render_plotly 11 | 12 | ui.page_opts(title="NBA Dashboard", fillable=True) 13 | 14 | ui.include_css(app_dir / "styles.css") 15 | 16 | with ui.sidebar(): 17 | ui.input_selectize( 18 | "players", 19 | "Search for players", 20 | multiple=True, 21 | choices=players_dict, 22 | selected=["893", "2544", "201939"], 23 | width="100%", 24 | ) 25 | ui.input_slider( 26 | "games", 27 | "Career games played", 28 | value=[300, gp_max], 29 | min=0, 30 | max=gp_max, 31 | step=1, 32 | sep="", 33 | ) 34 | ui.input_slider( 35 | "seasons", 36 | "Career within years", 37 | value=[from_start, to_end], 38 | min=from_start, 39 | max=to_end, 40 | step=1, 41 | sep="", 42 | ) 43 | 44 | 45 | with ui.layout_columns(col_widths={"sm": 12, "md": 12, "lg": [4, 8]}): 46 | with ui.card(full_screen=True): 47 | ui.card_header("Player career comparison") 48 | 49 | @render_plotly 50 | def career_compare(): 51 | return radar_chart(percentiles(), player_stats(), stats) 52 | 53 | ui.card_footer("Percentiles are based on career per game averages.") 54 | 55 | with ui.card(full_screen=True): 56 | with ui.card_header(class_="d-flex align-items-center gap-1"): 57 | "Player career" 58 | ui.input_select("stat", None, choices=stats, selected="PTS", width="auto") 59 | " vs the rest of the league" 60 | 61 | @render_plotly 62 | def stat_compare(): 63 | return density_plot( 64 | careers(), player_stats(), input.stat(), players_dict, on_rug_click 65 | ) 66 | 67 | ui.card_footer("Click on a player's name to add them to the comparison.") 68 | 69 | 70 | # Filter the careers data based on the selected games and seasons 71 | @reactive.calc 72 | def careers(): 73 | games = input.games() 74 | seasons = input.seasons() 75 | idx = ( 76 | (careers_df["GP"] >= games[0]) 77 | & (careers_df["GP"] <= games[1]) 78 | & (careers_df["from_year"] >= seasons[0]) 79 | & (careers_df["to_year"] <= seasons[1]) 80 | ) 81 | return careers_df[idx] 82 | 83 | 84 | # Update available players when careers data changes 85 | @reactive.effect 86 | def _(): 87 | players = dict(zip(careers()["person_id"], careers()["player_name"])) 88 | ui.update_selectize("players", choices=players, selected=input.players()) 89 | 90 | 91 | # Get the stats for the selected players 92 | @reactive.calc 93 | def player_stats(): 94 | players = req(input.players()) 95 | res = careers() 96 | res = res[res["person_id"].isin(players)] 97 | res["color"] = np.resize(color_palette, len(players)) 98 | return res 99 | 100 | 101 | # For each player, get the percentile of each stat 102 | @reactive.calc 103 | def percentiles(): 104 | d = player_stats() 105 | 106 | def apply_func(x): 107 | for col in stats: 108 | x[col] = (x[col].values > careers()[col].values).mean() 109 | return x 110 | 111 | return d.groupby("person_id").apply(apply_func) 112 | 113 | 114 | # When a player is clicked on the rug plot, add them to the selected players 115 | def on_rug_click(trace, points, state): 116 | player_id = trace.customdata[points.point_inds[0]] 117 | selected = list(input.players()) + [player_id] 118 | ui.update_selectize("players", selected=selected) 119 | -------------------------------------------------------------------------------- /nba-dashboard/etl.py: -------------------------------------------------------------------------------- 1 | import time 2 | from pathlib import Path 3 | 4 | import pandas as pd 5 | from nba_api.stats.endpoints import commonallplayers, playercareerstats 6 | 7 | players = commonallplayers.CommonAllPlayers().get_data_frames()[0] 8 | players.columns = players.columns.str.lower().str.replace(" ", "_") 9 | players["person_id"] = players["person_id"].astype(str) 10 | 11 | f = Path("nba-dashboard") / "players.csv" 12 | players.to_csv(f, index=False) 13 | 14 | # Get career stats for each player (one row per player per season) 15 | careers = pd.DataFrame() 16 | for id in players["person_id"]: 17 | print("Getting stats for player", id) 18 | stats = playercareerstats.PlayerCareerStats(player_id=str(id)) 19 | stats = stats.get_data_frames()[0] 20 | stats["person_id"] = id 21 | careers = pd.concat([careers, stats], axis=0) 22 | # Avoid getting rate-limited 23 | time.sleep(1) 24 | 25 | f = Path(__file__).parent / "careers_all.csv" 26 | careers.to_csv(f, index=False) 27 | 28 | # Columns to use for the visualizations 29 | stat_cols = ["PTS", "FG_PCT", "FG3_PCT", "FT_PCT", "REB", "AST", "STL", "BLK"] 30 | cols = ["person_id", "GP"] + stat_cols 31 | 32 | # Divide each non-pct stat by the number of games played (to get per-game averages) 33 | careers["PTS"] = careers["PTS"] / careers["GP"] 34 | careers["REB"] = careers["REB"] / careers["GP"] 35 | careers["AST"] = careers["AST"] / careers["GP"] 36 | careers["STL"] = careers["STL"] / careers["GP"] 37 | careers["BLK"] = careers["BLK"] / careers["GP"] 38 | 39 | 40 | # Get the average of each stat for each player (but sum the number of games played) 41 | def apply_func(x): 42 | res = x.mean() 43 | res["GP"] = x["GP"].sum() 44 | return res 45 | 46 | 47 | careers = careers[cols].groupby("person_id").apply(apply_func).reset_index() 48 | 49 | # Merge with players to get from_year and to_year 50 | careers = careers.merge(players[["person_id", "from_year", "to_year"]], on="person_id") 51 | 52 | f = Path(__file__).parent / "careers.csv" 53 | careers.to_csv(f, index=False) 54 | -------------------------------------------------------------------------------- /nba-dashboard/plots.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import plotly.express as px 3 | import plotly.figure_factory as ff 4 | import plotly.graph_objects as go 5 | 6 | color_palette = px.colors.qualitative.D3 7 | 8 | 9 | def radar_chart(percs_df, stats_df, stats): 10 | fig = go.Figure() 11 | 12 | for _, row in percs_df.iterrows(): 13 | id = row["person_id"] 14 | r = [row[x] for x in stats] 15 | vals = stats_df[stats_df["person_id"] == id][stats].values[0] 16 | text = np.round(vals, 2).astype(str).tolist() 17 | fig.add_trace( 18 | go.Scatterpolar( 19 | r=r + r[:1], 20 | theta=stats + stats[:1], 21 | text=text + text[:1], 22 | name=row["player_name"], 23 | hoverinfo="text+name", 24 | line=dict(width=1, color=row["color"]), 25 | ) 26 | ) 27 | 28 | fig.update_layout( 29 | margin=dict(l=30, r=30, t=30, b=30), 30 | polar=dict(radialaxis=dict(range=[0, 1])), 31 | showlegend=True, 32 | legend=dict(orientation="h", y=-0.1, yanchor="top", x=0.5, xanchor="center"), 33 | ) 34 | 35 | return fig 36 | 37 | 38 | def density_plot(careers_df, stats_df, stat, players_dict, on_rug_click): 39 | vals = careers_df[stat] 40 | vals = vals[~vals.isnull()] 41 | fig = ff.create_distplot( 42 | [vals], 43 | ["Overall"], 44 | rug_text=[careers_df["player_name"]], 45 | colors=["black"], 46 | show_hist=False, 47 | ) 48 | # Clean up some defaults (1st trace is the density plot, 2nd is the rug plot) 49 | fig.data[0].hoverinfo = "none" 50 | fig.data[0].showlegend = False 51 | fig.data[1].hoverinfo = "text+x" 52 | fig.data[1].customdata = careers_df["person_id"] 53 | # Use height of the density plot to inform the vertical lines 54 | ymax = fig.data[0].y.max() 55 | # Arrange rows from highest to lowest value so that legend order is correct 56 | stats_df = stats_df.sort_values(stat, ascending=False) 57 | # Add vertical lines for each player 58 | for _, row in stats_df.iterrows(): 59 | x = row[stat] 60 | fig.add_scatter( 61 | x=[x, x], 62 | y=[0, ymax], 63 | mode="lines", 64 | name=players_dict[row["person_id"]], 65 | line=dict(color=row["color"], width=1), 66 | hoverinfo="x+name", 67 | ) 68 | 69 | fig.update_layout( 70 | hovermode="x", 71 | xaxis=dict(title=stat + " per game (career average)", hoverformat=".1f"), 72 | legend=dict(orientation="h", y=1.03, yanchor="bottom", x=0.5, xanchor="center"), 73 | ) 74 | 75 | # Convert Figure to FigureWidget so we can add click events 76 | fig = go.FigureWidget(fig.data, fig.layout) 77 | fig.data[1].on_click(on_rug_click) 78 | 79 | return fig 80 | -------------------------------------------------------------------------------- /nba-dashboard/requirements.txt: -------------------------------------------------------------------------------- 1 | pandas 2 | numpy 3 | nba_api 4 | plotly 5 | shinywidgets 6 | scipy 7 | -------------------------------------------------------------------------------- /nba-dashboard/shared.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pandas as pd 4 | 5 | # Load data 6 | app_dir = Path(__file__).parent 7 | players_df = pd.read_csv(app_dir / "players.csv", dtype={"person_id": str}) 8 | careers_df = pd.read_csv(app_dir / "careers.csv", dtype={"person_id": str}) 9 | 10 | # Define the stats to compare 11 | stats = ["PTS", "FG_PCT", "FG3_PCT", "FT_PCT", "REB", "AST", "STL", "BLK"] 12 | 13 | # create dictionary where key is player id and value is name 14 | players_dict = dict(zip(players_df["person_id"], players_df["display_first_last"])) 15 | 16 | careers_df["player_name"] = careers_df["person_id"].map(players_dict) 17 | 18 | from_start = players_df["from_year"].min() 19 | to_end = players_df["to_year"].max() 20 | gp_max = careers_df["GP"].max() 21 | -------------------------------------------------------------------------------- /nba-dashboard/styles.css: -------------------------------------------------------------------------------- 1 | .plotly .modebar-container { 2 | display: none !important; 3 | } 4 | 5 | :root { 6 | --bslib-sidebar-main-bg: #f8f8f8; 7 | } -------------------------------------------------------------------------------- /regularization/README.md: -------------------------------------------------------------------------------- 1 | ## Regularization app 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /regularization/_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "regularization", 3 | "title": "Article on regularization in ML", 4 | "description": "An article on regularization in machine learning and statistics." 5 | } 6 | -------------------------------------------------------------------------------- /regularization/app-core.py: -------------------------------------------------------------------------------- 1 | # By Chelsea Parlett Pelleriti 2 | import matplotlib.pyplot as plt 3 | 4 | # Import modules for modeling 5 | import pandas as pd 6 | import seaborn as sns 7 | from compare import compare, sim_data 8 | 9 | # Import helper functions from local modules 10 | from shared import mathjax, prose, restrict_width 11 | from shiny import App, reactive, render, ui 12 | 13 | # Define the UI 14 | app_ui = ui.page_fixed( 15 | mathjax, 16 | restrict_width( 17 | ui.h1( 18 | "How Does Regularization Strength Affect Coefficient Estimates?", 19 | class_="text-lg-center text-left", 20 | ), 21 | sm=10, 22 | md=10, 23 | lg=8, 24 | ), 25 | restrict_width( 26 | ui.input_slider( 27 | "a", 28 | "Select a Regularization Strength:", 29 | 0.000000001, 30 | 1, 31 | 0.1, 32 | step=0.01, 33 | width="100%", 34 | ), 35 | ui.p( 36 | {"class": "pt-4 small"}, 37 | "(Notice how, as the strength increases, lasso coefficients approach 0)", 38 | ), 39 | sm=10, 40 | md=7, 41 | lg=5, 42 | pad_y=4, 43 | ), 44 | restrict_width(ui.output_plot("plot"), lg=11), 45 | # Explanation and Explore prose 46 | restrict_width(prose, sm=10, md=10, lg=6), 47 | restrict_width( 48 | ui.h2("Plots Separated by Vowels and Consonants", class_="text-center"), lg=11 49 | ), 50 | # output plots separated by real effects (vowels), and zero-effects (consonants) 51 | restrict_width( 52 | ui.output_plot("plotVOWELS"), 53 | ui.output_plot("plotCONSONANTS"), 54 | lg=11, 55 | pad_y=0, 56 | ), 57 | ui.div(class_="pb-5"), # add padding to bottom of page 58 | ) 59 | 60 | 61 | def server(input, output, session): 62 | # data 63 | nsims = 100 64 | sim = [sim_data(n=1000) for i in range(0, nsims)] 65 | 66 | # reactive Calc that runs LASSO, Ridge, and Linear models on generated data 67 | @reactive.calc 68 | def models(): 69 | sim_alpha = [compare(df, alpha=input.a()) for df in sim] 70 | sim_alpha = pd.concat(sim_alpha) 71 | 72 | return sim_alpha 73 | 74 | # output plot of all simulation coefficients 75 | @render.plot 76 | def plot(): 77 | # get data from reactive Calc 78 | sim_alpha = models() 79 | 80 | # create plot and manage aesthetics 81 | fig, ax = plt.subplots() 82 | ax2 = sns.boxplot( 83 | x="conames", 84 | y="coefs", 85 | hue="model", 86 | data=sim_alpha, 87 | ax=ax, 88 | order=[ 89 | "A", 90 | "E", 91 | "I", 92 | "O", 93 | "U", 94 | "Y", 95 | "W", 96 | "B", 97 | "C", 98 | "D", 99 | "G", 100 | "H", 101 | "J", 102 | "K", 103 | ], 104 | ) 105 | tt = "Coefficient Estimates when alpha = " + str(input.a()) 106 | ax2.set(xlabel="", ylabel="Coefficient Value", title=tt) 107 | return fig 108 | 109 | # output plot of all simulation coefficients (vowels only) 110 | @render.plot 111 | def plotVOWELS(): 112 | # get data from reactive Calc 113 | sim_alpha = models() 114 | vowels = [n in ["A", "E", "I", "O", "U", "Y", "W"] for n in sim_alpha.conames] 115 | sim_alpha_V = sim_alpha.loc[vowels] 116 | 117 | # create plot and manage aesthetics 118 | fig, ax = plt.subplots() 119 | ax2 = sns.boxplot( 120 | x="conames", 121 | y="coefs", 122 | hue="model", 123 | data=sim_alpha_V, 124 | ax=ax, 125 | order=["A", "E", "I", "O", "U", "Y", "W"], 126 | ) 127 | tt = "VOWEL Coefficient Estimates when alpha = " + str(input.a()) 128 | ax2.set(xlabel="", ylabel="Coefficient Value", title=tt) 129 | return fig 130 | 131 | # output plot of all simulation coefficients (consonants only) 132 | @render.plot 133 | def plotCONSONANTS(): 134 | # get data from reactive Calc 135 | sim_alpha = models() 136 | 137 | consonants = [ 138 | n in ["B", "C", "D", "G", "H", "J", "K"] for n in sim_alpha.conames 139 | ] 140 | sim_alpha_C = sim_alpha.loc[consonants] 141 | 142 | # create plot and manage aesthetics 143 | fig, ax = plt.subplots() 144 | ax2 = sns.boxplot( 145 | x="conames", 146 | y="coefs", 147 | hue="model", 148 | data=sim_alpha_C, 149 | ax=ax, 150 | order=["B", "C", "D", "G", "H", "J", "K"], 151 | ) 152 | tt = "CONSONANT Coefficient Estimates when alpha = " + str(input.a()) 153 | ax2.set(xlabel="", ylabel="Coefficient Value", title=tt) 154 | return fig 155 | 156 | 157 | app = App(app_ui, server) 158 | -------------------------------------------------------------------------------- /regularization/app-express.py: -------------------------------------------------------------------------------- 1 | # By Chelsea Parlett Pelleriti 2 | import matplotlib.pyplot as plt 3 | 4 | # Import modules for modeling 5 | import pandas as pd 6 | import seaborn as sns 7 | 8 | # Import custom Python Functions from local file 9 | from compare import compare, sim_data 10 | from shared import mathjax, prose, restrict_width 11 | from shiny import reactive 12 | from shiny.express import input, render, ui 13 | 14 | # Import MathJax for LaTeX rendering 15 | mathjax 16 | 17 | 18 | with restrict_width(sm=10, md=10, lg=8): 19 | ui.h1( 20 | "How Does Regularization Strength Affect Coefficient Estimates?", 21 | class_="text-lg-center text-left", 22 | ) 23 | 24 | with restrict_width(sm=10, md=7, lg=5, pad_y=4): 25 | ui.input_slider( 26 | "a", 27 | "Select a Regularization Strength:", 28 | 0.000000001, 29 | 1, 30 | 0.1, 31 | step=0.01, 32 | width="100%", 33 | ) 34 | ui.p( 35 | {"class": "pt-4 small"}, 36 | "(Notice how, as the strength increases, lasso coefficients approach 0)", 37 | ) 38 | 39 | # Plot of all simulation coefficients 40 | with restrict_width(lg=11): 41 | 42 | @render.plot 43 | def plot(): 44 | # get data from reactive Calc 45 | sim_alpha = models() 46 | 47 | # create plot and manage aesthetics 48 | fig, ax = plt.subplots() 49 | ax2 = sns.boxplot( 50 | x="conames", 51 | y="coefs", 52 | hue="model", 53 | data=sim_alpha, 54 | ax=ax, 55 | order=[ 56 | "A", 57 | "E", 58 | "I", 59 | "O", 60 | "U", 61 | "Y", 62 | "W", 63 | "B", 64 | "C", 65 | "D", 66 | "G", 67 | "H", 68 | "J", 69 | "K", 70 | ], 71 | ) 72 | tt = "Coefficient Estimates when alpha = " + str(input.a()) 73 | ax2.set(xlabel="", ylabel="Coefficient Value", title=tt) 74 | return fig 75 | 76 | 77 | # Explanation and Explore prose 78 | with restrict_width(sm=10, md=10, lg=6): 79 | prose 80 | 81 | 82 | with restrict_width(lg=11): 83 | ui.h2("Plots Separated by Vowels and Consonants", class_="text-center") 84 | 85 | # output plots separated by real effects (vowels), and zero-effects (consonants) 86 | with restrict_width(lg=11, pad_y=0): 87 | # output plot of vowel coefficients 88 | @render.plot 89 | def plotVOWELS(): 90 | # get data from reactive Calc 91 | sim_alpha = models() 92 | vowels = [n in ["A", "E", "I", "O", "U", "Y", "W"] for n in sim_alpha.conames] 93 | sim_alpha_V = sim_alpha.loc[vowels] 94 | 95 | # create plot and manage aesthetics 96 | fig, ax = plt.subplots() 97 | ax2 = sns.boxplot( 98 | x="conames", 99 | y="coefs", 100 | hue="model", 101 | data=sim_alpha_V, 102 | ax=ax, 103 | order=["A", "E", "I", "O", "U", "Y", "W"], 104 | ) 105 | tt = "VOWEL Coefficient Estimates when alpha = " + str(input.a()) 106 | ax2.set(xlabel="", ylabel="Coefficient Value", title=tt) 107 | return fig 108 | 109 | # output plot of all consonants coefficients 110 | @render.plot 111 | def plotCONSONANTS(): 112 | # get data from reactive Calc 113 | sim_alpha = models() 114 | consonants = [ 115 | n in ["B", "C", "D", "G", "H", "J", "K"] for n in sim_alpha.conames 116 | ] 117 | sim_alpha_C = sim_alpha.loc[consonants] 118 | 119 | # create plot and manage aesthetics 120 | fig, ax = plt.subplots() 121 | ax2 = sns.boxplot( 122 | x="conames", 123 | y="coefs", 124 | hue="model", 125 | data=sim_alpha_C, 126 | ax=ax, 127 | order=["B", "C", "D", "G", "H", "J", "K"], 128 | ) 129 | tt = "CONSONANT Coefficient Estimates when alpha = " + str(input.a()) 130 | ax2.set(xlabel="", ylabel="Coefficient Value", title=tt) 131 | return fig 132 | 133 | 134 | ui.div(class_="pb-5") # add padding to bottom of page 135 | 136 | 137 | # data 138 | nsims = 100 139 | sim = [sim_data(n=1000) for i in range(0, nsims)] 140 | 141 | 142 | # reactive Calc that runs LASSO, Ridge, and Linear models on generated data 143 | @reactive.calc 144 | def models(): 145 | sim_alpha = [compare(df, alpha=input.a()) for df in sim] 146 | sim_alpha = pd.concat(sim_alpha) 147 | return sim_alpha 148 | -------------------------------------------------------------------------------- /regularization/compare.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | from sklearn.linear_model import Lasso, LinearRegression, Ridge 4 | 5 | 6 | # define functions 7 | def sim_data(n=1000): 8 | # Real Variables 9 | A = np.random.normal(0, 1, n) 10 | E = np.random.normal(0, 1, n) 11 | I = np.random.normal(0, 1, n) 12 | O = np.random.normal(0, 1, n) 13 | U = np.random.normal(0, 1, n) 14 | Y = np.random.normal(0, 1, n) 15 | W = np.random.normal(0, 1, n) 16 | 17 | # Unrelated Variables 18 | B = np.random.normal(0, 1, n) 19 | C = np.random.normal(0, 1, n) 20 | D = np.random.normal(0, 1, n) 21 | G = np.random.normal(0, 1, n) 22 | H = np.random.normal(0, 1, n) 23 | J = np.random.normal(0, 1, n) 24 | K = np.random.normal(0, 1, n) 25 | 26 | # coefficients 27 | a = 12.34 28 | e = 8.23 29 | i = 7.83 30 | o = 5.12 31 | u = 3.48 32 | y = 2.97 33 | w = 1.38 34 | 35 | # Outcome 36 | X = ( 37 | 100 38 | + A * a 39 | + E * e 40 | + I * i 41 | + O * o 42 | + U * u 43 | + Y * y 44 | + W * w 45 | + np.random.normal(0, 15, n) 46 | ) 47 | 48 | X = (X - np.mean(X)) / np.std(X) # z-score X 49 | # the other variables already have a mean of 0 and sd of 1 50 | 51 | # Data Frame 52 | df = pd.DataFrame( 53 | { 54 | "A": A, 55 | "E": E, 56 | "I": I, 57 | "O": O, 58 | "U": U, 59 | "B": B, 60 | "C": C, 61 | "D": D, 62 | "G": G, 63 | "H": H, 64 | "J": J, 65 | "K": K, 66 | "Y": Y, 67 | "W": W, 68 | "X": X, 69 | } 70 | ) 71 | return df 72 | 73 | 74 | def compare(df, alpha=1): 75 | feat = ["A", "B", "C", "D", "E", "G", "H", "I", "O", "U", "J", "K", "Y", "W"] 76 | 77 | # linear 78 | lr = LinearRegression() 79 | lr.fit(df[feat], df["X"]) 80 | lr_co = lr.coef_ 81 | 82 | # lasso 83 | lasso = Lasso(alpha=alpha, fit_intercept=True, tol=0.0000001, max_iter=100000) 84 | lasso.fit(df[feat], df["X"]) 85 | lasso_co = lasso.coef_ 86 | 87 | # ridge 88 | ridge = Ridge( 89 | alpha=df.shape[0] * alpha, fit_intercept=True, tol=0.0000001, max_iter=100000 90 | ) 91 | ridge.fit(df[feat], df["X"]) 92 | ridge_co = ridge.coef_ 93 | 94 | conames = feat * 3 95 | coefs = np.concatenate([lr_co, lasso_co, ridge_co]) 96 | 97 | model = np.repeat( 98 | np.array(["Linear", "LASSO", "Ridge"]), 99 | [len(feat), len(feat), len(feat)], 100 | axis=0, 101 | ) 102 | 103 | df = pd.DataFrame({"conames": conames, "coefs": coefs, "model": model}) 104 | 105 | return df 106 | -------------------------------------------------------------------------------- /regularization/prose.md: -------------------------------------------------------------------------------- 1 | ### Explanation 2 | 3 | When we train Machine Learning models like linear regressions, logistic 4 | regressions, or neural networks, we do so by defining a loss function 5 | and minimizing that loss function. A loss function is a metric for 6 | measuring how your model is performing where lower is better. For 7 | example, Mean Squared Error is a loss function that measures the squared 8 | distance (on average) between a model's guesses and the true values. 9 | 10 | $$MSE = \\frac{1}{n} \\sum_{i=1}^{n} (Y_i - \hat{Y}_i)^2$$ 11 | 12 | Regularization works by adding a penalty to the loss function in order 13 | to penalize large model parameters. In Linear Regression, the penalty 14 | increases when the size of the coefficients increases. Because the loss 15 | function is made up of two things: the original loss function (the MSE, 16 | here) and the penalty, predictors must 'pull their weight' by reducing 17 | the MSE enough to be 'worth' the penalty. This causes small, unimportant 18 | predictors to have small or zero coefficients. 19 | 20 | LASSO (L1) and Ridge (L2) are two common forms of Regularization. LASSO 21 | adds a penalty to the loss function by taking the absolute value of each 22 | parameter/coefficient, and adding them all together. Ridge adds a 23 | penalty to the loss function by taking the square of each 24 | parameter/coefficient, and adding them all together. 25 | 26 | \$$LASSO = \\frac{1}{n} \\sum_{i=1}^{n} (Y_i - \hat{Y}\_i)^2 + \\lambda \\underbrace{\\sum\_{j=1}^{p} |\\beta_j|}_\\text{penalty}$$ 27 | 28 | $$Ridge = \\frac{1}{n} \\sum_{i=1}^{n} (Y_i - \hat{Y}\_i)^2 + \\lambda \\underbrace{\\sum\_{j=1}^{p} \\beta_j^2}_\\text{penalty}$$ 29 | 30 | When using regularization, we must choose the regularization strength 31 | (see slider above) which is a number that scales how harshly we 32 | penalize. If we multiply the penalty by 0, that's the same as not having 33 | a penalty at all. But if we multiply the penalty by 500, that would 34 | penalize the parameters a lot more. 35 | 36 | $$\\lambda \\text{is the regularization strength.}$$ 37 | 38 | ### Explore 39 | 40 | ##### Comparing LASSO, Ridge, and Linear Regression 41 | 42 | With the slider at 0.1 (the default) look at the boxplot at the top of 43 | the page. This shows the coefficients from 1000 simulated data sets. For 44 | each data set the 'vowels' (A, E, I, O, U, Y, W) do have some 45 | relationship with the outcome (X) that our model is predicting. A has 46 | the largest effect then E, I, O, U, Y and finally W has the smallest 47 | effect on X. The Consonants (B,C,D,G,H,J,K) have absolutely no effect on X. 48 | 49 | Look at the Graph and ask yourself these questions: 50 | 51 | - Which model (Linear, LASSO, Ridge) tends to have the highest 52 | coefficients? What does this tell you about the various penalties 53 | each model has? 54 | - What happens to the LASSO coefficients for the Consonant predictors 55 | (B-K) which have no real effect on X? 56 | - The Linear and Ridge Coefficients look similar for the Consonants 57 | (B-K) but what's slightly different between them? What does that 58 | tell you about what Ridge penalties do? 59 | - Are the larger effects (A-I) affected differently than the smaller 60 | effects (O-W) when you increase the Regularization Strength? 61 | 62 | ##### Comparing Different Regularization Strengths 63 | 64 | Now, using the slider at the top of the page, change the Regularization 65 | Strength. Try values that are very low, moderate, and very high. 66 | 67 | Look at the Graph and ask yourself these questions: 68 | 69 | - What happens to the LASSO and Ridge models when the Regularization 70 | Strength is almost 0? 71 | - What happens to the LASSO model's coefficients when the 72 | Regularization Strength is very high? 73 | - Do the Linear Regression coefficients change when you change 74 | Regularization Strength? (if so, why, if not, why not?) -------------------------------------------------------------------------------- /regularization/requirements.txt: -------------------------------------------------------------------------------- 1 | shiny 2 | pandas 3 | seaborn 4 | compare 5 | scikit-learn 6 | 7 | -------------------------------------------------------------------------------- /regularization/shared.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from shiny import ui 4 | 5 | app_dir = Path(__file__).parent 6 | 7 | 8 | # Helper function to restrict width of content 9 | def restrict_width(*args, sm=None, md=None, lg=None, pad_y=5, **kwargs): 10 | cls = "mx-auto" 11 | if sm: 12 | cls += f" col-sm-{sm}" 13 | if md: 14 | cls += f" col-md-{md}" 15 | if lg: 16 | cls += f" col-lg-{lg}" 17 | 18 | return ui.div(*args, {"class": cls}, {"class": f"py-{pad_y}"}, **kwargs) 19 | 20 | 21 | # Allow LaTeX to be displayed via MathJax 22 | mathjax = ui.head_content( 23 | ui.tags.script( 24 | src="https://mathjax.rstudio.com/latest/MathJax.js?config=TeX-AMS-MML_HTMLorMML" 25 | ), 26 | ui.tags.script("if (window.MathJax) MathJax.Hub.Queue(['Typeset', MathJax.Hub]);"), 27 | ) 28 | 29 | 30 | prose = ui.markdown(open(app_dir / "prose.md").read()) 31 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black 2 | isort 3 | flake8 4 | pytest 5 | pytest-xdist 6 | pytest-playwright 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -r basic-app/requirements.txt 2 | -r basic-app-plot/requirements.txt 3 | -r basic-navigation/requirements.txt 4 | -r basic-sidebar/requirements.txt 5 | -r dashboard-tips/requirements.txt 6 | -r dashboard/requirements.txt 7 | -r database-explorer/requirements.txt 8 | -r map-distance/requirements.txt 9 | -r model-scoring/requirements.txt 10 | -r monitor-database/requirements.txt 11 | -r monitor-file/requirements.txt 12 | -r monitor-folder/requirements.txt 13 | -r nba-dashboard/requirements.txt 14 | -r regularization/requirements.txt 15 | -r stock-app/requirements.txt 16 | -r survey-wizard/requirements.txt 17 | -r survey/requirements.txt 18 | -------------------------------------------------------------------------------- /stock-app/README.md: -------------------------------------------------------------------------------- 1 | ## Stock app 2 | 3 | 4 | -------------------------------------------------------------------------------- /stock-app/_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "stock-app", 3 | "title": "Stock price tracker", 4 | "description": "A basic stock price tracker app." 5 | } 6 | -------------------------------------------------------------------------------- /stock-app/app-core.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import cufflinks as cf 4 | import pandas as pd 5 | import yfinance as yf 6 | from faicons import icon_svg 7 | from shiny import App, Inputs, Outputs, Session, reactive, render, ui 8 | from shinywidgets import output_widget, render_plotly 9 | from stocks import stocks 10 | 11 | # Default to the last 6 months 12 | end = pd.Timestamp.now() 13 | start = end - pd.Timedelta(weeks=26) 14 | 15 | app_dir = Path(__file__).parent 16 | 17 | app_ui = ui.page_sidebar( 18 | ui.sidebar( 19 | ui.input_selectize("ticker", "Select Stocks", choices=stocks, selected="AAPL"), 20 | ui.input_date_range("dates", "Select dates", start=start, end=end), 21 | ), 22 | ui.layout_column_wrap( 23 | ui.value_box( 24 | "Current Price", 25 | ui.output_ui("price"), 26 | showcase=icon_svg("dollar-sign"), 27 | ), 28 | ui.value_box( 29 | "Change", 30 | ui.output_ui("change"), 31 | showcase=ui.output_ui("change_icon"), 32 | ), 33 | ui.value_box( 34 | "Percent Change", 35 | ui.output_ui("change_percent"), 36 | showcase=icon_svg("percent"), 37 | ), 38 | fill=False, 39 | ), 40 | ui.layout_columns( 41 | ui.card( 42 | ui.card_header("Price history"), 43 | output_widget("price_history"), 44 | full_screen=True, 45 | ), 46 | ui.card( 47 | ui.card_header("Latest data"), 48 | ui.output_data_frame("latest_data"), 49 | ), 50 | col_widths=[9, 3], 51 | ), 52 | ui.include_css(app_dir / "styles.css"), 53 | title="Stock explorer", 54 | fillable=True, 55 | ) 56 | 57 | 58 | def server(input: Inputs, output: Outputs, session: Session): 59 | @reactive.calc 60 | def get_ticker(): 61 | return yf.Ticker(input.ticker()) 62 | 63 | @reactive.calc 64 | def get_data(): 65 | dates = input.dates() 66 | return get_ticker().history(start=dates[0], end=dates[1]) 67 | 68 | @reactive.calc 69 | def get_change(): 70 | close = get_data()["Close"] 71 | return close.iloc[-1] - close.iloc[-2] 72 | 73 | @reactive.calc 74 | def get_change_percent(): 75 | close = get_data()["Close"] 76 | change = close.iloc[-1] - close.iloc[-2] 77 | return change / close.iloc[-2] * 100 78 | 79 | @render.ui 80 | def price(): 81 | close = get_data()["Close"] 82 | return f"{close.iloc[-1]:.2f}" 83 | 84 | @render.ui 85 | def change(): 86 | return f"${get_change():.2f}" 87 | 88 | @render.ui 89 | def change_icon(): 90 | change = get_change() 91 | icon = icon_svg("arrow-up" if change >= 0 else "arrow-down") 92 | icon.add_class(f"text-{('success' if change >= 0 else 'danger')}") 93 | return icon 94 | 95 | @render.ui 96 | def change_percent(): 97 | return f"{get_change_percent():.2f}%" 98 | 99 | @render_plotly 100 | def price_history(): 101 | qf = cf.QuantFig( 102 | get_data(), 103 | name=input.ticker(), 104 | up_color="#44bb70", 105 | down_color="#040548", 106 | legend="top", 107 | ) 108 | qf.add_sma() 109 | qf.add_volume(up_color="#44bb70", down_color="#040548") 110 | fig = qf.figure() 111 | fig.update_layout( 112 | hovermode="x unified", 113 | paper_bgcolor="rgba(0,0,0,0)", 114 | plot_bgcolor="rgba(0,0,0,0)", 115 | ) 116 | return fig 117 | 118 | @render.data_frame 119 | def latest_data(): 120 | data = get_data()[:1] # Get latest row 121 | 122 | data.index = data.index.astype(str) 123 | data = data.T 124 | 125 | result = pd.DataFrame( 126 | { 127 | "Category": data.index, 128 | "Value": data.values.flatten(), # Flatten to 1D array 129 | } 130 | ) 131 | 132 | # Format values 133 | result["Value"] = result["Value"].apply(lambda v: f"{v:.1f}") 134 | return result 135 | 136 | 137 | app = App(app_ui, server) 138 | -------------------------------------------------------------------------------- /stock-app/app-express.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import cufflinks as cf 4 | import pandas as pd 5 | import yfinance as yf 6 | from faicons import icon_svg 7 | from shiny import reactive 8 | from shiny.express import input, render, ui 9 | from shiny.ui import output_ui 10 | from shinywidgets import render_plotly 11 | from stocks import stocks 12 | 13 | # Default to the last 6 months 14 | end = pd.Timestamp.now() 15 | start = end - pd.Timedelta(weeks=26) 16 | 17 | 18 | ui.page_opts(title="Stock explorer", fillable=True) 19 | 20 | with ui.sidebar(): 21 | ui.input_selectize("ticker", "Select Stocks", choices=stocks, selected="AAPL") 22 | ui.input_date_range("dates", "Select dates", start=start, end=end) 23 | 24 | 25 | with ui.layout_column_wrap(fill=False): 26 | with ui.value_box(showcase=icon_svg("dollar-sign")): 27 | "Current Price" 28 | 29 | @render.ui 30 | def price(): 31 | close = get_data()["Close"] 32 | return f"{close.iloc[-1]:.2f}" 33 | 34 | with ui.value_box(showcase=output_ui("change_icon")): 35 | "Change" 36 | 37 | @render.ui 38 | def change(): 39 | return f"${get_change():.2f}" 40 | 41 | with ui.value_box(showcase=icon_svg("percent")): 42 | "Percent Change" 43 | 44 | @render.ui 45 | def change_percent(): 46 | return f"{get_change_percent():.2f}%" 47 | 48 | 49 | with ui.layout_columns(col_widths=[9, 3]): 50 | with ui.card(full_screen=True): 51 | ui.card_header("Price history") 52 | 53 | @render_plotly 54 | def price_history(): 55 | qf = cf.QuantFig( 56 | get_data(), 57 | name=input.ticker(), 58 | up_color="#44bb70", 59 | down_color="#040548", 60 | legend="top", 61 | ) 62 | qf.add_sma() 63 | qf.add_volume(up_color="#44bb70", down_color="#040548") 64 | fig = qf.figure() 65 | fig.update_layout( 66 | hovermode="x unified", 67 | paper_bgcolor="rgba(0,0,0,0)", 68 | plot_bgcolor="rgba(0,0,0,0)", 69 | ) 70 | return fig 71 | 72 | with ui.card(): 73 | ui.card_header("Latest data") 74 | 75 | @render.data_frame 76 | def latest_data(): 77 | x = get_data()[:1].T.reset_index() 78 | x.columns = ["Category", "Value"] 79 | x["Value"] = x["Value"].apply(lambda v: f"{v:.1f}") 80 | return x 81 | 82 | 83 | ui.include_css(Path(__file__).parent / "styles.css") 84 | 85 | 86 | @reactive.calc 87 | def get_ticker(): 88 | return yf.Ticker(input.ticker()) 89 | 90 | 91 | @reactive.calc 92 | def get_data(): 93 | dates = input.dates() 94 | return get_ticker().history(start=dates[0], end=dates[1]) 95 | 96 | 97 | @reactive.calc 98 | def get_change(): 99 | close = get_data()["Close"] 100 | if len(close) < 2: 101 | return 0.0 102 | return close.iloc[-1] - close.iloc[-2] 103 | 104 | 105 | @reactive.calc 106 | def get_change_percent(): 107 | close = get_data()["Close"] 108 | if len(close) < 2: 109 | return 0.0 110 | change = close.iloc[-1] - close.iloc[-2] 111 | return change / close.iloc[-2] * 100 112 | 113 | 114 | with ui.hold(): 115 | 116 | @render.ui 117 | def change_icon(): 118 | change = get_change() 119 | icon = icon_svg("arrow-up" if change >= 0 else "arrow-down") 120 | icon.add_class(f"text-{('success' if change >= 0 else 'danger')}") 121 | return icon 122 | -------------------------------------------------------------------------------- /stock-app/requirements.txt: -------------------------------------------------------------------------------- 1 | plotly < 6.0 # https://github.com/santosjorge/cufflinks/issues/289 2 | yfinance 3 | pandas==2.2.1 4 | shiny 5 | shinywidgets 6 | faicons 7 | cufflinks 8 | -------------------------------------------------------------------------------- /stock-app/stocks.py: -------------------------------------------------------------------------------- 1 | stocks = { 2 | "AAPL": "Apple Inc.", 3 | "MSFT": "Microsoft Corporation", 4 | "AMZN": "Amazon.com, Inc.", 5 | "GOOGL": "Alphabet Inc.", 6 | "META": "Meta Platforms", 7 | "BRK-A": "Berkshire Hathaway Inc.", 8 | "V": "Visa Inc.", 9 | "JNJ": "Johnson & Johnson", 10 | "WMT": "Walmart Inc.", 11 | "JPM": "JPMorgan Chase & Co.", 12 | "MA": "Mastercard Incorporated", 13 | "PG": "The Procter & Gamble Company", 14 | "UNH": "UnitedHealth Group Incorporated", 15 | "DIS": "The Walt Disney Company", 16 | "HD": "The Home Depot, Inc.", 17 | "BAC": "Bank of America Corporation", 18 | "NVDA": "NVIDIA Corporation", 19 | "PYPL": "PayPal Holdings, Inc.", 20 | "CMCSA": "Comcast Corporation", 21 | "NFLX": "Netflix, Inc.", 22 | "ADBE": "Adobe Inc.", 23 | "KO": "The Coca-Cola Company", 24 | "NKE": "NIKE, Inc.", 25 | "MRK": "Merck & Co., Inc.", 26 | "PEP": "PepsiCo, Inc.", 27 | "T": "AT&T Inc.", 28 | "PFE": "Pfizer Inc.", 29 | "INTC": "Intel Corporation", 30 | "CSCO": "Cisco Systems, Inc.", 31 | "CRM": "salesforce.com, inc.", 32 | "XOM": "Exxon Mobil Corporation", 33 | } 34 | -------------------------------------------------------------------------------- /stock-app/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bslib-sidebar-main-bg: #f8f8f8; 3 | } 4 | 5 | .plotly .modebar-container { 6 | display: none !important; 7 | } 8 | -------------------------------------------------------------------------------- /survey-wizard/README.md: -------------------------------------------------------------------------------- 1 | # Multi-page wizard 2 | 3 | 4 | 5 | 6 | This application shows how you would build a multi-page wizard app in Shiny. 7 | This is useful for large apps which have a natural progression from page-to-page. 8 | It illustrates a few Shiny techniques: 9 | 10 | 1. The application pages are [modules](https://shiny.posit.co/py/docs/workflow-modules.html) which lets you break them apart and work on them separately. The responses for each page are gathered into a reactive calculation which is returned from the server function to the main application. 11 | 12 | 2. The app uses buttons to navigate through the tabs. Tabsets can serve as inputs to reactive functions, and can also be modified from the server. 13 | 14 | 3. Modals are used to alert the user to important things, like when they've successfully finished the app. 15 | -------------------------------------------------------------------------------- /survey-wizard/_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "survey-wizard", 3 | "title": "Survey wizard form", 4 | "description": "A simple survey wizard that simply writes responses to a file and displays a confirmation message when submitted." 5 | } 6 | -------------------------------------------------------------------------------- /survey-wizard/app-core.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from shiny import App, reactive, render, ui 4 | from shiny_validate import InputValidator, check 5 | 6 | # Define the wizard steps 7 | # "title" is the title of the step 8 | # "contents" is any collection of Shiny UI/inputs 9 | # "input_checks" is a list of tuples (each tuple defines a rule for the InputValidator.add_rule() ) 10 | STEPS = [ 11 | { 12 | "title": "Personal Information", 13 | "contents": [ 14 | ui.input_text("name", "Name"), 15 | ui.input_text("email", "Email (Optional)"), 16 | ], 17 | "input_checks": [("name", check.required())], 18 | }, 19 | { 20 | "title": "Demographics", 21 | "contents": [ 22 | ui.input_numeric("age", "Age", None), 23 | ui.input_numeric("income", "Income (Optional)", None, step=1000), 24 | ], 25 | "input_checks": [("age", check.required())], 26 | }, 27 | ] 28 | 29 | app_ui = ui.page_fixed( 30 | ui.include_css(Path(__file__).parent / "styles.css"), 31 | ui.panel_title("Wizard Demo"), 32 | # A card which holds wizard steps in a hidden navset (so that panels can be 33 | # controlled via action buttons) 34 | ui.card( 35 | ui.card_header(ui.output_text("title")), 36 | ui.navset_hidden( 37 | *[ 38 | ui.nav_panel(step["title"], *step["contents"], value=str(i)) 39 | for i, step in enumerate(STEPS) 40 | ], 41 | id="tabs", 42 | ), 43 | ), 44 | # Action buttons provide navigation between steps 45 | # (conditional panels are used to show/hide the buttons when appropriate) 46 | ui.div( 47 | ui.panel_conditional( 48 | "input.tabs !== '0'", ui.input_action_button("prev", "Previous") 49 | ), 50 | ui.panel_conditional( 51 | f"input.tabs !== '{len(STEPS)-1}'", 52 | ui.input_action_button("next", "Next", class_="btn btn-primary"), 53 | ), 54 | ui.panel_conditional( 55 | f"input.tabs === '{len(STEPS)-1}'", 56 | ui.input_action_button("submit", "Submit", class_="btn btn-primary"), 57 | ), 58 | class_="d-flex justify-content-end gap-3", 59 | ), 60 | ) 61 | 62 | 63 | def server(input, output, session): 64 | # Update card title to reflect the current step 65 | @render.text 66 | def title(): 67 | return STEPS[int(input.tabs())]["title"] 68 | 69 | # Create an input validator for each step 70 | validators: list[InputValidator] = [] 71 | for step in STEPS: 72 | v = InputValidator() 73 | for chk in step["input_checks"]: 74 | v.add_rule(chk[0], chk[1]) 75 | validators.append(v) 76 | 77 | # When next is pressed, first validate the current step, 78 | # then move to the next step 79 | @reactive.effect 80 | @reactive.event(input.next) 81 | def _(): 82 | tab = int(input.tabs()) 83 | v = validators[tab] 84 | v.enable() 85 | if not v.is_valid(): 86 | return 87 | ui.update_navs("tabs", selected=str(tab + 1)) 88 | 89 | # When prev is pressed, move to the previous step 90 | @reactive.effect 91 | @reactive.event(input.prev) 92 | def _(): 93 | tab = int(input.tabs()) 94 | ui.update_navs("tabs", selected=str(tab - 1)) 95 | 96 | # See the survey template to learn how to record the form data 97 | @reactive.effect 98 | @reactive.event(input.submit) 99 | def _(): 100 | ui.modal_show(ui.modal("Form submitted, thank you!")) 101 | 102 | 103 | app = App(app_ui, server) 104 | -------------------------------------------------------------------------------- /survey-wizard/requirements.txt: -------------------------------------------------------------------------------- 1 | pandas 2 | shiny 3 | shiny_validate 4 | -------------------------------------------------------------------------------- /survey-wizard/responses.csv: -------------------------------------------------------------------------------- 1 | id,name,age,gender,occupation,country,allergies,medications,immune_diseases,pregnant,other_health 2 | 1f9d7b4d-20e7-4249-b799-5593b380229e,,,M,,,,,(),Not pregnant, 3 | 6ce0bdc4-4dec-4a77-8255-347061695c0e,,,M,,,,,(),Not pregnant, 4 | 7b6bd732-3745-40a0-ae08-5a9eac89e56a,,,M,,,,,(),Not pregnant, 5 | -------------------------------------------------------------------------------- /survey-wizard/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #dceaf5; 3 | 4 | > .container { 5 | max-width: 475px !important; 6 | margin-left: auto !important; 7 | margin-right: auto !important; 8 | padding-top: 3rem; 9 | padding-bottom: 5rem; 10 | } 11 | } 12 | 13 | .card-body .shiny-input-container { 14 | width: 100% !important; 15 | } 16 | -------------------------------------------------------------------------------- /survey/README.md: -------------------------------------------------------------------------------- 1 | ## Survey app 2 | 3 | 4 | -------------------------------------------------------------------------------- /survey/_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "survey", 3 | "title": "Survey form", 4 | "description": "A simple survey form with a few input fields that simply writes responses to a file and displays a confirmation message when submitted." 5 | } 6 | -------------------------------------------------------------------------------- /survey/app-core.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pandas as pd 4 | from shared import INPUTS 5 | from shiny import App, Inputs, Outputs, Session, reactive, ui 6 | from shiny_validate import InputValidator, check 7 | 8 | app_dir = Path(__file__).parent 9 | 10 | app_ui = ui.page_fixed( 11 | ui.include_css(app_dir / "styles.css"), 12 | ui.panel_title("Movie survey"), 13 | ui.card( 14 | ui.card_header("Demographics"), 15 | INPUTS["name"], 16 | INPUTS["country"], 17 | INPUTS["age"], 18 | ), 19 | ui.card( 20 | ui.card_header("Income"), 21 | INPUTS["income"], 22 | ), 23 | ui.card( 24 | ui.card_header("Ratings"), 25 | INPUTS["avengers"], 26 | INPUTS["spotlight"], 27 | INPUTS["the_big_short"], 28 | ), 29 | ui.div( 30 | ui.input_action_button("submit", "Submit", class_="btn btn-primary"), 31 | class_="d-flex justify-content-end", 32 | ), 33 | ) 34 | 35 | 36 | def server(input: Inputs, output: Outputs, session: Session): 37 | input_validator = InputValidator() 38 | input_validator.add_rule("name", check.required()) 39 | input_validator.add_rule("country", check.required()) 40 | input_validator.add_rule("age", check.required()) 41 | input_validator.add_rule("income", check.required()) 42 | 43 | @reactive.effect 44 | @reactive.event(input.submit) 45 | def save_to_csv(): 46 | input_validator.enable() 47 | if not input_validator.is_valid(): 48 | return 49 | 50 | df = pd.DataFrame([{k: input[k]() for k in INPUTS.keys()}]) 51 | 52 | responses = app_dir / "responses.csv" 53 | if not responses.exists(): 54 | df.to_csv(responses, mode="a", header=True) 55 | else: 56 | df.to_csv(responses, mode="a", header=False) 57 | 58 | ui.modal_show(ui.modal("Form submitted, thank you!")) 59 | 60 | 61 | app = App(app_ui, server) 62 | -------------------------------------------------------------------------------- /survey/app-express.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pandas as pd 4 | from shared import INPUTS 5 | from shiny import reactive 6 | from shiny.express import input, ui 7 | from shiny_validate import InputValidator, check 8 | 9 | app_dir = Path(__file__).parent 10 | ui.include_css(app_dir / "styles.css") 11 | 12 | ui.page_opts(title="Movie survey") 13 | 14 | with ui.card(): 15 | ui.card_header("Demographics") 16 | INPUTS["name"] 17 | INPUTS["country"] 18 | INPUTS["age"] 19 | 20 | with ui.card(): 21 | ui.card_header("Income") 22 | INPUTS["income"] 23 | 24 | with ui.card(): 25 | ui.card_header("Ratings") 26 | INPUTS["avengers"] 27 | INPUTS["spotlight"] 28 | INPUTS["the_big_short"] 29 | 30 | ui.div( 31 | ui.input_action_button("submit", "Submit", class_="btn btn-primary"), 32 | class_="d-flex justify-content-end", 33 | ) 34 | 35 | # Unfortunate workaround to get InputValidator to work in Express 36 | input_validator = None 37 | 38 | 39 | @reactive.effect 40 | def _(): 41 | # Add validation rules for each input that requires validation 42 | global input_validator 43 | input_validator = InputValidator() 44 | input_validator.add_rule("name", check.required()) 45 | input_validator.add_rule("country", check.required()) 46 | input_validator.add_rule("age", check.required()) 47 | input_validator.add_rule("income", check.required()) 48 | 49 | 50 | @reactive.effect 51 | @reactive.event(input.submit) 52 | def save_to_csv(): 53 | input_validator.enable() 54 | if not input_validator.is_valid(): 55 | return 56 | 57 | df = pd.DataFrame([{k: input[k]() for k in INPUTS.keys()}]) 58 | 59 | responses = app_dir / "responses.csv" 60 | if not responses.exists(): 61 | df.to_csv(responses, mode="a", header=True) 62 | else: 63 | df.to_csv(responses, mode="a", header=False) 64 | 65 | ui.modal_show(ui.modal("Form submitted, thank you!")) 66 | -------------------------------------------------------------------------------- /survey/requirements.txt: -------------------------------------------------------------------------------- 1 | shiny 2 | shiny_validate 3 | pandas 4 | -------------------------------------------------------------------------------- /survey/responses.csv: -------------------------------------------------------------------------------- 1 | ,name,country,age,income,avengers,spotlight,the_big_short 2 | 0,fdgdf,Canada,34,34,1,No response,No response 3 | 0,asd,Canada,2,12,,, 4 | -------------------------------------------------------------------------------- /survey/shared.py: -------------------------------------------------------------------------------- 1 | from shiny import ui 2 | 3 | INPUTS = { 4 | "name": ui.input_text("name", "Enter your name"), 5 | "country": ui.input_select( 6 | "country", 7 | "Country", 8 | choices=["", "USA", "Canada", "Portugal"], 9 | ), 10 | "age": ui.input_numeric("age", "Age", None, min=0, max=120, step=1), 11 | "income": ui.input_numeric("income", "Annual income", None, step=1000), 12 | "avengers": ui.input_radio_buttons( 13 | "avengers", 14 | "How would you rate: 'Avengers'?", 15 | choices=[1, 2, 3, 4, 5], 16 | selected=[], 17 | inline=True, 18 | ), 19 | "spotlight": ui.input_radio_buttons( 20 | "spotlight", 21 | "How would you rate: 'Spotlight'?", 22 | choices=[1, 2, 3, 4, 5], 23 | selected=[], 24 | inline=True, 25 | ), 26 | "the_big_short": ui.input_radio_buttons( 27 | "the_big_short", 28 | "How would you rate: 'The Big Short'?", 29 | choices=[1, 2, 3, 4, 5], 30 | selected=[], 31 | inline=True, 32 | ), 33 | } 34 | -------------------------------------------------------------------------------- /survey/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #dceaf5; 3 | 4 | > .container { 5 | max-width: 550px !important; 6 | margin-left: auto !important; 7 | margin-right: auto !important; 8 | padding-top: 3rem; 9 | padding-bottom: 5rem; 10 | } 11 | .card-header, .card-body, .card-footer { 12 | padding-right: 1.75rem; 13 | padding-left: 1.75rem; 14 | } 15 | .card { 16 | padding-bottom: 1rem; 17 | } 18 | } 19 | 20 | .card-body .shiny-input-container { 21 | width: 100% !important; 22 | } -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from typing import Any, Dict, Generator, List 4 | 5 | import pytest 6 | from playwright.sync_api import ( 7 | Browser, 8 | BrowserContext, 9 | Page, 10 | Playwright, 11 | sync_playwright, 12 | ) 13 | 14 | 15 | def load_test_urls() -> List[str]: 16 | base_dir = Path(__file__).parent.parent 17 | config_path = base_dir / "deployments.json" 18 | urls: List[str] = [] 19 | try: 20 | if not config_path.exists(): 21 | print( 22 | f"Warning: Configuration file {config_path} not found. No URLs will be loaded." 23 | ) 24 | return urls 25 | with config_path.open(encoding="utf-8") as f: 26 | data: Dict[str, Any] = json.load(f) 27 | urls = [ 28 | item["url"] 29 | for item in data.get("include", []) 30 | if isinstance(item, dict) and "url" in item 31 | ] 32 | except json.JSONDecodeError as e: 33 | print(f"Error decoding JSON from {config_path}: {e}") 34 | except Exception as e: 35 | print(f"An unexpected error occurred while loading test URLs: {e}") 36 | if not urls: 37 | print( 38 | "Warning: No URLs were loaded. Ensure deployments.json is correctly formatted and contains URLs." 39 | ) 40 | return urls 41 | 42 | 43 | def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: 44 | if ( 45 | "url" in metafunc.fixturenames 46 | and metafunc.function.__name__ == "test_shiny_app_for_errors" 47 | ): 48 | test_urls = load_test_urls() 49 | metafunc.parametrize("url", test_urls) 50 | 51 | 52 | @pytest.fixture(scope="session") 53 | def playwright_instance() -> Generator[Playwright, None, None]: 54 | with sync_playwright() as playwright: 55 | yield playwright 56 | 57 | 58 | @pytest.fixture(scope="function") 59 | def browser_context( 60 | playwright_instance: Playwright, 61 | ) -> Generator[BrowserContext, None, None]: 62 | browser: Browser = playwright_instance.chromium.launch(headless=True) 63 | context: BrowserContext = browser.new_context( 64 | viewport={"width": 1280, "height": 800} 65 | ) 66 | yield context 67 | context.close() 68 | browser.close() 69 | 70 | 71 | @pytest.fixture(scope="function") 72 | def page(browser_context: BrowserContext) -> Generator[Page, None, None]: 73 | test_page: Page = browser_context.new_page() 74 | yield test_page 75 | test_page.close() 76 | -------------------------------------------------------------------------------- /tests/test_shiny_apps.py: -------------------------------------------------------------------------------- 1 | from playwright.sync_api import Page, expect 2 | 3 | PAGE_LOAD_TIMEOUT = 60_000 4 | SHINY_INIT_TIMEOUT = 30_000 5 | ERROR_ELEMENT_TIMEOUT = 1_000 6 | POST_INIT_WAIT = 5_000 7 | 8 | 9 | def wait_for_shiny_initialization( 10 | page: Page, timeout: int = SHINY_INIT_TIMEOUT 11 | ) -> None: 12 | """Wait for Shiny app to complete initialization. 13 | 14 | Args: 15 | page: Playwright Page object 16 | timeout: Maximum time to wait in milliseconds 17 | 18 | Raises: 19 | AssertionError: If initialization fails or times out 20 | """ 21 | try: 22 | init_check = ( 23 | "() => window.Shiny && " 24 | "(window.Shiny.initializedPromise && " 25 | "typeof window.Shiny.initializedPromise.then === 'function')" 26 | ) 27 | page.wait_for_function(init_check, timeout=timeout) 28 | 29 | page.evaluate( 30 | """ 31 | async () => { 32 | return window.Shiny.initializedPromise; 33 | } 34 | """ 35 | ) 36 | 37 | except Exception as e: 38 | error_msg = f"Shiny initialization failed or timed out for {page.url}: {str(e)}" 39 | raise AssertionError(error_msg) 40 | 41 | 42 | def detect_errors_in_page(page: Page, url: str) -> None: 43 | expect(page.locator(".shiny-busy")).to_have_count(0, timeout=SHINY_INIT_TIMEOUT) 44 | error_locator = page.locator(".shiny-output-error") 45 | expect(error_locator).to_have_count(0, timeout=ERROR_ELEMENT_TIMEOUT) 46 | 47 | 48 | def test_shiny_app_for_errors(page: Page, url: str) -> None: 49 | page.goto(url, timeout=PAGE_LOAD_TIMEOUT) 50 | # Wait for shiny to init 51 | wait_for_shiny_initialization(page, timeout=SHINY_INIT_TIMEOUT) 52 | # Wait too long for output to load 53 | page.wait_for_timeout(POST_INIT_WAIT) 54 | detect_errors_in_page(page, url=url) 55 | --------------------------------------------------------------------------------