Python, dalle fondamenta Lezione 12 / 60

Costruire CLI: argparse, click, typer

Le tre librerie CLI che incontrerai, quando scegliere ciascuna, e il pattern typer che oggi sta vincendo.

Prima o poi, ogni progetto Python sviluppa un punto di ingresso da riga di comando. Un data engineer ha bisogno di un modo per lanciare un backfill dal terminale. L’autore di una libreria vuole mytool --version. Un team ha tre script che vogliono tutti flag condivisi. Il pattern è così consistente che ci sono tre librerie ben consolidate per farlo, e quella che scegli dipende perlopiù da quanto ti interessa l’ergonomia e quanto diventerà grande la superficie.

Questa lezione presenta le tre opzioni, argparse, click, typer, scritte fianco a fianco così che i compromessi siano visibili. Poi un esempio leggermente più grande, e poi come distribuire il risultato come comando installabile reale.

argparse: stdlib, nessuna dipendenza

argparse è nella libreria standard. Quello è il suo punto di forza principale: zero installazione, disponibile ovunque. Il costo è un’API verbosa e dichiarativa che sembra più pesante di quanto dovrebbe per quello che stai facendo.

# greet_argparse.py
import argparse

def main() -> None:
    parser = argparse.ArgumentParser(
        prog="greet",
        description="Greet a person by name.",
    )
    parser.add_argument("name", help="who to greet")
    parser.add_argument("--greeting", default="Hello", help="the greeting word")
    parser.add_argument("--count", type=int, default=1, help="how many times")
    parser.add_argument("--shout", action="store_true", help="uppercase the output")

    args: argparse.Namespace = parser.parse_args()
    line: str = f"{args.greeting}, {args.name}!"
    if args.shout:
        line = line.upper()
    for _ in range(args.count):
        print(line)

if __name__ == "__main__":
    main()
$ python greet_argparse.py Marco --greeting Ciao --count 2 --shout
CIAO, MARCO!
CIAO, MARCO!

Cose da notare:

  • Ogni add_argument è una chiamata di metodo con argomenti posizionali + keyword. Verboso ma esplicito.
  • args è un oggetto Namespace, args.name, args.greeting, ecc., non type-checked. I type hint nella tua funzione non aiutano qui.
  • --help è generato automaticamente. Quello è l’unico posto dove argparse è davvero buono.

Quando argparse va bene: un singolo script con cinque flag o meno, e non vuoi una dipendenza. Per qualsiasi cosa di più, il calo di produttività rispetto a click/typer è reale.

click: basato su decoratori, il cavallo di battaglia

click è un pacchetto di terze parti (pip install click) che è stato la libreria CLI Python di fatto per più di un decennio. Il pattern è basato su decoratori, le astrazioni sono mature, e una grossa fetta dell’ecosistema Python ci è costruita sopra. pip stesso è un’applicazione click.

# greet_click.py
import click

@click.command()
@click.argument("name")
@click.option("--greeting", default="Hello", help="the greeting word")
@click.option("--count", default=1, type=int, help="how many times")
@click.option("--shout", is_flag=True, help="uppercase the output")
def main(name: str, greeting: str, count: int, shout: bool) -> None:
    """Greet a person by name."""
    line: str = f"{greeting}, {name}!"
    if shout:
        line = line.upper()
    for _ in range(count):
        click.echo(line)

if __name__ == "__main__":
    main()

Stesso strumento, forma diversa:

  • La firma della funzione è la definizione della CLI. Ogni @click.option e @click.argument aggiunge un parametro.
  • La funzione prende argomenti Python normali, non un Namespace. Fluiscono nel tuo codice con i nomi e i tipi che hai dichiarato.
  • click.echo invece di print, l’unica differenza pratica è che gestisce i casi limite di codifica e rispetta i test runner che catturano l’output.

La grande vittoria di click sono i subcommand. Le CLI reali sono raramente comandi singoli; sono git commit, git push, git status. Il @click.group di click rende questo pulito:

import click

@click.group()
def cli() -> None:
    """Mytool: do things."""

@cli.command()
@click.option("--port", default=8000)
def serve(port: int) -> None:
    """Run the server."""
    click.echo(f"serving on port {port}")

@cli.command()
@click.argument("target")
def migrate(target: str) -> None:
    """Apply migrations up to TARGET."""
    click.echo(f"migrating to {target}")

@cli.command()
def status() -> None:
    """Show current status."""
    click.echo("everything is fine")

if __name__ == "__main__":
    cli()
$ python mytool.py --help
$ python mytool.py serve --port 9000
$ python mytool.py migrate v3
$ python mytool.py status

Ogni subcommand ha il suo --help, i suoi argomenti, la sua logica. I gruppi possono essere annidati: un gruppo cli che contiene un gruppo db che contiene i subcommand migrate, dump, restore.

Per CLI di taglia media-grande, click è la scelta sicura. È stabile, ben documentato, ben supportato, e i pattern sono prevedibili.

