Chapter 42 — Interfaces: Commands, Windows, and Dashboards

Series I5 — First Applications: Web, Maps, and Dashboards

NoteIn this chapter, you will:
  • Build command-line (CLI) tools with argparse and Typer
  • Test a CLI without opening a terminal (subprocess and CliRunner)
  • Make a simple window (GUI) with tkinter and test it without showing it
  • Build your first data dashboard as a self-contained HTML page
  • Wrap the business-unit data in a CLI: the Series I5 running project

All scripts for this chapter are in the kode/ch42/ folder. On the web version, you can also download them all at once (zip).

The scripts you have written so far are run by typing python name.py. That is enough for you, but other people need a friendlier way in. Picture a food stall: some people order at the counter (fast, just say the order), some sit at a table with a picture menu, and some look at the notice board showing today’s sales. One and the same kitchen, three ways to interact. Those are the three kinds of interface we discuss.

Three kinds of interface for the same program.

In this chapter we use the same sample business-unit data (15 units in five sectors, all numbers fictional) from the file data/business_units.csv, read by the helper module unit_data.py.

CLI with argparse

A CLI (Command-Line Interface) is a program used by typing commands in a terminal, like git commit -m "message". argparse is Python’s built-in module for reading that typing.

01_argparse_basics.py
# Chapter 42 - argparse: reading arguments from the command line
import argparse

parser = argparse.ArgumentParser(prog="feed_amount", description="Compute the daily feed for a pond.")
parser.add_argument("biomass_kg", type=float, help="fish biomass (kg)")                 # positional, required
parser.add_argument("--percent", type=float, default=3.0, help="feed percentage (default 3.0)")  # option
parser.add_argument("--detail", action="store_true", help="show the working")          # switch

# Normally argparse reads what you type in the terminal. Here we pass our own lists so you can try it.
for typed in (["432.4"], ["432.4", "--percent", "2.5", "--detail"]):
    args = parser.parse_args(typed)
    feed = args.biomass_kg * args.percent / 100
    print(f"typed {typed} -> {args}")
    print(f"  feed: {feed:.2f} kg" + (" (working: biomass x percent / 100)" if args.detail else ""))

print()
parser.print_help()
typed ['432.4'] -> Namespace(biomass_kg=432.4, percent=3.0, detail=False)
  feed: 12.97 kg
typed ['432.4', '--percent', '2.5', '--detail'] -> Namespace(biomass_kg=432.4, percent=2.5, detail=True)
  feed: 10.81 kg (working: biomass x percent / 100)

usage: feed_amount [-h] [--percent PERCENT] [--detail] biomass_kg

Compute the daily feed for a pond.

positional arguments:
  biomass_kg         fish biomass (kg)

options:
  -h, --help         show this help message and exit
  --percent PERCENT  feed percentage (default 3.0)
  --detail           show the working

Three kinds of arguments appear here:

  • Positional (biomass_kg): required, recognized by its order.
  • Option (--percent): starts with a double hyphen, may be skipped, has a default value.
  • Switch (--detail, action="store_true"): True when written, False when not.

type=float makes argparse turn the text "432.4" into a number. The result of parse_args() is a Namespace object whose attributes you read (args.percent). Normally parse_args() reads what is typed in the terminal; in this script we give it a list so it can be tried without a terminal (the same trick is used for testing). The arithmetic: 432.4 × 3 / 100 = 12.97 kg and 432.4 × 2.5 / 100 = 10.81 kg. The bottom of the output is the automatic help (--help) that argparse builds from the declarations, for free.

When the user mistypes

A good tool rejects wrong input clearly. We test the real unit tool (the helper module cli_argparse.py, shown below) through subprocess from Chapter 41:

cli_argparse.py
# Chapter 42 - The "unit" command-line tool, argparse version (helper module; run through scripts 02 and 03)
import argparse
import sys

from unit_data import read_units, summarize_by_sector

SECTORS = ["forest", "plantation", "paddy", "pond", "barn"]


def build_parser():
    parser = argparse.ArgumentParser(prog="unit", description="View the business-unit data (fictional).")
    sub = parser.add_subparsers(dest="command", required=True)

    list_cmd = sub.add_parser("list", help="show units")
    list_cmd.add_argument("--sector", choices=SECTORS, help="only one sector")
    list_cmd.add_argument("--max", type=int, default=0, help="row limit (0 = all)")

    sub.add_parser("summary", help="summary per sector")
    return parser


