Corso Python / Progetto finale: un gestore di spese con SQLite

Questo progetto finale è un programma da riga di comando che salva in SQLite data, categoria e importo di una spesa, mostra l’elenco e calcola i totali per categoria. Riunisce inizializzazione, controllo degli ingressi, sottocomandi e transazioni in un file completo che puoi eseguire e verificare.

Definire requisiti e regole dei dati

Usa Python 3.12 o successivo con il modulo standard sqlite3 disponibile. Salva il programma in una cartella nuova dedicata all’esercizio e seleziona il suo ambiente nel terminale. Non servono pacchetti pip. Le somme dell’esempio sono interi in unità intere di won coreani, senza conversioni di valuta o importi decimali.

Ogni importo deve essere compreso tra 0 e 1.000.000.000.000 inclusi, la data deve esistere ed essere scritta esattamente come YYYY-MM-DD e la categoria non può essere vuota o composta solo da spazi. I comandi disponibili sono init, add, list e summary. Pagamenti, collegamenti bancari e autenticazione di più utenti richiedono un progetto diverso.

add crea una nuova riga a ogni chiamata: ripetere un comando aggiunge un’altra spesa. Non è un upsert basato su un ID esterno. init, invece, crea la tabella se manca e non cancella i record esistenti. Il file predefinito ledger.db è relativo alla cartella corrente; --db permette di scegliere un percorso separato. Usa esclusivamente dati di esercitazione.

La documentazione ufficiale di sqlite3 descrive autocommit, transazioni e parametri. La lezione sulle query parametrizzate e le transazioni SQLite approfondisce la parte di archiviazione usata qui.

Salvare il programma completo

Crea il file UTF-8 ledger.py con questo contenuto:

import argparse
import sqlite3
from datetime import date
from pathlib import Path

MAX_AMOUNT = 1_000_000_000_000

def amount_value(text):
    try:
        value = int(text)
    except ValueError as error:
        raise argparse.ArgumentTypeError("expected an integer") from error
    if not 0 <= value <= MAX_AMOUNT:
        raise argparse.ArgumentTypeError("amount must be between 0 and 1000000000000")
    return value

def date_value(text):
    try:
        parsed = date.fromisoformat(text)
    except ValueError as error:
        raise argparse.ArgumentTypeError("invalid date") from error
    if parsed.isoformat() != text:
        raise argparse.ArgumentTypeError("use YYYY-MM-DD")
    return text

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--db", type=Path, default=Path("ledger.db"))
    commands = parser.add_subparsers(dest="command", required=True)
    commands.add_parser("init")
    add = commands.add_parser("add")
    add.add_argument("day", type=date_value)
    add.add_argument("category")
    add.add_argument("amount", type=amount_value)
    commands.add_parser("list")
    commands.add_parser("summary")
    args = parser.parse_args()
    if args.command != "init" and not args.db.is_file():
        parser.error("database not initialized")
    if args.command == "add" and not args.category.strip():
        parser.error("category is required")
    con = sqlite3.connect(args.db, autocommit=False)
    try:
        if args.command == "init":
            with con:
                con.execute("CREATE TABLE IF NOT EXISTS expenses (id INTEGER PRIMARY KEY, day TEXT NOT NULL, category TEXT NOT NULL CHECK(length(trim(category))>0), amount INTEGER NOT NULL CHECK(amount BETWEEN 0 AND 1000000000000))")
            print("initialized")
        elif args.command == "add":
            with con:
                con.execute("INSERT INTO expenses(day,category,amount) VALUES(?,?,?)", (args.day, args.category.strip(), args.amount))
            print("added")
        elif args.command == "list":
            for row in con.execute("SELECT id,day,category,amount FROM expenses ORDER BY day,id"):
                print(*row)
        else:
            totals = {}
            for category, amount in con.execute("SELECT category,amount FROM expenses"):
                totals[category] = totals.get(category, 0) + amount
            for category, total in sorted(totals.items()):
                print(category, total)
    finally:
        con.close()

if __name__ == "__main__":
    main()

amount_value converte il testo in intero e applica l’intervallo ammesso. date_value verifica il calendario con date.fromisoformat e confronta la rappresentazione normalizzata con l’ingresso, così rifiuta anche forme ISO alternative che non rispettano il formato richiesto. La documentazione di date.fromisoformat distingue le forme che il solo parser può accettare.

main sceglie il comando e gestisce la connessione. Prima di un’operazione diversa da init richiede che il percorso sia un file; questo controllo non dimostra però che un file qualsiasi contenga lo schema corretto. Per un database valido ma privo della tabella, la query segnalerà un errore: in questa esercitazione inizializza un nuovo file invece di indicare archivi estranei.

Inizializzare, aggiungere e consultare le spese

Dal terminale aperto nella cartella del programma esegui i comandi seguenti, uno alla volta e nell’ordine mostrato:

