Sviluppare applicazioni CLI in Python

Sviluppare applicazioni CLI in Python

Python è da sempre uno dei linguaggi preferiti per gli strumenti da riga di comando: è installato su quasi tutti i sistemi Unix, ha una libreria standard ricchissima e permette di passare in pochi minuti da uno script improvvisato a un programma strutturato. Proprio questa facilità, però, porta spesso a strumenti fragili: argomenti letti da sys.argv a mano, eccezioni che arrivano all'utente sotto forma di traceback, codici di uscita casuali. In questo articolo costruiremo un'applicazione CLI completa, tasks, un gestore di attività con persistenza su file JSON, usando esclusivamente la libreria standard e curando ogni aspetto che distingue uno strumento professionale.

Cosa ci aspettiamo da una buona CLI

Le convenzioni che seguiremo sono quelle consolidate nel mondo Unix:

  • i risultati vanno su standard output, gli errori e la diagnostica su standard error;
  • il codice di uscita è 0 in caso di successo, 1 per gli errori di esecuzione e 2 per gli errori di utilizzo; quest'ultima è anche la convenzione adottata da argparse;
  • l'utente non deve mai vedere un traceback per un errore previsto, come un file mancante o un ID inesistente;
  • i colori compaiono solo se l'output è un terminale e la variabile NO_COLOR non è impostata;
  • per l'uso da script è disponibile un formato strutturato come JSON;
  • un Ctrl+C termina il programma in modo pulito con codice 130.

Lo strumento finale si userà così:

tasks add "Rinnovare il certificato TLS" --priority high
tasks list --all --format json
tasks done 3
tasks rm 3

Struttura del progetto

Adottiamo il layout src, raccomandato dalla Python Packaging Authority perché impedisce di importare accidentalmente il pacchetto dalla directory di lavoro invece che dalla versione installata, e un file pyproject.toml che dichiara il punto di ingresso.

tasks/
├── pyproject.toml
├── src/
│   └── tasks/
│       ├── __init__.py
│       ├── __main__.py
│       ├── cli.py
│       └── store.py
└── tests/
    └── test_cli.py
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "tasks"
version = "1.0.0"
description = "Un semplice gestore di attività da riga di comando"
requires-python = ">=3.10"

[project.scripts]
tasks = "tasks.cli:main"

[project.optional-dependencies]
dev = ["pytest"]

La sezione [project.scripts] è il meccanismo standard per dichiarare un eseguibile: al momento dell'installazione, pip o uv creano nel PATH un piccolo programma chiamato tasks che importa il modulo tasks.cli e invoca la funzione main. Su Windows viene generato automaticamente un file .exe equivalente.

Il file __main__.py permette invece di eseguire il pacchetto con python -m tasks, utile durante lo sviluppo e nei contesti in cui il PATH non è configurato:

from tasks.cli import main

raise SystemExit(main())

Il livello di persistenza

Iniziamo dal modulo store.py, che non sa nulla della riga di comando. Questa separazione è fondamentale: la logica dei dati deve essere riutilizzabile e testabile indipendentemente dall'interfaccia.

"""Persistenza delle attività su file JSON."""

from __future__ import annotations

import json
import os
import sys
import tempfile
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path


class StoreError(Exception):
    """Errore di esecuzione legato ai dati (codice di uscita 1)."""


class TaskNotFound(StoreError):
    def __init__(self, task_id: int) -> None:
        super().__init__(f"attività non trovata: {task_id}")
        self.task_id = task_id


class Priority(str, Enum):
    LOW = "low"
    NORMAL = "normal"
    HIGH = "high"


@dataclass
class Task:
    id: int
    title: str
    priority: Priority = Priority.NORMAL
    done: bool = False
    created_at: str = field(
        default_factory=lambda: datetime.now(timezone.utc).isoformat(timespec="seconds")
    )

    def to_dict(self) -> dict:
        data = asdict(self)
        data["priority"] = self.priority.value
        return data

    @classmethod
    def from_dict(cls, data: dict) -> Task:
        return cls(
            id=int(data["id"]),
            title=str(data["title"]),
            priority=Priority(data.get("priority", "normal")),
            done=bool(data.get("done", False)),
            created_at=str(data.get("created_at", "")),
        )

Il percorso predefinito del file segue le convenzioni della piattaforma: la variabile TASKS_FILE ha la precedenza, poi su Windows si usa %APPDATA%, su macOS ~/Library/Application Support e sugli altri sistemi la specifica XDG. Il pacchetto esterno platformdirs fa esattamente questo, ma la logica è abbastanza semplice da non giustificare una dipendenza.