def main(argv=None):
    args = build_parser().parse_args(argv)
    units = read_units()
    if args.command == "list":
        if args.sector:
            units = [u for u in units if u["sector"] == args.sector]
        if args.max:
            units = units[: args.max]
        for u in units:
            print(f'{u["id"]:3} {u["sector"]:10} {u["unit"]:8} {u["value"]:8.1f} {u["uom"]}')
    else:
        for sector, r in summarize_by_sector(units).items():
            print(f'{sector:10} {r["count"]} units, mean {r["mean"]:.1f} {r["uom"]}')
    return 0


if __name__ == "__main__":
    sys.exit(main())
02_argparse_errors.py
# Chapter 42 - argparse: what happens when the user mistypes
import subprocess
import sys

for arguments in (["list", "--sector", "lake"], ["list", "--max", "two"], []):
    result = subprocess.run(
        [sys.executable, "cli_argparse.py", *arguments],
        capture_output=True, text=True, encoding="utf-8",
    )
    print("arguments:", arguments)
    print("exit code:", result.returncode)
    print(result.stderr.strip().splitlines()[-1])
    print()
arguments: ['list', '--sector', 'lake']
exit code: 2
unit list: error: argument --sector: invalid choice: 'lake' (choose from 'forest', 'plantation', 'paddy', 'pond', 'barn')

arguments: ['list', '--max', 'two']
exit code: 2
unit list: error: argument --max: invalid int value: 'two'

arguments: []
exit code: 2
unit: error: the following arguments are required: command

The official documentation describes this behaviour: on invalid input, parse_args() prints a usage message to stderr and exits with exit code 2. The choices=SECTORS setting makes the sector lake rejected, type=int rejects two, and a subcommand with required=True rejects an empty input.

Subcommands

Big tools have several commands (git commit, git push). In argparse, that is done with add_subparsers(): list and summary in our unit tool. Test it through a child program, exactly as people would use it:

03_argparse_subcommands.py
# Chapter 42 - argparse: the "unit" tool with subcommands, tested through subprocess
import subprocess
import sys


def unit(*arguments):
    result = subprocess.run([sys.executable, "cli_argparse.py", *arguments],
                            capture_output=True, text=True, encoding="utf-8")
    return result.returncode, result.stdout


for arguments in (["summary"], ["list", "--sector", "pond"], ["list", "--max", "2"]):
    code, output = unit(*arguments)
    print(f"$ unit {' '.join(arguments)}   (exit code {code})")
    print(output)
$ unit summary   (exit code 0)
forest     3 units, mean 35.2 cm
plantation 4 units, mean 257.5 kg
paddy      2 units, mean 1115.2 kg
pond       3 units, mean 369.3 kg
barn       3 units, mean 383.8 kg

$ unit list --sector pond   (exit code 0)
L1  pond       Pond A      432.4 kg
L2  pond       Pond B      380.0 kg
L3  pond       Pond C      295.5 kg

$ unit list --max 2   (exit code 0)
H1  forest     Plot A       32.5 cm
H2  forest     Plot B       45.2 cm

CLI with Typer

argparse is strong, but wordy. Typer (a third-party library built on Click) turns it around: you write an ordinary function with type hints (Chapter 33), and Typer makes the command. A parameter without a default becomes a required argument; a parameter with a default becomes an option; a bool becomes a switch.

04_typer_basics.py
# Chapter 42 - Typer: an ordinary function becomes a command, tested without a terminal
import typer
from typer.testing import CliRunner

app = typer.Typer(add_completion=False)


@app.command()
def feed(biomass_kg: float, percent: float = 3.0, detail: bool = False):
    """Compute the daily feed for a pond."""
    result = biomass_kg * percent / 100
    typer.echo(f"feed: {result:.2f} kg")
    if detail:
        typer.echo("working: biomass x percent / 100")


runner = CliRunner()      # Typer's built-in tester: calls the app without opening a terminal

for typed in (["432.4"], ["432.4", "--percent", "2.5", "--detail"], ["lots"]):
    result = runner.invoke(app, typed)
    print(typed, "-> exit code", result.exit_code)
    print(result.output.strip().splitlines()[0] if result.exit_code == 0 else "(usage error)")

