├── .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 |
--------------------------------------------------------------------------------