Exercises

1 Basic App

ExerciseExercise

Open code/demos/app-01-intro.py file in the repository and run it.

Before doing the exercise make a copy of the file for your solution.

Try out any one of the following changes:

  1. Add a third input β€” a checkbox group to filter by species β€” and connect it to the plot
  2. Try ui.input_slider() to filter by one of the numeric columns
  3. Add a @render.text output showing how many points are currently displayed
#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
#| layout: vertical
#| viewerHeight: 650

from palmerpenguins import load_penguins
from plotnine import aes, geom_point, ggplot, theme_minimal
from shiny.express import input, render, ui

dat = load_penguins().dropna()
num_cols = dat.select_dtypes("float64").columns.tolist()
species = dat["species"].unique().tolist()  #<<

ui.input_select("x", "", num_cols, selected="bill_depth_mm")
ui.input_select("y", "", num_cols, selected="body_mass_g")
ui.input_checkbox_group("species", "Species", species, selected=species)  #<<


@render.plot
def plot():
    sel = dat[dat["species"].isin(input.species())]  #<<
    return (
        ggplot(sel, aes(x=input.x(), y=input.y(), color="species"))  #<<
        + geom_point(alpha=0.7)
        + theme_minimal()
    )
#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
#| layout: vertical
#| viewerHeight: 650

from palmerpenguins import load_penguins
from plotnine import aes, geom_point, ggplot, theme_minimal
from shiny.express import input, render, ui

dat = load_penguins().dropna()
num_cols = dat.select_dtypes("float64").columns.tolist()

ui.input_select("x", "", num_cols, selected="bill_depth_mm")
ui.input_select("y", "", num_cols, selected="body_mass_g")
ui.input_slider(  #<<
    "body_mass", "Body Mass (g)",  #<<
    min=int(dat["body_mass_g"].min()),  #<<
    max=int(dat["body_mass_g"].max()),  #<<
    value=[int(dat["body_mass_g"].min()), int(dat["body_mass_g"].max())],  #<<
)  #<<


@render.plot
def plot():
    sel = dat[  #<<
        (dat["body_mass_g"] >= input.body_mass()[0])  #<<
        & (dat["body_mass_g"] <= input.body_mass()[1])  #<<
    ]  #<<
    return (
        ggplot(sel, aes(x=input.x(), y=input.y(), color="species"))  #<<
        + geom_point(alpha=0.7)
        + theme_minimal()
    )
#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
#| layout: vertical
#| viewerHeight: 650

from palmerpenguins import load_penguins
from plotnine import aes, geom_point, ggplot, theme_minimal
from shiny import reactive  #<<
from shiny.express import input, render, ui

dat = load_penguins().dropna()
num_cols = dat.select_dtypes("float64").columns.tolist()
species = dat["species"].unique().tolist()

ui.input_select("x", "", num_cols, selected="bill_depth_mm")
ui.input_select("y", "", num_cols, selected="body_mass_g")
ui.input_checkbox_group("species", "Species", species, selected=species)


@reactive.calc  #<<
def filtered():  #<<
    return dat[dat["species"].isin(input.species())]  #<<


@render.plot
def plot():
    return (
        ggplot(filtered(), aes(x=input.x(), y=input.y(), color="species"))  #<<
        + geom_point(alpha=0.7)
        + theme_minimal()
    )


@render.text  #<<
def count():  #<<
    return f"{len(filtered())} penguins selected"  #<<

2 Reactivity

ExerciseExercise

Part A β€” open code/demos/app-03-reactivity.py file in the repository.

Before doing the exercise make a copy of the file for your solution.

  1. Run it and confirm the scatter plot and count update together
  2. Add a print("filtering...") inside filtered() β€” change Species what do you see?

Part B β€” open code/demos/app-04-reactivity_chained.py file in the repository.

Before doing the exercise make a copy of the file for your solution.

  1. Run it and explore all three outputs (plot, count, table)
  2. Change Y Axis β€” notice count does not change (filtered is still cached)
  3. Add a print to each calc to confirm which ones re-run for each input change

Change the various input components. What do you see?

When you make a change to species, the filter call will only be called once.

  • When you make a change to species, both @reactive.calcs run.
  • When you change input.x() nothing only the figure changes.
  • When you change input.y(), the summary @reactive.calc runs, and the figure changes, but the filtered @reactive.calc() does not run.

