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 è
0in caso di successo,1per gli errori di esecuzione e2per gli errori di utilizzo; quest'ultima è anche la convenzione adottata daargparse; - 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_COLORnon è impostata; - per l'uso da script è disponibile un formato strutturato come JSON;
- un
Ctrl+Ctermina il programma in modo pulito con codice130.
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=Truenei sottoparser fa sì chetaskssenza argomenti produca un errore di utilizzo invece di non fare nulla;nargs="+"sul titolo permette di scriveretasks add Comprare il lattesenza virgolette;choicesdelega adargparsela 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 diif;- il tipo personalizzato
positive_inttrasforma un ID non valido in un errore di utilizzo con codice2, come ogni altro errore diargparse.
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.