typer: guidato dai type hint, la scelta moderna

typer (pip install typer) è costruito sopra a click. La tesi è semplice: se stai già scrivendo type hint, la libreria CLI dovrebbe leggerli e saltare il boilerplate dei decoratori.

# greet_typer.py
from typing import Annotated
import typer

app = typer.Typer()

@app.command()
def main(
    name: Annotated[str, typer.Argument(help="who to greet")],
    greeting: Annotated[str, typer.Option(help="the greeting word")] = "Hello",
    count: Annotated[int, typer.Option(help="how many times")] = 1,
    shout: Annotated[bool, typer.Option(help="uppercase the output")] = False,
) -> None:
    """Greet a person by name."""
    line: str = f"{greeting}, {name}!"
    if shout:
        line = line.upper()
    for _ in range(count):
        typer.echo(line)

if __name__ == "__main__":
    app()

Alcune cose da notare:

  • I type hint (int, bool, str) guidano il parsing. Non scrivi type=int; typer legge l’annotazione.
  • Annotated[T, typer.Option(...)] attacca metadati CLI senza inquinare il tipo a runtime. Questo è lo stile moderno; il codice typer più vecchio usa name: str = typer.Argument(...) come valore di default, che funziona ma è meno pulito.
  • I default nella firma diventano i default nella CLI.
  • La docstring diventa automaticamente la descrizione di --help.

I subcommand funzionano nello stesso modo dei gruppi click, solo con @app.command():

import typer

app = typer.Typer()

@app.command()
def serve(port: int = 8000) -> None:
    """Run the server."""
    typer.echo(f"serving on port {port}")

@app.command()
def migrate(target: str) -> None:
    """Apply migrations up to TARGET."""
    typer.echo(f"migrating to {target}")

@app.command()
def status() -> None:
    """Show current status."""
    typer.echo("everything is fine")

if __name__ == "__main__":
    app()

L’idea di typer è che non c’è quasi codice CLI-specifico, la funzione è la funzione, i tipi sono il parsing, la docstring è l’help. In una codebase dove tutto il resto ha type hint, typer è l’opzione che si adatta allo stile. Nel 2026, la maggior parte delle nuove CLI Python usa typer di default.

Fianco a fianco: stesso strumento, tre librerie

Lo strumento di saluto qui sopra appare in tutti e tre gli stili. Contando le righe non-import, non-commento di scaffolding CLI:

  • argparse: ~7 righe di add_argument più la gestione del parse + namespace.
  • click: 3 righe di decoratori più una firma di funzione.
  • typer: una firma di funzione, punto.

Per uno strumento da cinque flag la differenza è piccola. Per uno strumento da cinquanta flag con cinque subcommand, gli approcci a decoratori/type hint scalano drammaticamente meglio.

Quando scegliere quale:

  • argparse, davvero non puoi prendere una dipendenza (tooling di distribuzione Python, uno script una tantum in un ambiente vincolato), o la CLI è così banale che il costo dell’import batte il guadagno di produttività.
  • click, vuoi una libreria matura, stabile, battle-tested; stai lavorando su una codebase che usa già click; ti serve una feature che typer non ha ancora esposto.
  • typer, qualsiasi cosa nuova, specialmente codice pesante di type hint, specialmente progetti di data engineering e ML.

Help auto-generato

Tutti e tre ti danno gratis --help. La qualità differisce:

  • argparse: funzionale, formattazione occasionalmente goffa.
  • click: rifinito, supporta pannelli di help ricchi, si integra con click-help-colors per il colore.
  • typer: costruito su click, più tira il testo di help direttamente dalle docstring e dai metadati Annotated. Si abbina nativamente con rich per colore e tabelle.
# typer + rich, l'abbinamento tipico del 2026
import typer
from rich.console import Console

app = typer.Typer(rich_markup_mode="rich")
console = Console()

@app.command()
def report(name: str) -> None:
    """Print a [bold green]styled[/bold green] greeting."""
    console.print(f"Hello, [cyan]{name}[/cyan]!")

rich_markup_mode="rich" trasforma i tag della docstring in output colorato. La libreria rich da sola ti dà tabelle, barre di progresso, syntax highlighting e traceback. Per qualsiasi CLI che un umano guarderà, la combinazione typer + rich è il default attuale.

Nota sull’assistenza AI. Gli assistenti di codice sono insolitamente bravi a fare lo scaffolding di nuove CLI. I pattern sono così strutturati, dichiarare flag, parsare, fare dispatch, che uno o due prompt producono uno strumento funzionante. La modalità di fallimento affidabile è l’opposto: tendono a fare over-engineering. Chiedi uno strumento a singolo comando con tre flag e ti torna un framework a quattro subcommand con init, config, validate, e una flag --verbose cablata attraverso ogni livello. Leggi quello che hai ottenuto, cancella quello che non ti serve, tieni le parti che corrispondono allo scope reale. Le CLI sono facili da far crescere in seguito; la complessità preventiva è quella che non puoi rimuovere facilmente.