3 Tips

ExerciseExercise

Take a look at the code/exercises/app-ex03-tips.py file in the repository.

Before doing the exercise make a copy of the file for your solution.

  1. Replace the plotly scatter plot with the altair scatter plot below.
  2. Wrap the scatter plot in a function, scatterplot(),
  3. Use the appropriate output component decorator, @render_altair
data = ____ # use the reactive calc dataframe here
points = alt.Chart(data).mark_circle().encode(
    x=alt.X("total_bill", title="Total bill"),
    y=alt.Y("tip", title="Tip"),
    tooltip=[
        alt.Tooltip("total_bill:Q", title="Total bill", format=".2f"),
        alt.Tooltip("tip:Q", title="Tip", format=".2f"),
    ],
)
trend = points.transform_regression(
    "total_bill", "tip", method="linear"
).mark_line()
zoom = alt.selection_interval(bind="scales")
return (points + trend).add_params(zoom).properties(width="container")
#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
#| layout: vertical
#| viewerHeight: 650

import altair as alt
import seaborn as sns
from shiny.express import input, ui, render
from shiny import reactive
from shinywidgets import render_altair, render_widget

tips = sns.load_dataset("tips")

# title
ui.page_opts(title="Restaurant tipping", fillable=True)

# sidebar
with ui.sidebar(open="desktop"):
    ui.input_slider(
        id="slider",
        label="Bill amount",
        min=tips.total_bill.min(),
        max=tips.total_bill.max(),
        value=[tips.total_bill.min(), tips.total_bill.max()],
    )
    ui.input_checkbox_group(
        id="checkbox_group",
        label="Food service",
        choices={
            "Lunch": "Lunch",
            "Dinner": "Dinner",
        },
        selected=[
            "Lunch",
            "Dinner",
        ],
    )

@reactive.calc
def filtered_data():
    idx1 = tips.total_bill.between(
        left=input.slider()[0],
        right=input.slider()[1],
        inclusive="both",
    )
    idx2 = tips.time.isin(input.checkbox_group())
    tips_filtered = tips[idx1 & idx2]
    return tips_filtered


# body of application
# first row of value boxes
with ui.layout_columns(fill=False):
    with ui.value_box():
        "Total tippers"

        @render.text
        def total_tippers():
            return filtered_data().shape[0]

    with ui.value_box():
        "Average tip"

        @render.text
        def average_tip():
            perc = filtered_data().tip / filtered_data().total_bill
            return f"{perc.mean():.1%}"

    with ui.value_box():
        "Average bill"

        @render.text
        def average_bill():
            bill = filtered_data().total_bill.mean()
            return f"${bill:.2f}"


# second row of cards
with ui.layout_columns(col_widths=[6, 6]):
    with ui.card(full_screen=True):
        ui.card_header("Tips data")

        @render.data_frame
        def tips_data():
            return filtered_data()

    with ui.card(full_screen=True):
        ui.card_header("Total bill vs tip")

        @render_altair
        def scatterplot():
            data = filtered_data()
            points = alt.Chart(data).mark_circle().encode(
                x=alt.X("total_bill", title="Total bill"),
                y=alt.Y("tip", title="Tip"),
                tooltip=[
                    alt.Tooltip("total_bill:Q", title="Total bill", format=".2f"),
                    alt.Tooltip("tip:Q", title="Tip", format=".2f"),
                ],
            )
            trend = points.transform_regression(
                "total_bill", "tip", method="linear"
            ).mark_line()
            zoom = alt.selection_interval(bind="scales")
            return (points + trend).add_params(zoom).properties(width="container")

4 Tips + Button

ExerciseExercise

Take a look at the code/exercises/app-ex04-tips-button.py file in the repository.

Before doing the exercise make a copy of the file for your solution.

  1. Add a ui.input_action_button() component for the action button.
    • You can use the "action_button" id.
  2. Create a reset_filters() function that uses the ui.update_slider() and ui.update_checkbox_group() functions to reset the filter values to their default state.