def default_path() -> Path:
    """Restituisce il percorso del file dati rispettando le convenzioni del sistema."""
    if env_path := os.environ.get("TASKS_FILE"):
        return Path(env_path)
    if sys.platform == "win32":
        base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
    elif sys.platform == "darwin":
        base = Path.home() / "Library" / "Application Support"
    else:
        base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
    return base / "tasks" / "tasks.json"

La classe Store espone il caricamento e il salvataggio. Come in ogni linguaggio, il salvataggio deve essere atomico: scriviamo su un file temporaneo nella stessa directory, forziamo la scrittura su disco con os.fsync e sostituiamo l'originale con os.replace, che a differenza di os.rename sovrascrive la destinazione anche su Windows.

class Store:
    def __init__(self, path: Path) -> None:
        self.path = path

    def load(self) -> list[Task]:
        """Legge le attività; un file assente equivale a una lista vuota."""
        try:
            raw = self.path.read_text(encoding="utf-8")
        except FileNotFoundError:
            return []
        except OSError as exc:
            raise StoreError(f"impossibile leggere {self.path}: {exc.strerror}") from exc
        try:
            return [Task.from_dict(item) for item in json.loads(raw)]
        except (json.JSONDecodeError, KeyError, ValueError, TypeError) as exc:
            raise StoreError(f"il file {self.path} non è valido: {exc}") from exc

    def save(self, tasks: list[Task]) -> None:
        """Scrive le attività in modo atomico."""
        self.path.parent.mkdir(parents=True, exist_ok=True)
        payload = json.dumps([t.to_dict() for t in tasks], indent=2, ensure_ascii=False)
        fd, tmp_name = tempfile.mkstemp(dir=self.path.parent, prefix=".tasks-", suffix=".json")
        try:
            with os.fdopen(fd, "w", encoding="utf-8") as tmp:
                tmp.write(payload + "\n")
                tmp.flush()
                os.fsync(tmp.fileno())
            os.replace(tmp_name, self.path)
        except BaseException:
            # Qualunque interruzione, Ctrl+C incluso, non deve lasciare file orfani.
            Path(tmp_name).unlink(missing_ok=True)
            raise

    @staticmethod
    def next_id(tasks: list[Task]) -> int:
        return max((t.id for t in tasks), default=0) + 1

    @staticmethod
    def find(tasks: list[Task], task_id: int) -> int:
        for index, task in enumerate(tasks):
            if task.id == task_id:
                return index
        raise TaskNotFound(task_id)

Il blocco except BaseException merita una spiegazione: normalmente catturare BaseException è una cattiva pratica, ma qui la rilanciamo subito dopo aver fatto pulizia. Serve a includere KeyboardInterrupt, che non deriva da Exception, così che un Ctrl+C durante la scrittura non lasci file temporanei nella directory dei dati.

Il parsing degli argomenti con argparse

argparse fa parte della libreria standard e supporta tutto ciò che serve: sottocomandi, opzioni con valori ammessi, conversione dei tipi, aiuto generato automaticamente e messaggi di errore coerenti. Esistono alternative di terze parti molto popolari, come Click e Typer, ma argparse ha il vantaggio di non introdurre dipendenze, e uno strumento da riga di comando senza dipendenze è molto più semplice da distribuire.

Costruiamo il parser in una funzione dedicata, così da poterlo riutilizzare nei test e, se necessario, per generare le pagine di manuale.

"""Interfaccia a riga di comando di tasks."""

from __future__ import annotations

import argparse
import json
import os
import signal
import sys
from typing import TextIO

from tasks import __version__
from tasks.store import Priority, Store, StoreError, Task, default_path

EXIT_OK = 0
EXIT_FAILURE = 1
EXIT_USAGE = 2