result = runner.invoke(app, ["--help"])
print()
print(result.output)
['432.4'] -> exit code 0
feed: 12.97 kg
['432.4', '--percent', '2.5', '--detail'] -> exit code 0
feed: 10.81 kg
['lots'] -> exit code 2
(usage error)

                                                                               
 Usage: feed [OPTIONS] {biomass_kg}                                            
                                                                               
 Compute the daily feed for a pond.                                            
                                                                               
┌─ Arguments ─────────────────────────────────────────────────────────────────┐
│ *    biomass_kg      <float>  [required]                                    │
└─────────────────────────────────────────────────────────────────────────────┘
┌─ Options ───────────────────────────────────────────────────────────────────┐
│ --percent                   <float>  [default: 3.0]                         │
│ --detail     --no-detail             [default: no-detail]                   │
│ --help                               Show this message and exit.            │
└─────────────────────────────────────────────────────────────────────────────┘
  • The three inputs give: success (12.97), success with an option (10.81), and a usage error (exit code 2) for lots, because it is not a number.
  • CliRunner is Typer’s built-in tester: it calls the app inside the same process and keeps the exit_code and output. No terminal needs to be opened, so it is fast and suits automatic tests.
  • The automatic help uses a boxed look (made by the rich library). Notice --detail and --no-detail: Typer adds the switch pair by itself.

Here is the unit tool in its Typer version, with three commands (list, summary, report):

cli_typer.py
# Chapter 42 - The "unit" command-line tool, Typer version (helper module; used by scripts 04 to 09)
from enum import Enum
from pathlib import Path
from typing import Annotated, Optional

import typer

from unit_data import read_units, summarize_by_sector

app = typer.Typer(help="View the business-unit data (fictional).", no_args_is_help=True, add_completion=False)


class Sector(str, Enum):
    forest = "forest"
    plantation = "plantation"
    paddy = "paddy"
    pond = "pond"
    barn = "barn"


@app.command("list")
def list_units(
    sector: Annotated[Optional[Sector], typer.Option(help="only one sector")] = None,
    max_rows: Annotated[int, typer.Option("--max", min=0, help="row limit (0 = all)")] = 0,
):
    """Show units."""
    units = read_units()
    if sector:
        units = [u for u in units if u["sector"] == sector.value]
    if max_rows:
        units = units[:max_rows]
    for u in units:
        typer.echo(f'{u["id"]:3} {u["sector"]:10} {u["unit"]:8} {u["value"]:8.1f} {u["uom"]}')


@app.command()
def summary():
    """Summary per sector."""
    for sector, r in summarize_by_sector(read_units()).items():
        typer.echo(f'{sector:10} {r["count"]} units, mean {r["mean"]:.1f} {r["uom"]}')


@app.command()
def report(output: Annotated[Path, typer.Argument(help="target Markdown file")]):
    """Write a short report to a file."""
    lines = ["# Business unit summary (fictional data)", ""]
    for sector, r in summarize_by_sector(read_units()).items():
        lines.append(f'- {sector}: {r["count"]} units, mean {r["mean"]:.1f} {r["uom"]}')
    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_text("\n".join(lines) + "\n", encoding="utf-8")
    typer.echo(f"report written: {output.name}")


if __name__ == "__main__":
    app()

An Enum (a fixed list of choices) replaces choices. Annotated[..., typer.Option(...)] adds a description and a limit (min=0). The functions above can be run as a program:

05_typer_subcommands.py
# Chapter 42 - Typer: the full "unit" tool run as a real program through subprocess
import subprocess
import sys


def unit(*arguments):
    result = subprocess.run([sys.executable, "cli_typer.py", *arguments],
                            capture_output=True, text=True, encoding="utf-8")
    return result.returncode, result.stdout + result.stderr


for arguments in (["--help"], ["list", "--sector", "barn"], ["list", "--sector", "lake"]):
    code, text = unit(*arguments)
    print(f"$ unit {' '.join(arguments)}   (exit code {code})")
    print(text)
$ unit --help   (exit code 0)
                                                                               
 Usage: cli_typer.py [OPTIONS] COMMAND [ARGS]...                               
                                                                               
 View the business-unit data (fictional).                                      
                                                                               
