Exercises
1 Basic App
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:
- Add a third input β a checkbox group to filter by species β and connect it to the plot
- Try
ui.input_slider()to filter by one of the numeric columns - Add a
@render.textoutput 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
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.
- Run it and confirm the scatter plot and count update together
- Add a
print("filtering...")insidefiltered()β 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.
- Run it and explore all three outputs (plot, count, table)
- Change Y Axis β notice count does not change (filtered is still cached)
- Add a
printto 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.calcruns, and the figure changes, but the filtered@reactive.calc()does not run.
3 Tips
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.
- Replace the plotly scatter plot with the altair scatter plot below.
- Wrap the scatter plot in a function,
scatterplot(), - 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")
5 Testing
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):
- Confirm the default state β the
"checkbox_group"checkboxes start selected on["Lunch", "Dinner"]andtotal_tippersshows the full row count. - Change
"checkbox_group"to only["Dinner"]and confirmtotal_tippersupdates to the filtered count. - Click the
"action_button"(βReset filterβ) and confirm"checkbox_group"andtotal_tippersreturn 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
- Create a new GitHub repository
- Copy your first app code into an
app.pyfile - Make sure your repository has a
requirements.txtfile - Push the
app.pyandrequirements.txtto GitHub - 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.
- Import the querychat library and create the
QueryChatobject using the existingtipsdataset
from querychat.express import QueryChat
qc = QueryChat(tips, "tips", client="github/gpt-4.1")- Replace all the sidebar UI elements with the filters (including button) with the querychat sidebar
qc.sidebar()- 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")