def reset_filters():
    ui.update_slider(
        "slider",
        value=[tips.total_bill.min(), tips.total_bill.max()],
    )
    ui.update_checkbox_group(
        "checkbox_group",
        selected=["Lunch", "Dinner"],
    )
  1. Decorate the function with a @reactive.event(input.action_button) call.
  2. Decorate the function again with a @reactive.effect call.
#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
#| layout: vertical
#| viewerHeight: 650

import altair as alt
import seaborn as sns
from shiny.express import input, ui, render
from shiny import reactive
from shinywidgets import render_altair, render_widget

tips = sns.load_dataset("tips")

# title
ui.page_opts(title="Restaurant tipping", fillable=True)

# sidebar
with ui.sidebar(open="desktop"):
    ui.input_slider(
        id="slider",
        label="Bill amount",
        min=tips.total_bill.min(),
        max=tips.total_bill.max(),
        value=[tips.total_bill.min(), tips.total_bill.max()],
    )
    ui.input_checkbox_group(
        id="checkbox_group",
        label="Food service",
        choices={
            "Lunch": "Lunch",
            "Dinner": "Dinner",
        },
        selected=[
            "Lunch",
            "Dinner",
        ],
    )
    ui.input_action_button("action_button", "Reset filter")


@reactive.effect
@reactive.event(input.action_button)
def reset_filters():
    ui.update_slider(
        "slider",
        value=[tips.total_bill.min(), tips.total_bill.max()],
    )
    ui.update_checkbox_group(
        "checkbox_group",
        selected=["Lunch", "Dinner"],
    )

@reactive.calc
def filtered_data():
    idx1 = tips.total_bill.between(
        left=input.slider()[0],
        right=input.slider()[1],
        inclusive="both",
    )
    idx2 = tips.time.isin(input.checkbox_group())
    tips_filtered = tips[idx1 & idx2]
    return tips_filtered


# body of application
# first row of value boxes
with ui.layout_columns(fill=False):
    with ui.value_box():
        "Total tippers"

        @render.text
        def total_tippers():
            return filtered_data().shape[0]

    with ui.value_box():
        "Average tip"

        @render.text
        def average_tip():
            perc = filtered_data().tip / filtered_data().total_bill
            return f"{perc.mean():.1%}"

    with ui.value_box():
        "Average bill"

        @render.text
        def average_bill():
            bill = filtered_data().total_bill.mean()
            return f"${bill:.2f}"


# second row of cards
with ui.layout_columns(col_widths=[6, 6]):
    with ui.card(full_screen=True):
        ui.card_header("Tips data")

        @render.data_frame
        def tips_data():
            return filtered_data()

    with ui.card(full_screen=True):
        ui.card_header("Total bill vs tip")

        @render_altair
        def scatterplot():
            data = filtered_data()
            points = alt.Chart(data).mark_circle().encode(
                x=alt.X("total_bill", title="Total bill"),
                y=alt.Y("tip", title="Tip"),
                tooltip=[
                    alt.Tooltip("total_bill:Q", title="Total bill", format=".2f"),
                    alt.Tooltip("tip:Q", title="Tip", format=".2f"),
                ],
            )
            trend = points.transform_regression(
                "total_bill", "tip", method="linear"
            ).mark_line()
            zoom = alt.selection_interval(bind="scales")
            return (points + trend).add_params(zoom).properties(width="container")

5 Testing

ExerciseExercise

Look at the pattern in code/demos/testing/app.py and test_app.py in the repository β€” it uses run_shiny_app() plus Shiny’s Playwright controllers to drive a running app from a test.

Write a test_app.py for your Tips + Button solution (code/exercise_solutions/app-ex04-button.py):

  1. Confirm the default state β€” the "checkbox_group" checkboxes start selected on ["Lunch", "Dinner"] and total_tippers shows the full row count.
  2. Change "checkbox_group" to only ["Dinner"] and confirm total_tippers updates to the filtered count.
  3. Click the "action_button" (β€œReset filter”) and confirm "checkbox_group" and total_tippers return to their defaults.

Run it with pytest.

Playwright tests drive a real browser β€” they won’t run inside shinylive. Run locally with pytest.

from pathlib import Path

import seaborn as sns
from playwright.sync_api import Page
from shiny.playwright import controller
from shiny.run import run_shiny_app

APP = Path(__file__).parent / "app-ex04-button.py"
tips = sns.load_dataset("tips")