def positive_int(value: str) -> int:
    """Tipo personalizzato per argparse: accetta solo interi positivi."""
    try:
        number = int(value)
    except ValueError:
        raise argparse.ArgumentTypeError(f"ID non valido: {value!r}") from None
    if number <= 0:
        raise argparse.ArgumentTypeError(f"l'ID deve essere positivo: {value}")
    return number


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="tasks",
        description="Un semplice gestore di attività da riga di comando.",
    )
    parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
    subparsers = parser.add_subparsers(dest="command", required=True, metavar="<comando>")

    add = subparsers.add_parser("add", help="aggiunge un'attività")
    add.add_argument("title", nargs="+", help="titolo dell'attività")
    add.add_argument(
        "-p", "--priority",
        choices=[p.value for p in Priority],
        default=Priority.NORMAL.value,
        help="priorità (predefinita: %(default)s)",
    )
    add.set_defaults(handler=cmd_add)

    list_ = subparsers.add_parser("list", aliases=["ls"], help="elenca le attività")
    list_.add_argument("-a", "--all", action="store_true", help="include le attività completate")
    list_.add_argument(
        "-f", "--format", choices=["table", "json"], default="table",
        help="formato di output (predefinito: %(default)s)",
    )
    list_.set_defaults(handler=cmd_list)

    done = subparsers.add_parser("done", help="segna un'attività come completata")
    done.add_argument("id", type=positive_int, help="ID dell'attività")
    done.set_defaults(handler=cmd_done)

    remove = subparsers.add_parser("rm", aliases=["remove"], help="elimina un'attività")
    remove.add_argument("id", type=positive_int, help="ID dell'attività")
    remove.set_defaults(handler=cmd_remove)

    return parser

Alcuni dettagli di questa configurazione meritano attenzione:

  • required=True nei sottoparser fa sì che tasks senza argomenti produca un errore di utilizzo invece di non fare nulla;
  • nargs="+" sul titolo permette di scrivere tasks add Comprare il latte senza virgolette;
  • choices delega ad argparse la validazione della priorità e l'elenco dei valori ammessi nel messaggio di errore;
  • set_defaults(handler=...) associa a ogni sottocomando la funzione che lo esegue: è il modo più pulito per evitare una lunga catena di if;
  • il tipo personalizzato positive_int trasforma un ID non valido in un errore di utilizzo con codice 2, come ogni altro errore di argparse.

La versione arriva da __init__.py, che la legge dai metadati del pacchetto installato. In questo modo esiste un'unica fonte di verità, il pyproject.toml:

from importlib.metadata import PackageNotFoundError, version

try:
    __version__ = version("tasks")
except PackageNotFoundError:
    # Esecuzione dai sorgenti senza installazione.
    __version__ = "dev"

I gestori dei sottocomandi

Ogni gestore riceve gli argomenti già analizzati, lo store e il flusso di output, e restituisce un codice di uscita. Passare il flusso come parametro invece di usare direttamente print verso sys.stdout rende i gestori facilmente testabili.

def cmd_add(args: argparse.Namespace, store: Store, out: TextIO) -> int:
    title = " ".join(args.title).strip()
    if not title:
        raise UsageError("il titolo non può essere vuoto")
    tasks = store.load()
    task = Task(id=Store.next_id(tasks), title=title, priority=Priority(args.priority))
    tasks.append(task)
    store.save(tasks)
    print(f"Aggiunta l'attività {task.id}: {task.title}", file=out)
    return EXIT_OK


def cmd_list(args: argparse.Namespace, store: Store, out: TextIO) -> int:
    tasks = [t for t in store.load() if args.all or not t.done]
    if args.format == "json":
        json.dump([t.to_dict() for t in tasks], out, indent=2, ensure_ascii=False)
        out.write("\n")
        return EXIT_OK
    print_table(tasks, out)
    return EXIT_OK


def cmd_done(args: argparse.Namespace, store: Store, out: TextIO) -> int:
    tasks = store.load()
    tasks[Store.find(tasks, args.id)].done = True
    store.save(tasks)
    print(f"Attività {args.id} completata.", file=out)
    return EXIT_OK


def cmd_remove(args: argparse.Namespace, store: Store, out: TextIO) -> int:
    tasks = store.load()
    del tasks[Store.find(tasks, args.id)]
    store.save(tasks)
    print(f"Attività {args.id} eliminata.", file=out)
    return EXIT_OK


class UsageError(Exception):
    """Errore di utilizzo rilevato dopo il parsing (codice di uscita 2)."""

Output per le persone e per le macchine

La tabella per l'output interattivo calcola la larghezza delle colonne dai dati stessi. Il colore va applicato dopo aver calcolato il riempimento: i codici di escape ANSI occupano caratteri ma non spazio visibile, e includerli nel calcolo romperebbe l'allineamento.

COLORS = {"high": "\033[31m", "normal": "\033[33m", "low": "\033[90m"}
RESET = "\033[0m"


def use_color(stream: TextIO) -> bool:
    """I colori sono attivi solo su un terminale e se NO_COLOR non è impostata."""
    if os.environ.get("NO_COLOR"):
        return False
    return hasattr(stream, "isatty") and stream.isatty()