┌─ Options ───────────────────────────────────────────────────────────────────┐
│ --help          Show this message and exit.                                 │
└─────────────────────────────────────────────────────────────────────────────┘
┌─ Commands ──────────────────────────────────────────────────────────────────┐
│ list     Show units.                                                        │
│ summary  Summary per sector.                                                │
│ report   Write a short report to a file.                                    │
└─────────────────────────────────────────────────────────────────────────────┘


$ unit list --sector barn   (exit code 0)
D1  barn       Pen 1       350.0 kg
D2  barn       Pen 2       412.5 kg
D3  barn       Pen 3       389.0 kg

$ unit list --sector lake   (exit code 2)
Usage: cli_typer.py list [OPTIONS]
Try 'cli_typer.py list --help' for help.
┌─ Error ─────────────────────────────────────────────────────────────────────┐
│ Invalid value for '--sector': 'lake' is not one of 'forest', 'plantation',  │
│ 'paddy', 'pond', 'barn'.                                                    │
└─────────────────────────────────────────────────────────────────────────────┘

The sector lake is rejected with exit code 2 and an error box naming the valid choices.

TipTIP

Choose argparse when you want zero dependencies (it is built into Python). Choose Typer when your tool has many commands and you like short code based on type hints. Both give automatic help and clear exit codes.

Testing a CLI

An untested CLI will break silently. With CliRunner, each behaviour becomes one check:

06_cli_tests.py
# Chapter 42 - Testing a Typer CLI with CliRunner (pytest style, written as a script)
from pathlib import Path

from typer.testing import CliRunner

from cli_typer import app

runner = CliRunner()
passed = 0


def check(condition, name):
    global passed
    print(("PASS  " if condition else "FAIL  ") + name)
    passed += bool(condition)


result = runner.invoke(app, ["summary"])
check(result.exit_code == 0 and "pond" in result.output, "summary shows the pond sector")

result = runner.invoke(app, ["list", "--sector", "paddy"])
check(len(result.output.strip().splitlines()) == 2, "list --sector paddy gives 2 rows")

result = runner.invoke(app, ["list", "--max", "4"])
check(len(result.output.strip().splitlines()) == 4, "list --max 4 gives 4 rows")

result = runner.invoke(app, ["list", "--sector", "lake"])
check(result.exit_code == 2, "unknown sector is rejected with exit code 2")

target = Path(__file__).parent / "_tmp" / "summary.md"
result = runner.invoke(app, ["report", str(target)])
check(result.exit_code == 0 and target.read_text(encoding="utf-8").startswith("# Business unit"),
      "report writes a Markdown file")

print(f"\n{passed} of 5 checks passed")
PASS  summary shows the pond sector
PASS  list --sector paddy gives 2 rows
PASS  list --max 4 gives 4 rows
PASS  unknown sector is rejected with exit code 2
PASS  report writes a Markdown file

5 of 5 checks passed

This is the pytest style (Chapter 18) written as a script so the output shows. Notice that testing the failure path (an unknown sector must give code 2) is just as important as the success path. There are two ways to test a CLI in this chapter: CliRunner (fast, inside one process) and subprocess (a little slower, but exactly how people use it in a terminal).

A simple GUI with tkinter

A GUI (Graphical User Interface) is a program with windows: buttons, selection boxes, tables. tkinter is the GUI library that comes with Python (it wraps Tk). The official documentation notes that tkinter is an optional module: if it is missing from your copy of Python, look for instructions from whoever provided your Python. On the author’s computer it is there (Tk version 9.0). ttk is the set of themed widgets recommended for new code.

gui_unit.py
# Chapter 42 - A tkinter window for viewing business units (helper module)
import tkinter as tk
from tkinter import ttk

from unit_data import read_units

SECTORS = ["all", "forest", "plantation", "paddy", "pond", "barn"]