def test_default_state(page: Page):
    with run_shiny_app(str(APP)) as app:
        page.goto(app.url)

        checkbox_group = controller.InputCheckboxGroup(page, "checkbox_group")
        total_tippers = controller.OutputText(page, "total_tippers")

        checkbox_group.expect_selected(["Lunch", "Dinner"])
        total_tippers.expect_value(str(tips.shape[0]))


def test_checkbox_filter(page: Page):
    with run_shiny_app(str(APP)) as app:
        page.goto(app.url)

        checkbox_group = controller.InputCheckboxGroup(page, "checkbox_group")
        total_tippers = controller.OutputText(page, "total_tippers")

        checkbox_group.set(["Dinner"])

        expected = tips[tips["time"] == "Dinner"].shape[0]
        total_tippers.expect_value(str(expected))


def test_reset_button(page: Page):
    with run_shiny_app(str(APP)) as app:
        page.goto(app.url)

        checkbox_group = controller.InputCheckboxGroup(page, "checkbox_group")
        action_button = controller.InputActionButton(page, "action_button")
        total_tippers = controller.OutputText(page, "total_tippers")

        checkbox_group.set(["Dinner"])
        action_button.click()

        checkbox_group.expect_selected(["Lunch", "Dinner"])
        total_tippers.expect_value(str(tips.shape[0]))

6 Deploy Your App

ExerciseExercise
  1. Create a new GitHub repository
  2. Copy your first app code into an app.py file
  3. Make sure your repository has a requirements.txt file
  4. Push the app.py and requirements.txt to GitHub
  5. Deploy it to Posit Connect Cloud

https://docs.posit.co/connect-cloud/how-to/python/shiny-python.html

7 🎁 Bonus: AI Dashboards

Let’s start from the solution in the previous exercise code/exercise_solutions/app-ex04-button.py. You can find the original code in the repository.

We are going to convert this app from having multiple filters into an AI Chatbot using querychat.

  1. Import the querychat library and create the QueryChat object using the existing tips dataset
from querychat.express import QueryChat

qc = QueryChat(tips, "tips", client="github/gpt-4.1")
  1. Replace all the sidebar UI elements with the filters (including button) with the querychat sidebar
qc.sidebar()
  1. Finally, replace the filtered dataframe object with the filtered dataframe returned from querychat
qc.df()

Will not work in shinylive, run locally.


import altair as alt
import seaborn as sns
from shiny.express import input, ui, render
from shiny import reactive
from shinywidgets import render_altair
from querychat.express import QueryChat

tips = sns.load_dataset("tips")

qc = QueryChat(tips, "tips", client="github/gpt-4.1")

# title
ui.page_opts(title="Restaurant tipping", fillable=True)

# sidebar
qc.sidebar()


@reactive.calc
def filtered_data():
    return qc.df()

# body of application
# first row of value boxes
with ui.layout_columns(fill=False):
    with ui.value_box():
        "Total tippers"

        @render.text
        def total_tippers():
            return filtered_data().shape[0]

    with ui.value_box():
        "Average tip"

        @render.text
        def average_tip():
            perc = filtered_data().tip / filtered_data().total_bill
            return f"{perc.mean():.1%}"

    with ui.value_box():
        "Average bill"

        @render.text
        def average_bill():
            bill = filtered_data().total_bill.mean()
            return f"${bill:.2f}"


# second row of cards
with ui.layout_columns(col_widths=[6, 6]):
    with ui.card(full_screen=True):
        ui.card_header("Tips data")

        @render.data_frame
        def tips_data():
            return filtered_data()

    with ui.card(full_screen=True):
        ui.card_header("Total bill vs tip")

        @render_altair
        def scatterplot():
            data = filtered_data()
            points = alt.Chart(data).mark_circle().encode(
                x=alt.X("total_bill", title="Total bill"),
                y=alt.Y("tip", title="Tip"),
                tooltip=[
                    alt.Tooltip("total_bill:Q", title="Total bill", format=".2f"),
                    alt.Tooltip("tip:Q", title="Tip", format=".2f"),
                ],
            )
            trend = points.transform_regression(
                "total_bill", "tip", method="linear"
            ).mark_line()
            zoom = alt.selection_interval(bind="scales")
            return (points + trend).add_params(zoom).properties(width="container")