python ledger.py init
python ledger.py add 2026-10-17 food 3500
python ledger.py add 2026-10-17 food 8000
python ledger.py add 2026-10-17 transport 2500
python ledger.py list
python ledger.py summary

init stampa initialized; ciascun inserimento riuscito stampa added. list mostra ID, data, categoria e importo, ordinando prima per data e poi per ID. Dopo i tre inserimenti, il riepilogo completo deve essere:

food 11500
transport 2500

Le due spese food ammontano a 3.500 + 8.000 = 11.500; il trasporto ammonta a 2.500. I comandi terminano con codice 0 quando riescono. Per ripartire dall’archivio vuoto usa una cartella nuova o un nome nuovo, ad esempio python ledger.py --db other-ledger.db init. L’opzione globale --db va prima del sottocomando. Racchiudi il percorso tra virgolette nella shell se contiene spazi.

Non eliminare un database sconosciuto per ripetere la prova. Eseguire di nuovo soltanto init mantiene le tre righe; eseguire di nuovo i tre add le porta a sei. Ogni processo apre e chiude la connessione, perciò una successiva invocazione di list verifica anche la persistenza dopo la chiusura del programma precedente.

Verificare rifiuti e persistenza con un test completo

  1. Prova un inserimento con 2026-02-30, poi uno con importo -1, uno sopra il limite e uno con categoria "". Devono fallire senza aumentare l’elenco.
  2. Verifica subito il codice di uscita con $LASTEXITCODE in PowerShell o echo $? in una shell macOS/Ubuntu. Gli errori di argomenti prodotti da argparse terminano con codice 2.
  3. Riesegui list e summary sui dati validi, poi riproduci i tre inserimenti in un nuovo database per confrontare lo stesso risultato senza duplicare quelli precedenti.

Per automatizzare un controllo indipendente, salva il file completo seguente come check_ledger.py accanto a ledger.py:

import subprocess
import sys
import tempfile
from pathlib import Path
program = Path("ledger.py").resolve()
with tempfile.TemporaryDirectory() as folder:
    db = Path(folder) / "test.db"
    def run(*args):
        return subprocess.run([sys.executable, str(program), "--db", str(db), *args], capture_output=True, text=True)
    assert run("init").returncode == 0
    assert run("add", "2026-10-17", "food", "8000").returncode == 0
    assert run("add", "2026-02-30", "food", "8000").returncode != 0
    assert run("add", "2026-10-17", "food", "-1").returncode != 0
    assert run("add", "2026-10-17", "food", "1000000000001").returncode != 0
    result = run("summary")
    assert result.returncode == 0 and result.stdout.strip() == "food 8000"
    assert len(run("list").stdout.splitlines()) == 1
print("integration checks passed")

Dalla cartella contenente entrambi esegui python check_ledger.py, senza l’opzione -O che eliminerebbe queste istruzioni assert. Il controllo usa un database temporaneo e deve stampare:

integration checks passed

Il test inserisce una sola spesa da 8.000 e controlla che data impossibile, importo negativo e importo oltre il limite non creino righe aggiuntive. Il suo risultato atteso non è il riepilogo da tre spese del precedente esercizio. La cartella temporanea viene rimossa uscendo dal blocco with. Se manca ledger.py o una condizione non coincide, il controllo deve fallire; non modificare l’atteso soltanto per farlo passare.

I segnaposto SQL separano i valori dalla sintassi. Le verifiche Python e i vincoli del database si completano: il contesto della connessione conferma o annulla la transazione, mentre finally chiude la connessione. Lo schema non viene migrato automaticamente se la tabella esiste già. Le tecniche della lezione sui test automatici e di integrazione aiutano ad aggiungere verifiche per categorie vuote e altre regole quando il progetto cresce.

Separare le responsabilità quando il progetto cresce

Il flusso è: interpretazione degli argomenti, controllo di data e importo, verifica del database, operazione di scrittura o lettura, stampa. In un progetto più grande puoi separare questi compiti in moduli e mantenere test che fissino il comportamento. Conserva il blocco if __name__ == "__main__" affinché importare il modulo non crei archivi né interpreti gli argomenti del chiamante.

summary accumula interi Python invece di usare SUM di SQLite; questo evita il limite dell’aggregato intero del database su archivi molto grandi. La lettura procede per righe, ma il dizionario dei totali occupa memoria proporzionale al numero delle categorie. La documentazione SQLite di sum e total spiega perché un aggregato in virgola mobile non è una sostituzione equivalente per importi esatti.

Permessi dei file, backup e ripristino, migrazioni e accessi concorrenti richiedono decisioni aggiuntive prima di usare dati personali reali. Per valutare l’esercizio verifica insieme numeri, rifiuto degli ingressi non validi e significato delle riesecuzioni: un totale giusto da solo non prova che tutto il comportamento sia corretto.