def build_window(root):
    """Builds the window contents and returns the widgets that need testing."""
    root.title("Business units (fictional data)")
    choice = tk.StringVar(value="all")
    status = tk.StringVar()

    top = ttk.Frame(root, padding=8)
    top.pack(fill="x")
    ttk.Label(top, text="Sector:").pack(side="left")
    box = ttk.Combobox(top, textvariable=choice, values=SECTORS, state="readonly", width=10)
    box.pack(side="left", padx=6)

    table = ttk.Treeview(root, columns=("unit", "kind", "value"), show="headings", height=8)
    for column, title in (("unit", "Unit"), ("kind", "Kind"), ("value", "Value")):
        table.heading(column, text=title)
    table.pack(fill="both", expand=True, padx=8)
    ttk.Label(root, textvariable=status, padding=8).pack(anchor="w")

    def load(*_):
        table.delete(*table.get_children())
        chosen = [u for u in read_units() if choice.get() in ("all", u["sector"])]
        for u in chosen:
            table.insert("", "end", values=(u["unit"], u["kind"], f'{u["value"]} {u["uom"]}'))
        status.set(f"{len(chosen)} units shown")

    box.bind("<<ComboboxSelected>>", load)
    load()
    return choice, table, status, load


if __name__ == "__main__":
    window = tk.Tk()
    build_window(window)
    window.mainloop()          # shows the window and waits for the user

This window has a sector selection box, a table of units, and a status line. Choosing a sector calls load(), which empties the table and fills it again. Notice the good pattern: the whole window content is built by the function build_window(root), while mainloop() lives only in the if __name__ == "__main__": part.

This code was not run when the book was built (the mainloop() part) because a visible window needs a screen and a user, so no output is shown. You run it yourself with python gui_unit.py. But its content can still be tested without showing anything, because the structure is separated:

07_tkinter_test.py
# Chapter 42 - tkinter: testing the window WITHOUT showing it (the window is hidden)
import tkinter as tk

from gui_unit import build_window

root = tk.Tk()
root.withdraw()                      # hide it: no window appears on the screen
choice, table, status, load = build_window(root)
root.update()

print("window title :", root.title())
print("at the start :", status.get(), "| table rows:", len(table.get_children()))

for sector in ("pond", "forest"):
    choice.set(sector)               # as if the user picked it in the combo box
    load()
    rows = [table.item(i, "values")[0] for i in table.get_children()]
    print(f"{sector:7}      :", status.get(), "->", rows)

root.destroy()
window title : Business units (fictional data)
at the start : 15 units shown | table rows: 15
pond         : 3 units shown -> ['Pond A', 'Pond B', 'Pond C']
forest       : 3 units shown -> ['Plot A', 'Plot B', 'Plot C']

root.withdraw() hides the window so nothing appears on screen. Then we imitate the user: choice.set("pond") is like picking in the box, and load() is like the selection event. The results match: 15 units at the start, 3 pond units (Pond A, B, C), and 3 forest units (Plot A, B, C).

NoteREMEMBER

What is tested here is the window’s logic (the table contents, the status text). The looks (button positions, colours, sizes) cannot be checked without seeing the window itself. Checking the looks is still your job, with your own eyes, on your computer.

An introductory data dashboard

A dashboard is a single page that summarizes the important numbers with tables and charts, like a car’s instrument panel. The simplest dashboard needs no server: one self-contained HTML file that you can open with a double click.

08_static_dashboard.py
# Chapter 42 - Introductory dashboard: one self-contained HTML page with a table and a chart
import base64
import io
from pathlib import Path

import matplotlib.pyplot as plt
import pandas as pd

from unit_data import FILE

df = pd.read_csv(FILE)
summary = df.groupby("sector", sort=False)["value"].agg(["count", "mean"]).round(1)
print(summary)

fig, ax = plt.subplots(figsize=(6, 3))
summary["count"].plot.bar(ax=ax, color="#4f8a3b")
ax.set_ylabel("number of units")
ax.set_xlabel("")
fig.tight_layout()
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=100)
plt.close(fig)
picture = base64.b64encode(buf.getvalue()).decode("ascii")

