%%{init: {'theme': 'neutral', 'themeVariables': { 'fontFamily': 'monospace', "fontSize":"14px"}}}%%
flowchart TB
SystemPython[("System Python<br/><code>/usr/bin/python3</code>")]
subgraph VenvFlow["venv (standard library)"]
direction TB
VenvCmd["<code>python -m venv .venv</code>"]
VenvDir[".venv/<br/>├── bin/python (symlink)<br/>├── bin/activate<br/>└── lib/site-packages/"]
VenvPip["<code>pip install shiny</code>"]
VenvPkgs["Packages downloaded<br/>from PyPI"]
VenvCmd --> VenvDir
VenvDir --> VenvPip
VenvPip --> VenvPkgs
VenvPkgs --> VenvDir
end
ProjectA["Project A<br/>needs shiny 1.5.5"]
VenvDir -.->|"isolates"| ProjectA
SystemPython --> VenvCmd
style VenvDir text-align:left
classDef system fill:#2d3748,stroke:#48bb78,color:#fff
classDef venvStyle fill:#2c5282,stroke:#63b3ed,color:#fff
classDef project fill:#553c9a,stroke:#b794f4,color:#fff
class SystemPython system
class VenvCmd,VenvDir,VenvPip,VenvPkgs venvStyle
class ProjectA project
2 Shiny for Python
This chapter introduces Shiny for Python using the Core API. We’ll cover the App, ui, server, and render components to build the example application we’ll use throughout the rest of the book.
2.1 Create app directory
We’ll create and move into a new directory (pyap, for Python app-package),
mkdir pyap
cd pyap2.2 Virtual environments
In Python, it’s a good idea to use a virtual environment. This practice might be new to R developers, so I’ll give a quick overview.1
2.2.1 How virtual environments work
Python virtual environments isolate a project’s dependencies, so any packages installed for one project don’t conflict with other projects. Two common tools used in this approach are illustrated below:
Python’s built-in virtual environment tool (venv) ships with Python. The general workflow is:
- Create virtual environment (
python -m venv)
- Use the environment (
activate)
- Install dependencies (
pip install)
venv doesn’t have a built-in lockfile, which also means we’ll need to also run pip freeze to store the dependencies in requirements.txt.
uv is similar, but has a few more benefits:
%%{init: {'theme': 'neutral', 'themeVariables': { 'fontFamily': 'monospace', "fontSize":"14px"}}}%%
flowchart TB
SystemPython[("System Python<br/><code>/usr/bin/python3</code>")]
subgraph UvFlow["uv (Rust-based, fast)"]
direction TB
UvCmd["<code>uv venv</code>"]
UvDir[".venv/<br/>├── bin/python<br/>└── lib/site-packages/"]
UvAdd["<code>uv add shiny</code>"]
UvCache[("Global cache<br/><code>~/.cache/uv/</code>")]
UvLock["<code>uv.lock</code><br/>(reproducible)"]
UvCmd --> UvDir
UvAdd --> UvCache
UvCache -->|"hardlink<br/>(near-instant)"| UvDir
UvAdd --> UvLock
end
ProjectB["Project B<br/>needs shiny 1.6.1"]
SystemPython --> UvCmd
UvDir -.->|"isolates"| ProjectB
style UvDir text-align:left
classDef system fill:#2d3748,stroke:#48bb78,color:#fff
classDef uvStyle fill:#744210,stroke:#f6ad55,color:#fff
classDef project fill:#553c9a,stroke:#b794f4,color:#fff
class SystemPython system
class UvCmd,UvDir,UvAdd,UvCache,UvLock uvStyle
class ProjectB project
uv requires a separate install command, 2 but also includes a global cache with hardlinks. During installation, uv reuses downloaded dependencies across projects, which is typically faster than copying/downloading them each time (as venv does). uv add also installs new dependencies in one step, and the uv.lock file automatically tracks exact versions.
Both tools create a .venv/ folder with an isolated Python interpreter and its own site-packages/ directory. When activated, the shell points to that Python instead of the System Python. So, pip install shiny only installs shiny in this project.
2.2.2 Virtual environment setup
Install uv with:
macOS/Linux:
curl -LsSf https://astral.sh/uv/install.sh | shAfter installation, restart your terminal (or run source ~/.local/bin/env on macOS/Linux) so the uv binary is on your PATH.
Windows (PowerShell)
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"Create a virtual environment, defaults to .venv:
uv venvIf you don’t want to install/use uv, you can use Python’s built-in venv:
python -m venv .venvActivate the virtual environment:
source .venv/bin/activateThe terminal prompt will prepend with (pyap).
Windows (PowerShell):
.venv\Scripts\Activate.ps1 2.2.3 Installing dependencies in virtual environments
Now we can install Shiny into the venv:
uv pip install shiny2.3 Hello Shiny
The first app in this chapter is the Old Faithful histogram we saw at the start of the Whole Game chapter. In the Whole Game, we moved this app into a package without looking closely at how it works. Here we’ll slow down and walk through each piece of the Core API.
If you’re following along with the pyap repository, check out the 02.1_shiny-app branch:
git checkout 02.1_shiny-appThe branch contains a single app.py file (along with a README.md and a .gitignore). The app imports matplotlib and numpy in addition to shiny, so we’ll install both into the virtual environment before running it:
uv pip install matplotlib numpyThe .venv/ folder isn’t tracked by Git, so the same virtual environment stays in place when we switch branches. Each time we check out a new branch, we install whatever new packages that branch needs (the chapter will point these out).
2.3.1 app.py
The full contents of app.py are below:
# This is a Shiny for Python application.
# Run it with: shiny run app.py
from shiny import App, ui, render
import matplotlib.pyplot as plt
import numpy as np
# Old Faithful geyser waiting time data (bimodal distribution)
rng = np.random.default_rng(42)
waiting = np.concatenate([
rng.normal(54, 5, 100), # short-wait eruption cluster
rng.normal(80, 6, 172), # long-wait eruption cluster
])
app_ui = ui.page_sidebar(
ui.sidebar(
ui.input_slider(
"bins",
"Number of bins:",
min=1,
max=50,
value=30,
),
),
ui.output_plot("distPlot"),
title="Old Faithful Geyser Data",
)
def server(input, output, session):
@render.plot
def distPlot():
fig, ax = plt.subplots()
ax.hist(waiting, bins=input.bins(), color="darkgray", edgecolor="white")
ax.set_xlabel("Waiting time to next eruption (in mins)")
ax.set_title("Histogram of waiting times")
return fig
app = App(app_ui, server)Every Core API app is built from the same three parts: a UI object, a server function, and a call to App() that combines them. The waiting data are created at the top of the file (outside the server), so they’re computed once when the app starts rather than each time the plot is drawn.
2.3.2 Imports
The first line imports the three Core API components we’ll use from shiny:
from shiny import App, ui, renderui holds the functions for building the layout, inputs, and outputs. render holds the decorators that turn Python functions into outputs, and App is the class that builds the application object.
2.3.3 UI
The UI is a nested set of function calls stored in app_ui. ui.page_sidebar() creates a page with a sidebar on the left and a main content area on the right:
app_ui = ui.page_sidebar(
ui.sidebar(
ui.input_slider(
"bins",
"Number of bins:",
min=1,
max=50,
value=30,
),
),
ui.output_plot("distPlot"),
title="Old Faithful Geyser Data",
)The first argument to ui.page_sidebar() is the ui.sidebar(), and everything after it goes in the main area. The sidebar holds a single slider input, ui.input_slider(), which takes an id ("bins"), a label, and the min, max, and starting value. The main area holds ui.output_plot(), which reserves space for a plot with the id "distPlot".
These two ids are how the UI and server find each other. The server reads the slider value using "bins", and it sends the plot back to the space reserved by "distPlot".
2.3.4 Server
The server is a function that Shiny calls once for each user session. It takes three arguments: input, output, and session:
def server(input, output, session):
@render.plot
def distPlot():
fig, ax = plt.subplots()
ax.hist(waiting, bins=input.bins(), color="darkgray", edgecolor="white")
ax.set_xlabel("Waiting time to next eruption (in mins)")
ax.set_title("Histogram of waiting times")
return figOutputs are defined as functions inside the server. The @render.plot line is a decorator, a function that wraps the function defined below it. In this case, it tells Shiny that distPlot() returns a plot. The function name is the output id, so distPlot() fills the space created by ui.output_plot("distPlot") in the UI.3
Inside distPlot(), the slider value is read with input.bins(). Inputs are reactive values, so we call them like functions to get their current value.4 Reading input.bins() inside a render function also creates a reactive dependency: whenever the slider moves, Shiny re-runs distPlot() and redraws the histogram. The function builds the histogram with matplotlib and returns the figure, which @render.plot displays in the app.
We don’t use output or session in this app, but we’ll see them again when we cover modules and reactive patterns in later chapters.
2.3.5 App
The last line combines the UI and server into a Shiny application:5
app = App(app_ui, server)The name of this object matters. When we run the app, Shiny looks for an object named app in app.py by default.
2.3.6 Run the app
With the virtual environment activated, we can launch the app from the terminal with shiny run:
shiny run app.pyShiny starts a local web server and prints its address in the terminal (http://127.0.0.1:8000 by default). Open the address in a browser to view the app, and move the slider to confirm the histogram updates with the number of bins.
During development, the same --reload and --launch-browser flags we used in the Whole Game chapter restart the app whenever we save a change and open it in a browser automatically:
shiny run --reload --launch-browser app.pyPress Ctrl + C in the terminal to stop the app.
2.4 Movies app
The Old Faithful app is a good introduction, but it’s too small to show the problems that come up as an app grows. The app we’ll use for the rest of the book is a scatter plot of movie review data, and it has more inputs, a helper function in a separate file, and a dataset that’s read from disk. The code and data come from the Building Web Applications with Shiny course.
Check out the 02.2_movies-app branch to follow along:
git checkout 02.2_movies-appThe branch replaces the Old Faithful code with three files:
pyap/
├── app.py
├── movies.csv
└── utils.py
The movies app reads the data with pandas, so we’ll install it into the virtual environment (shiny and matplotlib are already installed):
uv pip install pandas2.4.1 app.py
The full app.py for the movies app is below:
# Run with: shiny run app.py
from shiny import App, ui, render, reactive
import pandas as pd
from utils import scatter_plot
movies = pd.read_csv("movies.csv")
app_ui = ui.page_sidebar(
ui.sidebar(
ui.input_select(
"y",
"Y-axis:",
choices={
"imdb_rating": "IMDB rating",
"imdb_num_votes": "IMDB number of votes",
"critics_score": "Critics Score",
"audience_score": "Audience Score",
"runtime": "Runtime",
},
selected="audience_score",
),
ui.input_select(
"x",
"X-axis:",
choices={
"imdb_rating": "IMDB rating",
"imdb_num_votes": "IMDB number of votes",
"critics_score": "Critics Score",
"audience_score": "Audience Score",
"runtime": "Runtime",
},
selected="critics_score",
),
ui.input_select(
"z",
"Color by:",
choices={
"title_type": "Title Type",
"genre": "Genre",
"mpaa_rating": "MPAA Rating",
"critics_rating": "Critics Rating",
"audience_rating": "Audience Rating",
},
selected="mpaa_rating",
),
ui.input_slider(
"alpha", "Alpha:", min=0.0, max=1.0, value=0.4, step=0.05
),
ui.input_slider("size", "Size:", min=1, max=5, value=3),
ui.input_text(
"plot_title",
"Plot title",
placeholder="Enter text to be used as plot title",
),
ui.input_action_button("update_plot_title", "Update plot title"),
),
ui.p(
"These data were obtained from ",
ui.a("IMDB", href="http://www.imdb.com/"),
" and ",
ui.a("Rotten Tomatoes", href="https://www.rottentomatoes.com/"),
".",
),
ui.p(
f"The data represent {len(movies)} randomly sampled movies "
"released between 1972 to 2014 in the United States."
),
ui.output_plot("scatterplot"),
ui.hr(),
ui.p(
ui.em(
"The code for this Shiny application comes from ",
ui.a(
"Building Web Applications with Shiny",
href="https://rstudio-education.github.io/shiny-course/",
),
)
),
)
def server(input, output, session):
# only updates title when button is clicked, runs on init with empty string
@reactive.calc
@reactive.event(input.update_plot_title, ignore_none=False, ignore_init=False)
def new_plot_title():
return input.plot_title().title()
@render.plot
def scatterplot():
return scatter_plot(
df=movies,
x_var=input.x(),
y_var=input.y(),
col_var=input.z(),
alpha_var=input.alpha(),
size_var=input.size(),
title=new_plot_title(),
)
app = App(app_ui, server)The structure is the same as the Old Faithful app: imports, a UI object, a server function, and App(). The differences are in what each part contains, so we’ll focus on those below.
2.4.2 Reading data
The app reads movies.csv into a pandas DataFrame (a table of rows and named columns) at the top of the file:
movies = pd.read_csv("movies.csv")Like waiting in the Old Faithful app, movies is created outside the server, so the file is read once when the app starts and every session shares the same data. The path is relative, which means Python looks for movies.csv in the current working directory. We need to run the app from the folder that holds movies.csv (we’ll fix this fragile path when we store the data inside the package in the Data chapter).
The data have 34 columns describing each movie (title, genre, runtime, mpaa_rating, imdb_rating, critics_score, audience_score, and others). The app only plots a handful of them.
2.4.3 Inputs
The sidebar holds seven inputs. Three of them are ui.input_select() dropdowns for the x axis, the y axis, and the color variable. Each dropdown takes a choices dictionary, where the keys are the values sent to the server and the values are the labels displayed in the app:6
ui.input_select(
"y",
"Y-axis:",
choices={
"imdb_rating": "IMDB rating",
"imdb_num_votes": "IMDB number of votes",
"critics_score": "Critics Score",
"audience_score": "Audience Score",
"runtime": "Runtime",
},
selected="audience_score",
),When a user picks “Critics Score” from the dropdown, input.y() returns "critics_score", which is the column name we need for the plot. selected sets the starting value.
The two ui.input_slider() calls control the transparency (alpha) and size of the points. The alpha slider uses step=0.05 to move in small increments between 0.0 and 1.0.
The last two inputs work together. ui.input_text() creates a text box for the plot title, and ui.input_action_button() creates a button that applies it. We’ll see how the server connects them below.
2.4.4 UI text
The main area holds more than the plot output. ui.p(), ui.a(), ui.em(), and ui.hr() create HTML paragraph, link, emphasis, and horizontal rule tags. Positional arguments become the tag’s contents, and keyword arguments (like href) become its attributes:
ui.p(
"These data were obtained from ",
ui.a("IMDB", href="http://www.imdb.com/"),
" and ",
ui.a("Rotten Tomatoes", href="https://www.rottentomatoes.com/"),
".",
),The second paragraph uses an f-string to insert the number of rows in movies with len(movies). Because the UI is built once when the app starts, this number is computed once too (it isn’t reactive).
2.4.5 Reactive outputs
The server has two functions. scatterplot() is the output, and it follows the same pattern as distPlot() in the Old Faithful app: the @render.plot decorator, a function name that matches the ui.output_plot("scatterplot") id, and calls to input.x(), input.y(), and the other inputs. Each input read inside scatterplot() is a reactive dependency, so changing any dropdown or slider redraws the plot.
The plot title works differently. We don’t want the title to change with every keystroke in the text box, only when the button is clicked. new_plot_title() handles this with two decorators from the reactive module:
@reactive.calc
@reactive.event(input.update_plot_title, ignore_none=False, ignore_init=False)
def new_plot_title():
return input.plot_title().title()Decorators are applied from the bottom up, so we’ll read them in that order:
@reactive.event()limits when the function runs. It only responds toinput.update_plot_title(the button), and it ignores changes toinput.plot_title()even though the function reads it.ignore_none=Falseandignore_init=Falselet the function run once when the app starts (before the button has been clicked), so the first plot is drawn with an empty title instead of waiting for a click.@reactive.calcturns the function into a reactive calculation, which caches its result and returns it to any output that callsnew_plot_title().7
.title() is a Python string method that capitalizes the first letter of each word, so typing critics vs audience and clicking the button gives the plot the title “Critics Vs Audience”. scatterplot() calls new_plot_title() just like an input, which makes the reactive calculation one of its dependencies.
2.4.6 Utility functions
app.py doesn’t build the plot itself. The plotting code lives in scatter_plot(), a function in utils.py:
import matplotlib.pyplot as plt
import pandas as pd
def scatter_plot(df, x_var, y_var, col_var, alpha_var, size_var, title=""):
"""Create a scatter plot of df colored by a categorical variable."""
fig, ax = plt.subplots()
categories = sorted(df[col_var].dropna().unique())
cmap = plt.get_cmap("tab10")
for i, cat in enumerate(categories):
subset = df[df[col_var] == cat]
ax.scatter(
subset[x_var],
subset[y_var],
alpha=alpha_var,
s=size_var ** 2 * 10,
color=cmap(i % 10),
label=str(cat),
)
ax.set_xlabel(x_var.replace("_", " ").title())
ax.set_ylabel(y_var.replace("_", " ").title())
if title:
ax.set_title(title)
ax.legend(
title=col_var.replace("_", " ").title(),
bbox_to_anchor=(1.05, 1),
loc="upper left",
)
plt.tight_layout()
return figmatplotlib doesn’t map a categorical variable to colors automatically, so scatter_plot() loops over each category in col_var and draws its points with a color from the tab10 palette. The s argument sets the marker area, so size_var ** 2 * 10 scales the slider’s 1 to 5 range into visible point sizes. The axis and legend labels are built from the column names (critics_score becomes “Critics Score”), and the legend is placed outside the plot area so it doesn’t cover any points.
Moving this code out of app.py keeps the server short, and it gives us a plain Python function we can call (and test) without running the app. app.py imports it with from utils import scatter_plot, which works because utils.py sits in the same folder as app.py. This is the same idea behind the geyser_waiting() function we added in the Whole Game chapter, and it’s where the package structure in later chapters starts to pay off.
2.4.7 Run the movies app
We launch the movies app the same way we launched the Old Faithful app, from the folder that holds app.py and movies.csv:
shiny run --reload --launch-browser app.pyWe can confirm the app is working by changing the x axis, y axis, and color dropdowns and watching the scatter plot update. Typing a title in the text box should leave the plot unchanged until we click Update plot title.
2.5 Project structure
The movies app runs, but nothing in the folder tells another developer (or our future selves) which packages it needs or how the editor should be configured. The 02.3_proj-app branch adds these files:
git checkout 02.3_proj-app
pyap/
├── .gitignore
├── .vscode/
│ └── settings.json
├── README.md
├── app.py
├── movies.csv
├── requirements.txt
└── utils.py
The app code (app.py, utils.py, and movies.csv) is unchanged. The branch adds requirements.txt and .vscode/settings.json, and it updates README.md and .gitignore.
2.5.1 requirements.txt
requirements.txt lists the packages the app depends on, one per line:
shiny>=1.2.0
matplotlib>=3.8.0
pandas>=2.1.0
numpy>=1.26.0Each line sets a minimum version (>=), not an exact one. This makes requirements.txt a looser record than a lockfile: it guarantees a version that’s new enough, but two people installing from it on different days can end up with different versions.8
Anyone who clones the branch can create a virtual environment and install everything in requirements.txt with -r:
uv venv
source .venv/bin/activate
uv pip install -r requirements.txtIf you’re using Python’s built-in venv, the equivalent install command is pip install -r requirements.txt. Once we turn the app into a package, the dependencies will move into pyproject.toml and the exact versions will be recorded in uv.lock (we saw both in the Whole Game chapter, and the Dependencies chapter covers them in detail).
2.5.2 IDE settings
VS Code (and Positron, which is built on the same open source code base) reads project settings from .vscode/settings.json. The settings in this branch are below:
{
"python.defaultInterpreterPath": ".venv/bin/python",
"python.terminal.activateEnvironment": true,
"editor.formatOnSave": true,
"editor.rulers": [88],
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter"
}
}python.defaultInterpreterPath points the editor at the Python inside .venv/, and python.terminal.activateEnvironment activates the virtual environment whenever we open a new terminal. The remaining settings format Python files with Black every time we save, and draw a ruler at 88 characters (Black’s default line length). The Black formatter setting requires the Black Formatter extension.
2.6 Recap
In this chapter we built the app we’ll use throughout the rest of the book, one branch at a time:
02.1_shiny-app: the Old Faithful app introduced the Core API, with a UI object built fromuifunctions, aserver()function with@renderoutputs, andApp()to combine them.02.2_movies-app: the movies app added dropdown, text, and button inputs, read data frommovies.csv, moved the plotting code intoscatter_plot()inutils.py, and used@reactive.calcand@reactive.event()to control when the title updates.02.3_proj-app: the project gained arequirements.txtfor its dependencies and a.vscode/settings.jsonfor its editor configuration.
The project is still a folder of loose files, though. The data path depends on the working directory, utils.py is only importable from the same folder, and requirements.txt doesn’t pin exact versions. In the Packages chapter we’ll start turning this project into an installable Python package.
Python environments are a similar concept to the
renvpackage in R.↩︎Install
uvfrom the Terminal with:curl -LsSf https://astral.sh/uv/install.sh | sh↩︎The decorated function replaces the
output$distPlot <- renderPlot({...})assignment we’d write in R.↩︎input.bins()is the Python equivalent ofinput$bins. The parentheses are easy to forget, especially if you’re used to R.↩︎App()plays the same role asshinyApp()in R.↩︎This is the reverse of a named vector in R’s
selectInput(), where the names are the labels (c("IMDB rating" = "imdb_rating")).↩︎Together, these decorators do the same job as
eventReactive(input$update_plot_title, ..., ignoreNULL = FALSE)in R.↩︎requirements.txtfills a role similar torenv.lock, althoughrenv.lockrecords exact versions and these entries are only lower bounds.↩︎