def print_table(tasks: list[Task], out: TextIO) -> None:
    if not tasks:
        print("Nessuna attività.", file=out)
        return
    color = use_color(out)
    headers = ("ID", "STATO", "PRIORITÀ", "TITOLO")
    rows = [
        (str(t.id), "fatta" if t.done else "aperta", t.priority.value, t.title)
        for t in tasks
    ]
    widths = [max(len(row[i]) for row in [headers, *rows]) for i in range(3)]

    def format_row(row: tuple[str, ...], paint: bool) -> str:
        cells = [row[i].ljust(widths[i]) for i in range(3)]
        if paint:
            # Il colore avvolge la cella già riempita, così l'allineamento resta corretto.
            cells[2] = f"{COLORS[row[2]]}{cells[2]}{RESET}"
        return "  ".join([*cells, row[3]])

    print(format_row(headers, paint=False), file=out)
    for row in rows:
        print(format_row(row, paint=color), file=out)

Il formato JSON, al contrario, non deve contenere nulla di diverso dai dati: niente colori, niente intestazioni, niente messaggi informativi. Uno script deve poter fare affidamento sul fatto che tasks list --format json produca sempre un array valido, anche quando è vuoto.

La funzione main e la gestione degli errori

La funzione main è l'unico punto in cui le eccezioni vengono trasformate in messaggi per l'utente e codici di uscita. Accetta opzionalmente la lista degli argomenti, così i test possono invocarla senza toccare sys.argv.

def main(argv: list[str] | None = None) -> int:
    parser = build_parser()
    # argparse gestisce da solo gli errori di utilizzo: stampa il messaggio
    # su stderr e solleva SystemExit(2); --help e --version escono con 0.
    args = parser.parse_args(argv)
    store = Store(default_path())

    try:
        return args.handler(args, store, sys.stdout)
    except UsageError as exc:
        parser.error(f"{args.command}: {exc}")
    except StoreError as exc:
        print(f"tasks: {exc}", file=sys.stderr)
        return EXIT_FAILURE
    except KeyboardInterrupt:
        print("\ntasks: operazione interrotta", file=sys.stderr)
        return 128 + signal.SIGINT
    except BrokenPipeError:
        # L'output è stato chiuso dal lettore (ad esempio "tasks list | head -1").
        devnull = os.open(os.devnull, os.O_WRONLY)
        os.dup2(devnull, sys.stdout.fileno())
        return EXIT_FAILURE

Tre casi meritano un approfondimento.

parser.error stampa l'uso sintetico seguito dal messaggio e termina con codice 2: usarlo per gli errori di validazione successivi al parsing garantisce che tutti gli errori di utilizzo abbiano lo stesso aspetto.

KeyboardInterrupt viene sollevata da Python quando riceve SIGINT. Senza gestione, l'utente vedrebbe un traceback; con la gestione esplicita il programma termina con il codice 130, quello che la shell assegna ai processi interrotti da Ctrl+C.

BrokenPipeError è un caso tipico di Python: quando l'output viene letto da un programma che termina prima della fine, come head, la scrittura successiva fallisce. La documentazione ufficiale di Python raccomanda proprio di redirigere lo standard output su /dev/null per evitare un secondo errore durante lo svuotamento del buffer all'uscita.

Test

Con main che accetta gli argomenti e restituisce un codice, i test diventano lineari. La fixture capsys di pytest cattura standard output e standard error, mentre monkeypatch e tmp_path isolano ogni test in una directory temporanea.

import json

import pytest

from tasks.cli import EXIT_FAILURE, EXIT_OK, EXIT_USAGE, main


@pytest.fixture(autouse=True)
def isolated_store(tmp_path, monkeypatch):
    """Ogni test usa un file dati temporaneo e nessun colore."""
    monkeypatch.setenv("TASKS_FILE", str(tmp_path / "tasks.json"))
    monkeypatch.setenv("NO_COLOR", "1")


def run(capsys, *args: str) -> tuple[int, str, str]:
    """Esegue la CLI e restituisce codice di uscita, stdout e stderr."""
    try:
        code = main(list(args))
    except SystemExit as exc:
        code = exc.code if isinstance(exc.code, int) else EXIT_FAILURE
    captured = capsys.readouterr()
    return code, captured.out, captured.err


def test_add_and_list_json(capsys):
    code, out, _ = run(capsys, "add", "Scrivere", "i", "test", "--priority", "high")
    assert code == EXIT_OK
    assert "Aggiunta l'attività 1" in out

    code, out, _ = run(capsys, "list", "--format", "json")
    assert code == EXIT_OK
    tasks = json.loads(out)
    assert tasks == [
        {**tasks[0], "id": 1, "title": "Scrivere i test", "priority": "high", "done": False}
    ]