page = f"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Business unit dashboard</title></head>
<body style="font-family: sans-serif; max-width: 640px; margin: 2em auto">
<h1>Business unit dashboard (fictional data)</h1>
<p>Total units: <b>{len(df)}</b> in <b>{df['sector'].nunique()}</b> sectors.</p>
<img alt="number of units per sector" src="data:image/png;base64,{picture}">
{summary.to_html(classes="summary")}
</body></html>
"""
target = Path(__file__).parent / "_tmp" / "dashboard.html"
target.parent.mkdir(exist_ok=True)
target.write_text(page, encoding="utf-8")
print("\ndashboard.html created:", target.stat().st_size > 5000, "(open it with a double click)")
print("has the embedded chart :", "data:image/png;base64," in page)
            count    mean
sector                   
forest          3    35.2
plantation      4   257.5
paddy           2  1115.2
pond            3   369.3
barn            3   383.8

dashboard.html created: True (open it with a double click)
has the embedded chart : True

The chart is made with matplotlib, turned into base64 text, then embedded directly in the HTML (data:image/png;base64,...), so the file needs no separate image. The summary table comes from pandas’ to_html(). We check that the file was made and contains the chart; we cannot see how it looks from this script, so open _tmp/dashboard.html yourself if you want to see it.

The weakness of a static dashboard: its numbers are frozen when the file is made and there is no interaction. A live dashboard that can be clicked around and shows a map is covered in Chapter 43.

Running Project I5: a CLI for business units

In Chapter 41 the weekly report was fetched by scraping. Now we hand colleagues the unit tool: they only have to type a command. The daily automation script below calls that CLI like a person at a terminal, checks the exit code of each step, and stops when one fails.

09_project_cli.py
# Chapter 42 - Running project I5: the "unit" CLI called by a daily automation script
import subprocess
import sys
from pathlib import Path

target = Path(__file__).parent / "_tmp" / "daily" / "summary.md"


def unit(*arguments):
    result = subprocess.run([sys.executable, "cli_typer.py", *arguments],
                            capture_output=True, text=True, encoding="utf-8")
    return result.returncode, result.stdout.strip()


steps = [
    ("summary", ["summary"]),
    ("first two ponds", ["list", "--sector", "pond", "--max", "2"]),
    ("write report", ["report", str(target)]),
]
for name, arguments in steps:
    code, output = unit(*arguments)
    print(f"[{name}] exit code {code}")
    print(output)
    if code != 0:                    # good automation stops when one step fails
        raise SystemExit(f"step '{name}' failed")

print("\ncontents of the report file:")
print(target.read_text(encoding="utf-8"))
[summary] exit code 0
forest     3 units, mean 35.2 cm
plantation 4 units, mean 257.5 kg
paddy      2 units, mean 1115.2 kg
pond       3 units, mean 369.3 kg
barn       3 units, mean 383.8 kg
[first two ponds] exit code 0
L1  pond       Pond A      432.4 kg
L2  pond       Pond B      380.0 kg
[write report] exit code 0
report written: summary.md

contents of the report file:
# Business unit summary (fictional data)

- forest: 3 units, mean 35.2 cm
- plantation: 4 units, mean 257.5 kg
- paddy: 2 units, mean 1115.2 kg
- pond: 3 units, mean 369.3 kg
- barn: 3 units, mean 383.8 kg

Three steps ran, all with exit code 0. The report file holds the mean of each sector. Take plantation: 4 units with a mean of (245.5 + 310.0 + 198.5 + 276.0) / 4 = 1030.0 / 4 = 257.5 kg. The habit of raise SystemExit(...) when a step fails keeps the automation from carrying on with work built on a broken result.

Exercises

  1. Add a --tall option (a switch) to 01_argparse_basics.py; when written, also print “feed per fish” for 800 fish.
  2. In cli_typer.py, add a command one that takes a unit_id (e.g. D2) and prints that unit, or “not found” with exit code 1. Add two checks in 06_cli_tests.py.
  3. Change gui_unit.py so the status also shows the highest value currently displayed, and update 07_tkinter_test.py to check it.
  4. Challenge: give the unit CLI a dashboard command that calls the code of 08_static_dashboard.py and prints the location of the file. Test it with CliRunner.

Chapter Summary

  • There are three interfaces: command line (CLI), window (GUI), and web page (dashboard).
  • argparse (built in): positional arguments, options, switches, choices, subcommands. Wrong input: a message to stderr and exit code 2.
  • Typer: a function with type hints becomes a command; test with CliRunner (exit_code, output).
  • Test a CLI on two paths: success and failure; CliRunner or subprocess.
  • tkinter: separate building the window from mainloop() so the logic can be tested with withdraw(); the looks still need a human.
  • A static dashboard = one self-contained HTML with an embedded chart; the interactive dashboard waits in Chapter 43.

Next, in Chapter 43: quick data apps and live dashboards: Streamlit, Gradio, Dash, interactive maps, HTMX, how to serve them to users, and the closing of Series I5.