Caso di studio: come uv struttura la sua CLI

uv è il package manager Python supportato da Rust che ha largamente sostituito pip e pip-tools per i nuovi progetti negli ultimi due anni. Non è scritto in Python, ma la sua struttura di comandi è un riferimento utile perché la stessa forma funziona ugualmente bene con click o typer.

Il livello superiore è un gruppo, con i comandi organizzati per area:

  • uv pip install, uv pip compile, uv pip sync, operazioni compatibili con pip sotto un sottogruppo pip.
  • uv add, uv remove, uv lock, uv sync, operazioni a livello di progetto al livello superiore.
  • uv run, uv tool run, esecuzione.
  • uv python install, uv python list, gestione degli interpreti.

La lezione non sono i comandi specifici; è il raggruppamento. I subcommand si raggruppano per area, e le operazioni più comuni vivono al livello superiore per meno tasti. In typer questo si mappa a:

app = typer.Typer()
pip_app = typer.Typer()
python_app = typer.Typer()
tool_app = typer.Typer()

app.add_typer(pip_app, name="pip")
app.add_typer(python_app, name="python")
app.add_typer(tool_app, name="tool")

@app.command()
def add(package: str) -> None: ...
@app.command()
def sync() -> None: ...

@pip_app.command("install")
def pip_install(package: str) -> None: ...
@pip_app.command("compile")
def pip_compile(spec: str) -> None: ...

La chiamata add_typer monta una sub-applicazione sotto un nome. Dalla prospettiva dell’utente, mytool pip install foo è un solo comando; dalla prospettiva del codice, è una funzione in una sub-app.

Packaging come console script

L’ultimo passo. Ora il tuo strumento gira come python mytool.py args. Le CLI reali girano come mytool args. Il trucco è l’entry point console_scripts.

In pyproject.toml:

[project]
name = "mytool"
version = "0.1.0"
dependencies = ["typer", "rich"]

[project.scripts]
mytool = "mytool.cli:app"

La riga mytool = "mytool.cli:app" dice “crea un eseguibile chiamato mytool che chiama app dal modulo mytool.cli”. L’app qui è l’oggetto applicazione di typer (o per click, la funzione del gruppo). Al momento dell’installazione, pip install . (o uv pip install ., o uv tool install . per un’installazione globale) genera un piccolo script launcher nella directory bin del tuo ambiente, e mytool diventa un comando reale.

Per un progetto destinato a essere installato globalmente senza aggiungere all’ambiente dell’utente, uv tool install (o il più vecchio pipx install) è il pattern giusto, configura un venv isolato per ogni strumento ed espone l’eseguibile su $PATH.

Un template di partenza ragionevole

Per una nuova CLI oggi, lo scheletro di default è così:

# src/mytool/cli.py
from typing import Annotated
import logging
import typer
from rich.console import Console
from rich.logging import RichHandler

app = typer.Typer(no_args_is_help=True, rich_markup_mode="rich")
console = Console()

def _setup_logging(verbose: bool) -> None:
    logging.basicConfig(
        level=logging.DEBUG if verbose else logging.INFO,
        format="%(message)s",
        handlers=[RichHandler(console=console, rich_tracebacks=True)],
    )

@app.callback()
def main(
    verbose: Annotated[bool, typer.Option("--verbose", "-v", help="enable debug logging")] = False,
) -> None:
    """Mytool: do things."""
    _setup_logging(verbose)

@app.command()
def serve(
    port: Annotated[int, typer.Option(help="port to bind")] = 8000,
) -> None:
    """Run the server."""
    logging.getLogger(__name__).info("serving on port %d", port)

@app.command()
def status() -> None:
    """Show current status."""
    console.print("[green]everything is fine[/green]")

if __name__ == "__main__":
    app()

Cosa ti dà questo:

  • Un’app typer con due subcommand.
  • Una flag --verbose di livello superiore gestita nel @app.callback(), che gira prima di qualsiasi subcommand.
  • Logging tramite RichHandler così le righe di log e i traceback sono colorati e allineati.
  • no_args_is_help=True così che eseguire mytool senza nulla stampa l’help invece di uscire silenziosamente.

Aggiungi una entry [project.scripts], pip install ., e mytool serve --port 9000 funziona.

Quello è il pattern moderno. argparse è l’opzione a minore dipendenza, click è il cavallo di battaglia consolidato, typer è dove sta andando il codice nuovo. Qualunque scelga: tieni piccola la superficie, lascia che --help faccia il lavoro di documentazione, logga invece di print, e distribuiscilo come console script nel momento in cui più di una persona lo usa.


References: argparse — Parser for command-line options, click documentation, typer documentation, rich documentation, Python Packaging User Guide — Entry points, uv. Retrieved 2026-05-01.

Cerca