def test_completed_tasks_are_hidden_by_default(capsys):
    run(capsys, "add", "Prima")
    run(capsys, "add", "Seconda")
    run(capsys, "done", "1")

    _, out, _ = run(capsys, "list", "--format", "json")
    assert [t["id"] for t in json.loads(out)] == [2]

    _, out, _ = run(capsys, "list", "--all", "--format", "json")
    assert [t["id"] for t in json.loads(out)] == [1, 2]


@pytest.mark.parametrize(
    ("args", "expected"),
    [
        ((), EXIT_USAGE),
        (("fly",), EXIT_USAGE),
        (("add",), EXIT_USAGE),
        (("add", "x", "--priority", "urgent"), EXIT_USAGE),
        (("done", "abc"), EXIT_USAGE),
        (("done", "0"), EXIT_USAGE),
        (("done", "42"), EXIT_FAILURE),
        (("list", "--help"), EXIT_OK),
    ],
)
def test_exit_codes(capsys, args, expected):
    code, _, _ = run(capsys, *args)
    assert code == expected


def test_errors_go_to_stderr(capsys):
    code, out, err = run(capsys, "rm", "7")
    assert code == EXIT_FAILURE
    assert out == ""
    assert err.startswith("tasks: attività non trovata")


def test_corrupted_file_is_reported(capsys, tmp_path):
    (tmp_path / "tasks.json").write_text("{non json", encoding="utf-8")
    code, _, err = run(capsys, "list")
    assert code == EXIT_FAILURE
    assert "non è valido" in err
    assert "Traceback" not in err
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
pytest

L'ultimo test verifica una proprietà essenziale per la qualità percepita: anche con un file dati corrotto, l'utente riceve un messaggio comprensibile e non un traceback.

Distribuzione

Uno strumento da riga di comando scritto in Python non dovrebbe essere installato nell'ambiente Python di sistema, dove le sue dipendenze entrerebbero in conflitto con quelle di altri programmi. Le distribuzioni Linux più recenti lo impediscono esplicitamente tramite il meccanismo degli ambienti gestiti esternamente definito dalla PEP 668. Gli strumenti pensati per questo scopo creano un ambiente virtuale isolato per ogni applicazione ed espongono solo l'eseguibile nel PATH:

# Con uv
uv tool install .
uv tool install git+https://gitlab.example.com/tools/tasks.git

# Con pipx
pipx install .

# Esecuzione una tantum senza installazione permanente
uvx --from . tasks list

Per pubblicare il pacchetto su PyPI o su un registry privato, come il Package Registry di GitLab, si costruiscono la wheel e l'archivio dei sorgenti:

uv build
uv publish --publish-url https://gitlab.example.com/api/v4/projects/42/packages/pypi

Se gli utenti finali non hanno Python installato, strumenti come PyInstaller o Nuitka producono un eseguibile autonomo che include l'interprete. Il prezzo da pagare sono dimensioni nell'ordine delle decine di megabyte e un avvio più lento, oltre alla necessità di compilare separatamente su ciascun sistema operativo di destinazione.

Il tempo di avvio

Un aspetto spesso trascurato delle CLI in Python è la latenza di avvio. Ogni import ha un costo, e un programma che importa librerie pesanti a livello di modulo può impiegare centinaia di millisecondi prima di stampare anche solo l'aiuto. Python offre uno strumento per misurarlo:

python -X importtime -m tasks --help 2> import.log
sort -t'|' -k2 -n import.log | tail -5

Se un sottocomando ha bisogno di una libreria pesante, come un client HTTP o una libreria di analisi dei dati, conviene importarla all'interno della funzione che la usa invece che in testa al modulo: gli altri sottocomandi non ne pagheranno il costo.

Conclusione

Con la sola libreria standard abbiamo costruito uno strumento da riga di comando completo e robusto. argparse si è occupato di sottocomandi, validazione, aiuto e codici di uscita per gli errori di utilizzo; una gerarchia di eccezioni dedicate ha permesso di trasformare ogni errore previsto in un messaggio chiaro; la scrittura atomica protegge i dati da interruzioni; e una funzione main parametrica ha reso l'intera applicazione testabile con poche righe di pytest. Quando la complessità crescerà, il passaggio a Click o Typer riguarderà solo il modulo cli.py: lo store e i test di comportamento resteranno validi.