ImportantCaution
This section is still under development. Thank you for your patience.
This chapter walks through the complete workflow, from a bare app.py to a working app inside an installable package. It’s modeled on the Whole Game chapter of R Packages, 2ed.
I’ll touch on each area of the development workflow used throughout the rest of the book, so you can see how the pieces fit together before we cover each topic in detail.
We’ll start with a single app.py file stored in the pyap repository. The app simulates the Old Faithful geyser waiting times and displays them in a histogram, with a slider that controls the number of bins:
# 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)The app uses the Shiny Core API: app_ui defines the layout, server() defines the outputs, and App() combines them into the app object. The Shiny for Python chapter covers each of these components in detail.
The first step is turning the folder that holds app.py into a uv project. From inside that folder, uv init --package creates the package scaffolding, and --python 3.12 pins the Python version we’re using in this book:1
cd pyap
uv init --package --python 3.12
Initialized project `pyap`
uv takes the project name from the folder name, so the package is named pyap. The folder now looks like this:
pyap/
├── .python-version
├── README.md
├── app.py
├── pyproject.toml
└── src/
└── pyap/
└── __init__.py
.python-version records the Python version uv will use for the project, and src/pyap/ is the importable package. uv won’t overwrite an existing README.md. If the folder isn’t already a Git repository, uv init also runs git init and writes a .gitignore (we’ll come back to Git at the end of the chapter).
Every Python package needs a pyproject.toml file. This is where the package name, version, description, authors, dependencies, and build settings live.2 The file uv init --package created is shown below:
[project]
name = "pyap"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Jane Doe", email = "jane.doe@email.io" }
]
requires-python = ">=3.12"
dependencies = []
[project.scripts]
pyap = "pyap:main"
[build-system]
requires = ["uv_build>=0.12.23,<0.13.0"]
build-backend = "uv_build"uv fills in authors from your Git configuration, and the version range under [build-system] depends on which version of uv you have installed. The [project.scripts] table registers a terminal command (we’ll replace it when we add a launch function below).
The only field we’ll edit right now is description:
description = "A Shiny for Python app-package"The Python Packages chapter covers the rest of the fields in pyproject.toml.
Code in a package lives inside the package directory, so we’ll move app.py into src/pyap/:
mv app.py src/pyap/app.py
pyap/
├── .python-version
├── README.md
├── pyproject.toml
└── src/
└── pyap/
├── __init__.py
└── app.py
Keeping the package under src/ means Python can only import pyap after it’s installed. This sounds like a restriction, but it guarantees our tests run against the installed package (not whatever files happen to be in the working directory). The src/ Layout chapter explains why this matters.
app.py imports shiny, matplotlib, and numpy, so all three need to be listed as package dependencies. uv add records them in pyproject.toml and installs them in one step:
uv add matplotlib numpy shinyThe dependencies field in pyproject.toml now lists each package with a lower version bound (your versions will match whatever was current when you ran the command):
dependencies = [
"matplotlib>=3.11.2",
"numpy>=2.5.3",
"shiny>=1.8.0",
]The first uv add also creates two new items in the project folder. .venv/ is the project’s virtual environment, and uv.lock records the exact version of every package installed in it. The Dependencies chapter covers how to declare, pin, and update dependencies.
uv installs the project itself into .venv/ whenever it syncs the environment, and it uses an editable install by default. An editable install points Python at the files in src/pyap/ instead of copying them, so any changes we make to the package are available the next time it’s imported (without reinstalling).3
uv add already synced the environment, but we can run uv sync any time to bring .venv/ in line with pyproject.toml and uv.lock:
uv syncWe can verify pyap is installed (and points to our source files) by importing it with uv run, which runs a command inside the project’s environment without activating it first:
uv run python -c "import pyap; print(pyap.__file__)"
/path/to/pyap/src/pyap/__init__.py
The Development Workflow chapter covers the edit, sync, and run loop in more detail.
With the dependencies installed, we can launch the app from its new location. The --reload flag restarts the app whenever we save a change to the code, and --launch-browser opens it in a browser:
uv run shiny run --reload --launch-browser src/pyap/app.pyThe app looks exactly the same as it did before we moved it. Press Ctrl + C in the terminal to stop it.
Right now the data are created at the top of app.py, which makes them hard to reuse or test. We’ll move that code into a function in a new module, src/pyap/data.py:
"""Data for the pyap app."""
import numpy as np
def geyser_waiting(seed: int = 42) -> np.ndarray:
"""Simulate Old Faithful geyser waiting times.
Draws from two normal distributions (short and long waits) to
mimic the bimodal shape of the original geyser data.
Args:
seed: Seed for the random number generator.
Returns:
A 1D array of 272 waiting times (in minutes).
"""
rng = np.random.default_rng(seed)
return np.concatenate([
rng.normal(54, 5, 100),
rng.normal(80, 6, 172),
])In src/pyap/app.py, we’ll replace the numpy import and the data code with a call to the new function:
from shiny import App, ui, render
import matplotlib.pyplot as plt
from pyap.data import geyser_waiting
waiting = geyser_waiting()The import uses the full package path (pyap.data), which works because pyap is installed in editable mode. If the app is still running with --reload, saving both files should restart it with the same histogram.
The text between the triple quotes in geyser_waiting() is a docstring. Docstrings are the primary way to document Python code, and they’re stored on the object itself, so there’s no separate step to generate help files.4 We can read it in the Python console:
>>> from pyap.data import geyser_waiting
>>> help(geyser_waiting)The docstring above follows the Google style (Args: and Returns: sections). The Documentation chapter covers docstring conventions and how to build a documentation site from them.
pytest is the most common Python testing framework. We only need it while developing the package (not when running the app), so we’ll add it to the dev dependency group with --dev:
uv add --dev pytest[dependency-groups]
dev = [
"pytest>=9.1.1",
]Tests go in a tests/ folder at the root of the project, in files whose names start with test_. The tests below check that geyser_waiting() returns the expected number of values and that the same seed always produces the same data. We’ll save them in tests/test_data.py:
import numpy as np
from pyap.data import geyser_waiting
def test_geyser_waiting_length():
assert len(geyser_waiting()) == 272
def test_geyser_waiting_reproducible():
np.testing.assert_array_equal(geyser_waiting(seed=1), geyser_waiting(seed=1))uv run pytest finds and runs every test in tests/:
uv run pytestThe output (trimmed) tells us both tests passed:
collected 2 items
tests/test_data.py .. [100%]
============================== 2 passed in 0.15s ===============================
The Test Suite chapter covers configuring pytest and running tests in Positron, and the rest of the Testing section covers fixtures, module tests, and end-to-end tests.
Users of our package shouldn’t need to know the path to app.py to run the app. We’ll add run_app to the shiny import at the top of src/pyap/app.py, then add a run() function at the bottom of the file that calls it:
from shiny import App, ui, render, run_app
# ... app_ui, server(), and app are unchanged ...
def run() -> None:
"""Launch the pyap Shiny app in a browser."""
run_app("pyap.app:app", launch_browser=True)The "pyap.app:app" string tells Shiny to import the app object from the pyap.app module.
Next we’ll register run() as a terminal command by replacing the [project.scripts] entry uv init created:
[project.scripts]
pyap = "pyap.app:run"The original entry pointed to a placeholder main() function in src/pyap/__init__.py. Nothing refers to it anymore, so we can replace the contents of __init__.py with a one-line docstring:
"""A Shiny for Python app-package."""After syncing the environment, the pyap command launches the app:
uv sync
uv run pyapWe can also launch the app from the Python console with from pyap.app import run and run(). The Launch chapter covers entry points in more detail (including running the package with python -m pyap, which requires a src/pyap/__main__.py file containing from pyap.app import run; run()).
uv build packages the project into a source distribution and a wheel (a pre-built, installable archive) in a new dist/ folder:
uv build
dist/
├── pyap-0.1.0-py3-none-any.whl
└── pyap-0.1.0.tar.gz
The wheel is the installable package. Anyone with Python 3.12 or later can install it (along with shiny, matplotlib, and numpy) and launch the app with the pyap command:
pip install dist/pyap-0.1.0-py3-none-any.whl
pyapThe PyPI chapter covers publishing the package so it can be installed by name.
The README.md is the first thing users and contributors see in the repository, so it should explain what the package does and how to install and run it. A minimal version for pyap is below:
# pyap
A Shiny for Python app-package.
## Installation
```bash
pip install git+https://github.com/<username>/pyap.git
```
## Usage
Launch the app from the terminal:
```bash
pyap
```The last step is committing the project to Git. Because pyap is already a Git repository, uv doesn’t generate a .gitignore, so make sure yours lists the virtual environment (.venv/), dist/, and Python cache files (__pycache__/) before committing everything else:
git add .
git commit -m "Create pyap app-package"uv.lock should be committed with the rest of the project. It guarantees everyone who runs uv sync gets the exact same package versions.
If you have the GitHub CLI installed, gh repo create creates the repository on GitHub and pushes the local commits in one step:
gh repo create pyap --public --source=. --pushThe GitHub Setup appendix covers creating the repository, configuring .gitignore, and protecting the main branch.
In this chapter we turned a single app.py file into an installable Python package. The final project is shown below:
pyap/
├── .gitignore
├── .python-version
├── .venv/
├── README.md
├── dist/
├── pyproject.toml
├── src/
│ └── pyap/
│ ├── __init__.py
│ ├── app.py
│ └── data.py
├── tests/
│ └── test_data.py
└── uv.lock
The workflow followed these steps:
uv init --package and edit the metadata in pyproject.toml.app.py into the src/pyap/ package.uv add (which also installs the package in editable mode).pytest.[project.scripts].uv build, write a README.md, and commit the project to Git.The rest of the book covers each of these steps in detail, starting with the Shiny for Python chapter.
uv init --package plays the same role as usethis::create_package().↩︎
pyproject.toml is the Python equivalent of the DESCRIPTION file in an R package.↩︎
An editable install gives us the same quick feedback loop as devtools::load_all().↩︎
Docstrings fill the same role as roxygen2 comments, but there’s no devtools::document() step.↩︎