Code of the Day
IntermediateClick and Subcommands

Lab: Click tool

Build ds-tool — a multi-subcommand CLI with scan, report, and clean subcommands, config file support, and rich output.

Lab · optionalUtilitiesIntermediate35 min
By the end of this lesson you will be able to:
  • Build a top-level Click group with at least three subcommands
  • Read user configuration from a TOML file with CLI flags taking precedence
  • Use rich output in command results
  • Add a confirmation prompt before a destructive operation

This lab builds ds-tool (dataset tool) from scratch — a multi-subcommand CLI with three commands: scan to discover files, report to display a Rich table of results, and clean to delete matched files with a confirmation prompt.

The full tool is assembled in four steps. Run each demo independently to verify it works before moving on.

Step 1 — The group and scan subcommand

scan takes a directory and an extension, walks the directory, and collects file metadata. The group holds shared state (the scan results) in the context:

Python — editable, runs in your browser

Step 2 — The report subcommand

report reads from the shared context and displays a Rich table:

Python — editable, runs in your browser

Step 3 — The clean subcommand with confirmation

clean deletes the scanned files. It always asks for confirmation before deleting. The --yes flag skips the prompt for scripting:

Python — editable, runs in your browser

Destructive operations must always have a confirmation step. The --yes flag exists to allow automation and scripting, not to encourage skipping the confirmation in interactive use. Document the --yes flag clearly so users know it is for scripts.

Step 4 — Config file support

Add a default directory from ~/.config/ds-tool/config.toml so users do not have to type the path every time:

import tomllib
import pathlib

def load_config() -> dict:
    p = pathlib.Path.home() / ".config" / "ds-tool" / "config.toml"
    if p.exists():
        with open(p, "rb") as f:
            return tomllib.load(f).get("defaults", {})
    return {}

@ds.command()
@click.argument("directory", required=False)
@click.option("--ext", default=None)
@click.pass_obj
def scan(obj, directory, ext):
    config = load_config()
    directory = directory or config.get("directory", ".")
    ext = ext or config.get("ext", ".csv")
    ...

With a config file at ~/.config/ds-tool/config.toml containing:

[defaults]
directory = "/data/datasets"
ext = ".parquet"

Running ds-tool scan with no arguments scans /data/datasets for .parquet files. The CLI arguments still override the config values when provided.

What you built

ds-tool now has:

  • A top-level group with three subcommands (scan, report, clean)
  • Shared state through Click's context object
  • Rich table output in the report command
  • A confirmation prompt before destructive deletion
  • Config file support with CLI flags taking precedence

This is the shape of most real-world CLI tools — the Click skills you used here transfer directly.

Where to go next

Next module: Testing CLIs — using Click's CliRunner to write reliable automated tests for every command path.

Finished reading? Mark it complete to track your progress.

On this page